groovy腳本導致FullGC問題排查

背景: 
應用中內嵌了groovy引擎,會動態執行傳入的表達式並返回執行結果 
線上問題:

  • 發現機器的fullGC從某個時候開始暴漲,並且一直持續;
  • 登到機器上,用jstat -gcutil 命令觀察,發現perm區一直是100%,fullGC無法回收;
  • 將這臺機器的內存dump出來進行分析;
  • 在類視圖中,發現大量的groovy.lang.GroovyClassLoader$InnerLoader;
  • 在類加載器視圖裏面也看到大量的groovy的InnerLoader;
  • 基本上可以定位問題在groovy腳本的加載處;

    初步的問題分析:

groovy每執行一次腳本,都會生成一個腳本的class對象,並new一個InnerLoader去加載這個對象,而InnerLoader和腳本對象都無法在fullGC的時候被回收,因此運行一段時間後將PERM佔滿,一直觸發fullGC。

因此,跟了一下groovy的編譯腳本的源碼

腳本編譯的入口是GroovyShell的parse方法:

public Script parse(GroovyCodeSource codeSource)    throws CompilationFailedException
 {
 return InvokerHelper.createScript(parseClass(codeSource), this.context);
}

所有的腳本都是由GroovyClassLoader加載的,每次加載腳本都會生成一個新的InnerLoader去加載腳本,但InnerLoader只是繼承GroovyClassLoader,加載腳本的時候,也是交給GroovyClassLoader去加載:

創建新的innerLoader:

InnerLoader loader = (InnerLoader)AccessController.doPrivileged(new PrivilegedAction() {
public GroovyClassLoader.InnerLoader run() {
return new GroovyClassLoader.InnerLoader(GroovyClassLoader.this);
     }
   });

