在Java里整合Groovy脚本的一个陷阱

最近在项目里要在Java中整合Groovy脚本来粘合各个组件/服务,所以这两天在测试几种整合方法。最初是想用JSR 223系的API,不过我们这边对ClassLoader有特别需求,JSR 223的API满足不了,所以还是转而考虑Groovy自身的整合机制。

除了[url=http://jakarta.apache.org/bsf/]BSF[/url]与[url=http://www.jcp.org/en/jsr/detail?id=223]JSR 223[/url]之外,整合Groovy基本上有三种途径:[url=http://groovy.codehaus.org/api/groovy/lang/GroovyShell.html]GroovyShell[/url](以及[url=http://groovy.codehaus.org/api/groovy/util/Eval.html]Eval[/url])、[url=http://groovy.codehaus.org/api/groovy/lang/GroovyClassLoader.html]GroovyClassLoader[/url]和[url=http://groovy.codehaus.org/api/groovy/util/GroovyScriptEngine.html]GroovyScriptEngine[/url]。这些在官网的[url=http://groovy.codehaus.org/Embedding+Groovy]Embedding Groovy[/url]文档上有所描述,在几本Groovy的书里也有提及。

然而在整合Groovy脚本的时候可能会遇到一类陷阱:临时加载的类未能及时被释放,进而导致PermGen OutOfMemoryError;没那么严重的时候也会引发比较频繁的full GC从而影响稳定运行时的性能。

如果只是要执行一些Groovy脚本,那么GroovyShell看来是个不错的选择。于是用它做个小测试:
(环境在后面的截图里有写,这里就不详细说了。Windows XP SP3/Sun JDK 1.6.0u18/client默认参数/Groovy 1.7.1)
package fx.test;

import groovy.lang.GroovyShell;
import groovy.lang.Script;

import java.io.IOException;

/**
* @author sajia
*
*/
public class TestGroovyShell {
// see if the number of loaded class keeps growing when
// using GroovyShell.parse
public static void test() {
GroovyShell shell = new GroovyShell();
String scriptText = "def mul(x, y) { x * y }\nprintln mul(5, 7)";

while (true) {
Script script = shell.parse(scriptText);
Object result = script.run();
}
}

public static void main(String[] args) {
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
test();
}
}

启动这个程序,按一下回车,放着跑不到一分钟就会看到异常:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:616)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:124)
at groovy.lang.GroovyClassLoader.access$300(GroovyClassLoader.java:55)
at groovy.lang.GroovyClassLoader$ClassCollector.createClass(GroovyClassLoader.java:496)
at groovy.lang.GroovyClassLoader$ClassCollector.onClassNode(GroovyClassLoader.java:513)
at groovy.lang.GroovyClassLoader$ClassCollector.call(GroovyClassLoader.java:517)
at org.codehaus.groovy.control.CompilationUnit$11.call(CompilationUnit.java:767)
at org.codehaus.groovy.control.CompilationUnit.applyToPrimaryClassNodes(CompilationUnit.java:971)
at org.codehaus.groovy.control.CompilationUnit.doPhaseOperation(CompilationUnit.java:519)
at org.codehaus.groovy.control.CompilationUnit.processPhaseOperations(CompilationUnit.java:497)
at org.codehaus.groovy.control.CompilationUnit.compile(CompilationUnit.java:474)
at groovy.lang.GroovyClassLoader.parseClass(GroovyClassLoader.java:292)
at groovy.lang.GroovyShell.parseClass(GroovyShell.java:727)
at groovy.lang.GroovyShell.parse(GroovyShell.java:739)
at groovy.lang.GroovyShell.parse(GroovyShell.java:766)
at groovy.lang.GroovyShell.parse(GroovyShell.java:757)
at fx.test.TestGroovyShell.test(TestGroovyShell.java:20)
at fx.test.TestGroovyShell.main(TestGroovyShell.java:31)

