Groovy性能优化三部曲

给Java加点料

最近在研究服务的动态校验规则,在厌倦了使用Java的静态语言进行校验时,开始尝试使用可以运行在JVM的脚本语言(Groovy,Scala,Clojure,JRuby,JPython)进行规则的DSL开发,在运行期通过改变DSL来动态的改变规则来达到动态校验的目的,通过一段时间的调研最终选型使用Groovy进行开发。
选用Groovy的原因主要有:

  • 学习成本较低
  • 元编程能力较强
  • Gradle的流行
  • 函数式编程

关于运行在JVM的脚步语言可以参考一下这几篇文章:五大基于JVM的脚本语言Java下一代9个杀手级JVM编程语言

GroovyDSL语法和校验API都准备就绪后就开始了在Java应用中集成,整个集成到最后优化完差不多耗时2个星期,过程相当的波折,下面就记录一下在集成Groovy中遇到问题到处理问题的三部曲。

三重境界

古今之成大事业、大学问者,必经过三种之境界:”昨夜西风凋碧树。独上高楼,望尽天涯路。”此第一境也。”衣带渐宽终不悔,为伊消得人憔悴。”此第二境也。”众里寻他千百度,蓦然回首,那人却在灯火阑珊处。”此第三境也。此等语皆非大词人不能道。然遽以此意解释诸词,恐为晏欧诸公所不许也。” —— 王国维《人间词话》

一部

“昨夜西风凋碧树。独上高楼,望尽天涯路。”

集成的第一步就是在Java代码中嵌入Groovy代码,在经过各种资料查阅之后分析到几种可以在Java代码中使用Groovy。

代码集成方式

1.使用groovy.util.Eval

groovy.util.Eval的使用比较简单,直接调用Eval的me方法即可,groovy.util.Eval是Groovy的Jar包中的工具类,依赖了Groovy的jar包就可以。

1
2
3
4
5
6
public class EvalTest {
public static void main(String[] args) {
System.out.println(Eval.me("33*3"));
System.out.println(Eval.me("'foo'.toUpperCase()"));
}
}
2.使用GroovyShell

groovy.lang.GroovyShell 类是建议采用的脚本计算方式,因为它具有缓存结果脚本实例的能力。虽然 Eval 类能够返回编译脚本的执行结果,但 GroovyShell 类却能提供更多选项。

1
2
3
4
5
6
7
8
9
10
11
public class GroovyShellTest {
public static void main(String[] args) {
GroovyShell shell = new GroovyShell();

System.out.println(shell.evaluate("3*5"));

Script script = shell.parse("3*5");

System.out.println(script.run());
}
}
3.使用GroovyClassLoader

GroovyClassLoader功能和Java中的ClassLoader功能类似,但可以加载类或脚本,持有一个它所创建的所有类的引用,因此很容易造成内存泄露,尤其当你两次执行同一脚本时,比如一个字符串,那么你将获得两个不同的类,因此做脚本的缓存是必不可少的。

1
2
3
4
5
6
7
8
9
public static void main(String[] args) throws IllegalAccessException, InstantiationException {
GroovyClassLoader classLoader = new GroovyClassLoader();
Class<? extends GroovyObject> clazz = classLoader.parseClass("class Foo { void doIt() { println \"ok\" } }");

GroovyObject obj =clazz.newInstance();

obj.invokeMethod("doIt",null);

}
4.使用GroovyScriptEngine

GroovyScriptEngine从指定的位置(文件系统,URL,数据库等等)加载Groovy脚本,并且随着脚本变化可重新加载它们。和GroovyShell一样,GroovyScriptEngine也可以传进变量值返回脚本的计算结果。这样我们可以把一些可用的计算公式或计算条件写入Groovy脚本中来执行应用计算。当这些公式或计算条件变更时,我们可更方便地进行更改计算。
例如:

SimpleScript.groovy

1
2
println "Welcome to $language"
return "The End"

ScriptEngineTest.java

1
2
3
4
5
6
7
8
9
10
11
public class ScriptEngineTest {
public static void main(String args[]) throws ResourceException, ScriptException, IOException {
String[] roots = new String[]{"/Users/liuxinyu/code/github/Learning-code-demo/groovy-learning/src/main/java/org/bugkillers/groovy/test/zhenghe/"};//定义Groovy脚本引擎的根路径
GroovyScriptEngine engine = new GroovyScriptEngine(roots);
Binding binding = new Binding();
binding.setVariable("language", "Groovy");
Object value = engine.run("SimpleScript.groovy", binding);
assert value.equals("The End");

}
}
5.使用CompilationUnit

我们甚至直接依靠 org.codehaus.groovy.control.CompilationUnit 类在编译时执行更多的指令。该类负责确定编译的各种步骤,可以让我们引入更多新的步骤,或者甚至停止各种编译阶段。比如说在联合编译器中如何生成存根。
但是,不建议重写 CompilationUnit,如果没有其他的办法时才应该这样做。

问题

在本次接入使用的是第三种使用GroovyClassLoader来解析脚本,并将生成后的脚本class进行缓存后面再重新使用,在这块遇到的问题是程序运行一段时间后突然爆出JVMperm使用达到100%并且gc无法回收,最终导致服务down掉,于是开始分析日志。
发现到都是在Groovy的ClassLoader在load class的时候发生的

于是在仔细分析GroovyClassLoader,发现loader每次加载class都会生成一个脚本对应的class对象,并new一个InnerLoader去加载这个对象,而InnerLoader和脚本对象都无法在gc的时候被回收运行一段时间后将perm占满,一直触发fullgc。