innerLoader繼承GroovyClassLoader:

 public static class InnerLoader extends GroovyClassLoader {
    private final GroovyClassLoader delegate;
   private final long timeStamp;

    public InnerLoader(GroovyClassLoader delegate) {
     super();
       this.delegate = delegate;
      this.timeStamp = System.currentTimeMillis();
   }

innerLoader的類加載是交給GroovyClassLoader進行的:

public Class loadClass(String name, boolean lookupScriptFiles, boolean preferClassOverScript, boolean resolve) throws ClassNotFoundException, CompilationFailedException {
    Class c = findLoadedClass(name);
   if (c != null) return c;
      return this.delegate.loadClass(name, lookupScriptFiles, preferClassOverScript, resolve);
     }

GroovyClassLoader的類加載:

private Class doParseClass(GroovyCodeSource codeSource) {
     validate(codeSource);
    CompilationUnit unit = createCompilationUnit(this.config, codeSource.getCodeSource());
    SourceUnit su = null;
     File file = codeSource.getFile();
    if (file != null) {
      su = unit.addSource(file);
    } else {
       URL url = codeSource.getURL();
      if (url != null) {
         su = unit.addSource(url);
       } else {
        su = unit.addSource(codeSource.getName(), codeSource.getScriptText());
}
   }
    ClassCollector collector = createCollector(unit, su);
     unit.setClassgenCallback(collector);
    int goalPhase = 7;
   if ((this.config != null) && (this.config.getTargetDirectory() != null)) goalPhase = 8;
    unit.compile(goalPhase);
    Class answer = collector.generatedClass;
     String mainClass = su.getAST().getMainClassName();
     for (Object o : collector.getLoadedClasses()) {
      Class clazz = (Class)o;
       String clazzName = clazz.getName();
       definePackage(clazzName);
      setClassCacheEntry(clazz);
      if (clazzName.equals(mainClass)) answer = clazz;
    }
    return answer;
}

使用InnerLoader加載腳本的原因參見groovy的classloader加載原理,總結的原因如下,但是在這次的線上問題中,雖然用新創建的InnerLoader加載腳本,但是fullGC的時候,腳本對象和InnerLoader都無法被回收:

  • 由於一個ClassLoader對於同一個名字的類只能加載一次,如果都由GroovyClassLoader加載,那麼當一個腳本里定義了C這個類之後,另外一個腳本再定義一個C類的話,GroovyClassLoader就無法加載了。
  • 由於當一個類的ClassLoader被GC之後,這個類才能被GC,如果由GroovyClassLoader加載所有的類,那麼只有當GroovyClassLoader被GC了,所有這些類才能被GC,而如果用InnerLoader的話,由於編譯完源代碼之後,已經沒有對它的外部引用,除了它加載的類,所以只要它加載的類沒有被引用之後,它以及它加載的類就都可以被GC了。

InnerLoader的依賴路徑:

groovy.lang.GroovyClassLoader$InnerLoader@18622f3  
groovy.lang.GroovyClassLoader@147c1db  
org.codehaus.groovy.tools.RootLoader@186db54  
sun.misc.Launcher$AppClassLoader@192d342  
sun.misc.Launcher$ExtClassLoader@6b97fd  

這裏有個問題,JVM滿足GC的條件:

JVM中的Class只有滿足以下三個條件,才能被GC回收,也就是該Class被卸載(unload):

  • 該類所有的實例都已經被GC,也就是JVM中不存在該Class的任何實例。
  • 加載該類的ClassLoader已經被GC。
  • 該類的java.lang.Class 對象沒有在任何地方被引用,如不能在任何地方通過反射訪問該類的方法.

逐條檢查GC的條件:

  • Groovy會把腳本編譯爲一個名爲Scriptxx的類,這個腳本類運行時用反射生成一個實例並調用它的MAIN函數執行,這個動作只會被執行一次,在應用裏面不會有其他地方引用該類或它生成的實例。

groovy執行腳本的代碼:

final GroovyObject object = (GroovyObject) scriptClass
                        .newInstance();
                if (object instanceof Script) {
                    script = (Script) object;
                } else {
                    // it could just be a class, so lets wrap it in a Script
                    // wrapper
                    // though the bindings will be ignored
                    script = new Script() {
                        public Object run() {
                            Object args = getBinding().getVariables().get("args");
                            Object argsToPass = EMPTY_MAIN_ARGS;
                            if(args != null && args instanceof String[]) {
                                argsToPass = args;
                            }
                            object.invokeMethod("main", argsToPass);
                            return null;
                        }
                    };
                    setProperties(object, context.getVariables());
                }

  • 上面已經講過,Groovy專門在編譯每個腳本時new一個InnerLoader就是爲了解決GC的問題,所以InnerLoader應該是獨立的,並且在應用中不會被引用;

只剩下第三種可能:

  • 該類的Class對象有被引用

進一步觀察內存的dump快照,在對象視圖中找到Scriptxx的class對象,然後查看它在PERM代的被引用路徑以及GC的根路徑。

發現Scriptxxx的class對象被一個HashMap引用,如下:

classCache groovy.lang.GroovyClassLoader

發現groovyClassLoader中有一個class對象的緩存,進一步跟下去,發現每次編譯腳本時都會在Map中緩存這個對象,即:

setClassCacheEntry(clazz);

再次確認問題原因:

每次groovy編譯腳本後,都會緩存該腳本的Class對象,下次編譯該腳本時,會優先從緩存中讀取,這樣節省掉編譯的時間。這個緩存的Map由GroovyClassLoader持有,key是腳本的類名,而腳本的類名在不同的編譯場景下(從文件讀取腳本/從流讀取腳本/從字符串讀取腳本)其命名規則不同,當傳入text時,class對象的命名規則爲:

"script" + System.currentTimeMillis() + Math.abs(text.hashCode()) + ".groovy"

因此,每次編譯的對象名都不同,都會在緩存中添加一個class對象,導致class對象不可釋放,隨着次數的增加,編譯的class對象將PERM區撐滿。

爲了進一步證明是Groovy的腳本加載導致的,在本地進行模擬,分別測試不停加載groovy腳本和不停加載普通對象時,內存和GC的狀態:

加載Groovy腳本的代碼:

public void testMemory() throws Throwable {
        while (true) {
            for (int i = 0; i < 10000; i++) {
                testExecuteExpr();
            }
            Thread.sleep(1000);
            System.gc();
        }
    }

加載普通對象的代碼:

public void testCommonMemory() throws InterruptedException {
    while (true) {
        for (int i = 0; i < 10000; i++) {
            com.alipay.baoxian.trade.util.groovy.test.Test test = new com.alipay.baoxian.trade.util.groovy.test.Test() {

                public void test() {
                }
            };
            test.test();
        }
        Thread.sleep(1000);
    }
}

運行一段時間以後,加載groovy腳本的JAVA進程由於OOM被crash掉了,而加載普通對象的JAVA進程可以一直運行。

加上JVM參數,把類加載卸載的信息以及GC的信息打出來: 
-XX:+TraceClassLoading 
-XX:+TraceClassUnloading 
-XX:+CMSClassUnloadingEnabled 
-Xloggc:*/gc.log 
-XX:+PrintGCDetails 
-XX:+PrintGCDateStamps

觀察GC的log,發現groovy運行時fullGC是幾乎無法回收PERM區,而另一個可以正常回收。

groovy的gc日誌:

[Full GC 2015-03-11T20:48:23.090+0800: 50.168: [CMS: 44997K->44997K(458752K), 0.2805613 secs] 44997K->44997K(517760K), [CMS Perm : 83966K->83966K(83968K)], 0.2806654 secs] [Times: user=0.28 sys=0.00, real=0.28 secs]

修改代碼,在每次執行腳本前清空緩存:

shell.getClassLoader().clearCache();

GroovyClassLoader有提供清空緩存的方法,直接調用就可以了,再次執行,這次FullGC可以正常的回收內存了:

[Full GC 2015-03-11T19:42:22.908+0800: 143.055: [CMS: 218134K->33551K(458752K), 0.4226301 secs] 218134K->33551K(517760K), [CMS Perm : 83967K->25740K(83968K)], 0.4227156 secs] [Times: user=0.42 sys=0.00, real=0.43 secs]

解決該問題的方法:

之前對groovy做過簡單的性能測試,解釋執行時Groovy的耗時是編譯執行耗時的三倍。大多數的情況下,Groovy都是編譯後執行的,實際在本次的應用場景中,雖然是腳本是以參數傳入,但其實大多數腳本的內容是相同的,所以我覺得應該修改Groovy對腳本類進行命名的方式,保證相同的腳本每次得到的命名都是相同的,這樣在Groovy中就不會出現每次都新增一個class對象的方式,然後定時進行緩存清理,去掉長期不再執行的腳本,在腳本總數在一定數量限制的前提下,應該可以解決掉Groovy的PERM被佔滿的問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章