如果在启动这个测试时加上-verbose选项,可以看到每次执行GroovyShell.parse()方法时都会打印出这样的日志:
[Loaded Script183 from file:/groovy/shell]
[Loaded Script183$mul from file:/groovy/shell]

也就是说上面测试中的脚本每次被parse()都新生成两个类,一个对应顶层代码,一个对应其中的mul()方法。在循环中调用parse()方法,不消一会儿就把HotSpot的PermGen给撑爆了;虽然执行过程中也可以看到PermGen的空间紧张经常引发full GC,而在full GC时会卸载掉许多不再有引用的类,但这个测试中卸载的速度没有生成的速度快,就杯具了。

除了类自身之外,类中的常量池所引用的字符串也都需要被intern,上面的例子中像"mul"这个名字就会被intern掉;在HotSpot中,intern的String实例也是在PermGen上分配空间的。内容相同的字符串就算被intern很多次在PermGen的字符串池里也只会有一份,不过如果连续执行很多脚本,脚本里在“成员”和“类型”级别上出现了很多不同的标识符的话,这也会对字符串池造成压力。

用JConsole可以形象的看到PermGen爆掉的过程。下面两张截图中右边骤然下降的线是在测试程序抛出异常而终止后JConsole与之连接被断开的时候的,可以忽略掉。
[img]http://dl.iteye.com/upload/attachment/220661/9f9a5389-9d03-335c-b49d-8c7087a47672.png[/img]

[img]http://dl.iteye.com/upload/attachment/220663/f9ab3c09-637a-3347-a5e2-5a722be4cf45.png[/img]

[img]http://dl.iteye.com/upload/attachment/220684/a18e36e4-cd54-3d20-81c1-e4a81ad6dd7c.png[/img]

(补一张PermGen趋势截图)
[img]http://dl.iteye.com/upload/attachment/220773/36213d56-b026-3102-a187-c50863a51cd9.png[/img]

==========================================================================

Sun JDK 1.6.0u18的HotSpot在32位Windows XP SP3上默认选用client模式,默认PermGen大小是64MB。如果在上面的测试里给入参数-XX:MaxPermSize=512m,将PermGen最大大小设置到512MB,情况会怎样呢?放着让它多跑几分钟,会看到:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOfRange(Unknown Source)
at java.lang.String.<init>(Unknown Source)
at java.lang.StringBuffer.toString(Unknown Source)
at java.net.URLStreamHandler.toExternalForm(Unknown Source)
at java.net.URL.toExternalForm(Unknown Source)
at java.net.URL.toString(Unknown Source)
at java.lang.ClassLoader.defineClassSourceLocation(Unknown Source)
at java.lang.ClassLoader.defineClass(Unknown Source)
at org.codehaus.groovy.reflection.ClassLoaderForClassArtifacts.define(ClassLoaderForClassArtifacts.java:27)
at org.codehaus.groovy.reflection.ClassLoaderForClassArtifacts$1.run(ClassLoaderForClassArtifacts.java:71)
at org.codehaus.groovy.reflection.ClassLoaderForClassArtifacts$1.run(ClassLoaderForClassArtifacts.java:69)
at java.security.AccessController.doPrivileged(Native Method)
at org.codehaus.groovy.reflection.ClassLoaderForClassArtifacts.defineClassAndGetConstructor(ClassLoaderForClassArtifacts.java:69)
at org.codehaus.groovy.runtime.callsite.CallSiteGenerator.compilePojoMethod(CallSiteGenerator.java:227)
at org.codehaus.groovy.reflection.CachedMethod.createPojoMetaMethodSite(CachedMethod.java:244)
at org.codehaus.groovy.runtime.callsite.PojoMetaMethodSite.createCachedMethodSite(PojoMetaMethodSite.java:158)
at org.codehaus.groovy.runtime.callsite.PojoMetaMethodSite.createPojoMetaMethodSite(PojoMetaMethodSite.java:147)
at groovy.lang.MetaClassImpl.createPojoCallSite(MetaClassImpl.java:2994)
at org.codehaus.groovy.runtime.callsite.CallSiteArray.createPojoSite(CallSiteArray.java:114)
at org.codehaus.groovy.runtime.callsite.CallSiteArray.createCallSite(CallSiteArray.java:148)
at org.codehaus.groovy.runtime.callsite.CallSiteArray.defaultCall(CallSiteArray.java:40)
at org.codehaus.groovy.runtime.callsite.AbstractCallSite.call(AbstractCallSite.java:117)
at org.codehaus.groovy.runtime.callsite.AbstractCallSite.call(AbstractCallSite.java:125)
at org.codehaus.groovy.ast.builder.AstBuilderInvocationTrap.visitMethodCallExpression(AstBuilderTransformation.groovy:179)
at org.codehaus.groovy.ast.expr.MethodCallExpression.visit(MethodCallExpression.java:67)
at org.codehaus.groovy.ast.CodeVisitorSupport.visitExpressionStatement(CodeVisitorSupport.java:69)
at org.codehaus.groovy.ast.stmt.ExpressionStatement.visit(ExpressionStatement.java:40)
at org.codehaus.groovy.ast.CodeVisitorSupport.visitBlockStatement(CodeVisitorSupport.java:35)
at org.codehaus.groovy.ast.stmt.BlockStatement.visit(BlockStatement.java:51)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)


