這個是由一個線上問題致使的:java
背景:
應用中內嵌了groovy引擎,會動態執行傳入的表達式並返回執行結果
線上問題:shell
基本上能夠定位問題在groovy腳本的加載處;緩存
初步的問題分析:安全
groovy每執行一次腳本,都會生成一個腳本的class對象,並new一個InnerLoader去加載這個對象,而InnerLoader和腳本對象都沒法在fullGC的時候被回收,所以運行一段時間後將PERM佔滿,一直觸發fullGC。app
所以,跟了一下groovy的編譯腳本的源碼:函數
腳本編譯的入口是GroovyShell的parse方法:性能
public Script parse(GroovyCodeSource codeSource) throws CompilationFailedException { return InvokerHelper.createScript(parseClass(codeSource), this.context); }
全部的腳本都是由GroovyClassLoader加載的,每次加載腳本都會生成一個新的InnerLoader去加載腳本,但InnerLoader只是繼承GroovyClassLoader,加載腳本的時候,也是交給GroovyClassLoader去加載:測試
建立新的innerLoader:this
InnerLoader loader = (InnerLoader)AccessController.doPrivileged(new PrivilegedAction() { public GroovyClassLoader.InnerLoader run() { return new GroovyClassLoader.InnerLoader(GroovyClassLoader.this); } });
innerLoader繼承GroovyClassLoader:url
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被佔滿的問題。
參考連接
JAVA安全模型
實例示範
groovy的classloader加載原理
深刻探討JAVA類加載器
JAVA類加載原理淺析
JAVA類加載器淺析
ClassLoader原理淺析