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的依赖路径:

[email protected]
[email protected]
[email protected]
[email protected]
[email protected]

这里有个问题,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原理浅析

时间: 2024-08-29 07:10:05

groovy脚本导致的FullGC问题的相关文章

ElasticSearch Groovy脚本远程代码执行漏洞

什么是ElasticSearch? 它是一种分布式的.实时性的.由JAVA开发的搜索和分析引擎. 2014年,曾经被曝出过一个远程代码执行漏洞(CVE-2014-3120),漏洞出现在脚本查询模块,由于搜索引擎支持使用脚本代码(MVEL),作为表达式进行数据操作,攻击者可以通过MVEL构造执行任意java代码,后来脚本语言引擎换成了Groovy,并且加入了沙盒进行控制,危险的代码会被拦截,结果这次由于沙盒限制的不严格,导致远程代码执行任意命令..."任意"你懂的,比如:利用nc反弹sh

Runtime.getRuntime.exec()执行linux脚本导致程序卡死有关问题

Runtime.getRuntime.exec()执行linux脚本导致程序卡死问题问题: 在Java程序中,通过Runtime.getRuntime().exec()执行一个Linux脚本导致程序被挂住,而在终端上直接执行这个脚本则没有任何问题.原因: 先来看Java代码: public final static void process1(String[] cmdarray) {        Process p = null;        BufferedReader br = null

【java web】java执行预编译Groovy脚本

在JVM中运行Groovy类有两种方式: 使用Groovy编译所有的*.groovy为java的*.class文件,把这些*.class文件放在java类路径中,通过java类加载器来加载这些类. 通过groovy类加载器在运行时直接加载*.groovy文件并生成对象.在这种方式下,没有生成任何*.class,但是生成了一个java.lang.Class对象的实例. 下面介绍前一种使用Groovy的方法:编译成java字节码并且作为正常java应用程序运行在java虚拟机上,即预编译模式. 1.

Java执行groovy脚本

1 Binding binding = new Binding(); 2 binding.setVariable("foo", new Integer(2)); 3 GroovyShell shell = new GroovyShell(binding); 4 5 String script = "import com.myb.to.infrastructure.Md5Util; " 6 + "def a = 12; println 'C# md5:' +

Elasticsearch中使用groovy脚本处理boolean字段的一个问题

Elasticsearch中使用groovy脚本获取文档的bool字段值时,得到的值是字符的 'T' 或者 'F' ,而不是bool值 true 和 false . 比如文档中有一个字段是 { "bool_value":true } 然后用这个字段判断时候得写成 if (doc['bool_value'].value == 'T') { //blahblah } else { //blahblah }

ODI 12c中使用Groovy脚本创建工程

本文主要介绍在ODI中使用groovy创建工程,并添加一个表转换的映射.要创建groovy脚本,可以从ODI Studio的菜单:工具->Groovy->新脚本 打开一个编辑窗口.在执行下面的脚本之前,确认在模型中已经有EMP表. USERSRC USERDEST import oracle.odi.domain.project.OdiProject import oracle.odi.domain.project.finder.IOdiProjectFinder import oracle.

JAVA嵌入运行Groovy脚本

一.GroovyShell代码样例 1) 简单的表达式执行,方法调用 /**  * 简答脚本执行  * @throws Exception  */ public static void evalScriptText() throws Exception{ //groovy.lang.Binding Binding binding = new Binding(); GroovyShell shell = new GroovyShell(binding); binding.setVariable("

JAVA与groovy脚本的结合使用

java执行groovy shell脚本 <dependency> <groupId>org.codehaus.groovy</groupId> <artifactId>groovy-all</artifactId> <version>2.4.0-beta-1</version> </dependency> 添加groovy jar包支持 ====================================

SoapUI 引用第三方jar包和引用Groovy脚本

SoapUI仅支持JavaScript脚本和Groovy脚本,我们项目中要引用第三方jar包,那么如何操作呢?  百度上关于SoapUI引用第三方jar包的资料实在太少了. 下面是详细的介绍: 一.首先是jar包的代码以及生成jar包的名称: package是com.test.demo import java.util.*; public class DemoJAR { public String outp() { return "This is a demo!"; } public