如果看JConsole监视到的类加载状况,会看到:
[img]http://dl.iteye.com/upload/attachment/220766/8a831fab-afaa-3437-84db-7e4ea6f22d90.png[/img]
右边陡然下降的曲线跟上一个测试一样是在抛了异常之后的部分,可以忽略。

中间一段看起来很平,看来是没问题?
其实不然。如果结合程序的执行速度与GC消耗的时间来看,会发现加载类的数量的曲线比较平的这段时间里,上面测试代码的每轮循环都要等很久才会输出一个35,而大部分时间都消耗在了full GC上;这是由于“[b]某种原因[/b]”(*)使得GC堆的年老代非常满,于是稍微分配一点空间就要触发full GC。最终GC堆还是没撑住,就爆了。
也就是说这次没有让PermGen爆掉只不过是因为瓶颈转移到别的部分了而已。

[color=red]*:这个“某种原因”以后或许会发篇帖分析一下。这篇就只谈谈现象吧。[/color]

==========================================================================

GroovyShell上的几个方法都有同样的问题,像是evaluate()的各个重载、parse(),还有Eval.me()/x/xy()/xyz()这些方法都一样。

当然,在上面的测试中只要把shell.parse(scriptText);这句移到循环的外面就可以避免撑爆PermGen的问题——因为只调用了一次parse()方法,相应的也就只生成了对应的那些新的类。
于是这里就有个启示:如果嵌入GroovyShell的场景需要经常执行Groovy脚本,那么或许应该通过weak cache来检查先前是不是已经处理过当前输入的脚本,没处理过的时候才去调用GroovyShell.parse()并将脚本记录到weak cache里。

==========================================================================

如果GroovyShell可能导致PermGen问题,那GroovyClassLoader是不是也一样会呢?换用下面的代码来测试的话:

package fx.test;

import groovy.lang.GroovyClassLoader;

import java.io.IOException;

/**
* @author sajia
*
*/
public class TestGroovyClassLoader {
// see if the number of loaded class keeps growing when
// using GroovyClassLoader.parseClass
public static void test() {
GroovyClassLoader loader = new GroovyClassLoader();
String scriptText = "class Foo {\n"
+ " int add(int x, int y) { x + y }\n"
+ "}";

Class<?> clazz = null;
while (true) {
Class<?> newClazz = loader.parseClass(scriptText);
if (clazz == newClazz) {
System.out.println("class cached");
break;
}
clazz = newClazz;
}
}

public static void main(String[] args) {
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
test();
}
}

却发现它跑起来没导致PermGen OOM。

同样看看JConsole监控的截图:
[img]http://dl.iteye.com/upload/attachment/220686/28f96b75-150f-3be8-9df8-9799639c1655.png[/img]