在分析一下class为什么没有被卸载,我们都知道,在JVM中想要将JVM中的类卸载必须满足这几个条件:

  1. 该类所有的实例都已经被GC,也就是JVM中不存在该Class的任何实例;
  2. 加载该类的ClassLoader已经被GC;
  3. 该类的java.lang.Class对象没有在任何地方被引用,如不能在任何地方通过反射访问该类的方法。

然而在本次开发中错误的使用了ClassLoader,为了减少内存消耗将其定义成了一个全局的GroovyClassLoader,那么这个全局的Loader是在整个JVM的声明周期中都是存活了,所以只要是加载过一次的脚本的class回收都要前置于这个全局的Loader回收,同时在GroovyClassLoader代码中有一个class对象的缓存,进一步跟下去,发现每次编译脚本时都会在Map中缓存这个对象,即:setClassCacheEntry(clazz)。每次groovy编译脚本后,都会缓存该脚本的Class对象,下次编译该脚本时,会优先从缓存中读取,这样节省掉编译的时间。这个缓存的Map由GroovyClassLoader持有,key是脚本的类名,这就导致每个脚本对应的class对象都存在引用,无法被gc清理掉。

一个ClassLoader对于同一个名字的类只能加载一次,都由GroovyClassLoader加载,那么当一个脚本里定义了FOO这个类之后,另外一个脚本再定义一个FOO类的话,GroovyClassLoader就无法加载了。为什么这里会每次执行都会加载?
这是因为对于同一个groovy脚本,groovy执行引擎都会不同的命名,且命名与时间戳有关系。当传入text时,class对象的命名规则为:"script" + System.currentTimeMillis() + Math.abs(text.hashCode()) + ".groovy"。这就导致就算groovy脚本未发生任何变化,每次执行parse方法都会新生成一个脚本对应的class对象,且由GroovyClassLoader进行加载,不断增大perm区。

最终结论

  • 单独的脚本加载请使用单独的ClassLoader,同时对生成的class对象缓存
  • 不必要在GroovyClassLoaderparse class的时候加入时间戳,这样反而影响加载的效率

关于GroovyClassLoader可以看一下这篇文章Groovy深入探索——Groovy的ClassLoader体系

二部

“衣带渐宽终不悔,为伊消得人憔悴。”

问题

在经历了一次优化之后性能在一部分上得到了提升,但是好景不长,在逐渐加大程序的访问的QPS,突然又出现了FullGC现象并且服务不可用,于是立即登录服务器查看JVM状态。

使用jstat 查看当前gc情况

再使用jstat 查看gc发生的原因

通过上面两个数据(图一中第四列P标示perm区已经使用的百分比,从图中看一直是99%,图二中最后一列是GCC - gc cause也就是发生gc的原因也是Permanent Generation Full)可以推断到引起fullGC的仍然是Perm区,于是考虑到是否是Perm区太小导致容易内存打满。

于是再使用jmap 查看heap情况的perm区情况

发现Perm区大小为默认值大小82M左右,80多M的perm区竟然全都被打满并且无法被回收,这显然不正常。

关于java 1.7和1.6中Perm区的默认大小为82M请参考oracle的官网

Almost every JVM nowadays uses a separate region of memory, called the Permanent Generation (orPermGen for short), to hold internal representations of java classes. PermGen is also used to store more information – find out the details from this post if you are interested – but for our article it is safe to assume that only the class definitions are being stored in PermGen. The default size of this region on my two machines running java 1.7 is not a very impressive 82MB.

有过第一次的FullGC的问题修复的经验之后,推断还是在Java使用GroovyClassLoader加载Groovy脚本的时候发生了问题,于是想要通过一个小的demo来复现一下。

测试脚本:ScriptRule.groovy

1
2
3
4
5
class ScriptRule {
def exec(){
println('script rule exce')
}
}

测试Java类:ClassLoaderTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ClassLoaderTest {

public static void main(String[] args) throws IOException, InterruptedException, IllegalAccessException, InstantiationException {
while (true) {
String path = "/xxx/ScriptRule.groovy";
GroovyClassLoader classLoader = new GroovyClassLoader(ClassLoaderTest.class.getClassLoader());
Class helloClass = classLoader.parseClass(new File(path));
GroovyObject instance = (GroovyObject) helloClass.newInstance();

instance.invokeMethod("exec", null);
Thread.sleep(100);
}
}
}

指定VM参数:-XX:+TraceClassLoading -XX:+TraceClassUnloading -XX:+CMSClassUnloadingEnabled -Xloggc:./gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:PermSize=15m -XX:MaxPermSize=20m -Xms100m -Xmx100m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof -XX:+UseConcMarkSweepGC

由VM参数(-XX:MaxPermSize=20m)可见Perm区的最大值调整为20M以方便复现问题,同时在Java代码中通过一个死循环来不断的初始化脚本并调用脚本中方法。

经过一次测试后分析了一下GC log,果然出现了Perm区溢出,和上述问题中的现象一模一样。于是带着问题开始查找解决方案,最终找到一个比较相似的问题Groovy update causing a ton of dead GroovyClassLoaders in PermGen,尝试了一下SO上给出的解决方法,在Groovy运行时加入了一个参数System.setProperty("groovy.use.classvalue","true"); 或者在JVM启动时加入-Dgroovy.use.classvalue=true,加入这参数后发现当不断的通过GroovyClassLoader加载新的脚本类时在占用率快要达到100%后又被gc掉了。

三部

“众里寻他千百度,蓦然回首,那人却在灯火阑珊处。”

Groovy版本:

参考文章列表