这个是由一个线上问题致使的: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原理浅析