[img]http://dl.iteye.com/upload/attachment/220688/34bdd6ee-2326-3ff9-afe2-c8d661c2836a.png[/img]

可以看到虽然被加载的类仍然非常多,但多数都及时被卸载了所以PermGen能动态维持在一个不太满的水平上。
观察-verbose得到的日志,可以看到上面例子中每次调用GroovyClassLoader.parseClass()只生成并加载了一个类:
[Loaded Foo from file:/groovy/script]


虽然每次生成并加载的类的数量比GroovyShell.parse()的少,但这个测试总觉得缺了点什么。对,没对那些新生成的类生成过实例。那么改一下,加上对Class.newInstance()的调用:
Class<?> clazz = null;
while (true) {
Class<?> newClazz = loader.parseClass(scriptText);
try {
newClazz.newInstance(); // make new instance!
} catch (Exception e) {
e.printStackTrace();
}
if (clazz == newClazz) {
System.out.println("class cached");
break;
}
clazz = newClazz;
}

则类加载与PermGen的表现又有所不同了:
[img]http://dl.iteye.com/upload/attachment/220719/46cee90e-4ba1-3219-98a4-3a184a39885d.png[/img]

[img]http://dl.iteye.com/upload/attachment/220722/a3955a03-36f4-3c10-8616-4e3e9d1b22aa.png[/img]
虽然还是没有因为PermGen而OOM,但PermGen的压力明显比不调用newInstance()时高了些。

接下来,模仿我们这边已有的一个项目里对Groovy的用法,加上对新生成的实例的方法调用再来测试一下:
public static void test() {
String scriptText = "class Foo {\n"
+ " int add(int x, int y) { x + y }\n"
+ "}";

Class<?> clazz = null;
while (true) {
GroovyClassLoader loader = new GroovyClassLoader();
Class<?> newClazz = loader.parseClass(scriptText);
try {
Object obj = newClazz.newInstance();
Object i = obj.getClass()
.getMethod("add", int.class, int.class)
.invoke(obj, 2, 3);
} catch (Exception e) {
e.printStackTrace();
}
if (clazz == newClazz) {
System.out.println("class cached");
break;
}
clazz = newClazz;
}
}

结果也还正常,跑了十几分钟都没有OOM,也没有表现出OOM的倾向。Good。

==========================================================================

说来GroovyShell里还特别写了注释说不缓存脚本:
    private Class parseClass(final GroovyCodeSource codeSource) throws CompilationFailedException {
// Don't cache scripts
return loader.parseClass(codeSource, false);
}

不乱缓存东西或许也算是一种美德吧……?

GroovyShell.parse()内部其实也就是调用GroovyClassLoader.parseClass()去解析Groovy脚本并生成Class实例(会是groovy.lang.Script的子类),然后调用Class.newInstance()构造出一个新的实例以Script类型的引用返回出来。

既然它默认不缓存东西,怎么上面的例子里用它就会PermGen OOM而直接用GroovyClassLoader就没事呢?看来是两个例子中脚本的内容不同带来了差异。不过换成下面的版本来测却并没出问题:
package fx.test;

import groovy.lang.GroovyClassLoader;
import groovy.lang.Script;

import java.io.IOException;

/**
* @author sajia
*
*/
public class TestGroovyClassLoader {
// see if the number of loaded class keeps growing when
// using GroovyClassLoader.parseClass
public static void test() {
String scriptText = "def mul(x, y) { x * y }\nprintln mul(5, 7)";

while (true) {
GroovyClassLoader loader = new GroovyClassLoader();
Class<?> newClazz = loader.parseClass(scriptText);
try {
Object obj = newClazz.newInstance();
Script script = (Script) obj;
script.run();
} catch (Exception e) {
e.printStackTrace();
}
}
}

public static void main(String[] args) {
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
test();
}
}


用GroovyShell的时候什么地方挂住了什么不该挂住的引用么……?
下次再找原因吧……
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值