天天看点

刨根问底:Java动态运行Groovy可能有哪些坑

作者:Java码农之路

Groovy特性

Apache的Groovy是Java平台上设计的面向对象编程语言。这门动态语言拥有类似Python、Ruby和Smalltalk中的一些特性,可以作为Java平台的脚本语言使用,Groovy代码动态地编译成运行于Java虚拟机(JVM)上的Java字节码,并与其他Java代码和库进行互操作。由于其运行在JVM上的特性,Groovy可以使用其他Java语言编写的库。Groovy的语法与Java非常相似,大多数Java代码也符合Groovy的语法规则,尽管可能语义不同。

Groovy代码能够与Java代码很好地结合,也能用于扩展现有代码。相对于Java,它在编写代码的灵活性上有非常明显的提升。Groovy是动态编译语言,广泛用作脚本语言和快速原型语言,主要优势之一就是它的生产力。Groovy代码通常要比Java代码更容易编写,而且编写起来也更快,这使得它有足够的资格成为开发工作包中的一个附件。

Groovy与Java集成的方式

单纯实现Groovy脚本执行很简单,一般有三种方式:GroovyClassLoader、GroovyShell、GroovyScirptEngine。

GroovyClassLoader

用Groovy的GroovyClassLoader ,动态地加载一个脚本并执行它的行为。GroovyClassLoader是一个定制的类装载器,负责解释加载Java类中用到的Groovy类,可使用Binding对象输入参数。

GroovyClassLoader loader = new GroovyClassLoader();
Class groovyClass = loader.parseClass(new File(groovyFileName));
GroovyObject groovyObject = (GroovyObject) groovyClass.newInstance();
groovyObject.invokeMethod("run", "helloworld");           

GroovyShell

GroovyShell允许在Java类中(甚至Groovy类)求任意Groovy表达式的值,可使用Binding对象输入参数给表达式,并最终通过GroovyShell返回Groovy表达式的计算结果。GroovyShell还支持一些沙盒环境等特性,多用于推求对立的脚本或表达式。

// 脚本写在文件中,先解析成Script对象,再运行
GroovyShell shell = new GroovyShell();
Script groovyScript = shell.parse(new File(groovyFileName));
Object[] args = {};
groovyScript.invokeMethod("run", args);
// 直接运行脚本表达式
Binding bind = new Binding();
bind.setVariable("name", "zhangsan");
bind.setVariable("age", "25");
GroovyShell shell = new GroovyShell(bind);
Object obj = shell.evaluate("str = name + age; println str; return str");
System.out.println(obj);
// 脚本语句直接用字符串写完,先解析成Script对象,再运行
String scriptText = "println 'Script!'; return '222'";
Script script = shell.parse(scriptText);
Object res = script.run();
System.out.println(res);           

GroovyScriptEngine

GroovyScirptEngine作为一个引擎,功能更全面,它本身提供一些脚本的缓存等机制,如果换成相互关联的多个脚本,使用GroovyScriptEngine会更好些。GroovyScriptEngine可以从指定的位置(文件系统、URL、数据库等)加载Groovy脚本,并且随着脚本变化而重新加载它们,同样也允许传入参数值,并能返回脚本的值。

FunArgGroove.groovy文件

package com.alipay.cci
String printArg(String name){
    System.out.println("参数:"+name);
    return "返回结果:"+name;
}
//执行方法
printArg(arg);           

GroovyScriptEngineApp.java文件

public class GroovyScriptEngineApp {
 
    public static void main(String[] args) {
        try {
            // GroovyScriptEngine的根路径,如果参数是字符串数组,说明有多个根路径
            GroovyScriptEngine engine = new GroovyScriptEngine("src/test/java/com/alipay/cci/");
            Binding binding = new Binding();
            // arg 和 参数同名
            binding.setVariable("arg", "测试参数");
            Object result = engine.run("FunArgGroove.groovy", binding);
            System.out.println(result);
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ResourceException e) {
            e.printStackTrace();
        } catch (ScriptException e) {
            e.printStackTrace();
        }
    }
}           

现实中使用最多的集成方式是GroovyShell,原因在于使用脚本的场景更多的是想依赖其灵活动态的特性,不像Java逻辑一变就需要重新发布。而本身脚本的逻辑不会特别复杂,更多的是对传入的参数进行简单的计算看是否符合期望。

Groovy脚本与class文件的对应关系

作为基于JVM的语言,Groovy可以非常容易的和Java进行互操作,但也需要编译成class文件后才能运行,所以了解Groovy代码文件和class文件的对应关系,有助于更好地理解Groovy的运行方式和结构。

Groovy脚本中没有任何类定义

直接说结论,如果Groovy脚本里只有执行代码,没有定义任何类(Class),则编译器会生成一个名Script的子类,类名是Script+数字(例如Script1、Script2、Script3),脚本代码会被包含在一个名为run的方法中,同时还会生成一个main方法,作为整个脚本的入口。

GroovyShell方式运行脚本示例。

@Test
public void testGroovyShellParse() throws Exception {
    GroovyShell groovyShell = new GroovyShell();
    // 要执行的脚本内容,是一行一行的代码,在shell.evaluate(script)的时候,就是逐行执行。
    // 如果最后有return语句,就可以接收返回结果。
    String scriptText = "println 'Script!'; return '222'";
    // 下面这行parse是关键,根据字符串生成Script对象
    // 会先将scriptText解析成Class,再反射生成其实例
    // Class的名称是Script1\Script2这种形式
    Script script = groovyShell.parse(scriptText);
    Object res = script.run();
    System.out.println(res);
}           

以上代码中groovyShell.parse是关键,根据字符串生成Script对象,利用该对象执行脚本中的可执行代码。groovyShell.parse会调用到的关键方法如下。

public class GroovyShell extends GroovyObjectSupport {
	//...省略
	private GroovyClassLoader loader;
	private int counter;
	//...省略
	// 根据字符串scriptText生成Script对象实例
    public Script parse(String scriptText) throws CompilationFailedException {
        return this.parse(scriptText, this.generateScriptName());
    }
    // 这里为脚本生成名称,Script1.groovy、Script2.groovy等
    protected synchronized String generateScriptName() {
        return "Script" + ++this.counter + ".groovy";
    }
    // 根据字符串scriptText解析出Script对象
    public Script parse(final String scriptText, final String fileName) throws CompilationFailedException {
        GroovyCodeSource gcs = (GroovyCodeSource)AccessController.doPrivileged(new PrivilegedAction<GroovyCodeSource>() {
            public GroovyCodeSource run() {
                return new GroovyCodeSource(scriptText, fileName, "/groovy/shell");
            }
        });
        return this.parse(gcs);
    }
    public Script parse(GroovyCodeSource codeSource) throws CompilationFailedException {
    	// 先解析成Class对象,再调用Class的newInstance()方法反射生成一个Script对象
        return InvokerHelper.createScript(this.parseClass(codeSource), this.context);
    }
	// 利用GroovyClassLoader生成一个脚本对应的Class对象
    private Class parseClass(GroovyCodeSource codeSource) throws CompilationFailedException {
    	// 
        return this.loader.parseClass(codeSource, false);
    }
    //...省略
}           

一路Debug追踪到GroovyClassLoader的parseClass方法中去,查看根据脚本字符串scriptText生成的Class对象,如下图所示,可以发现生成的Class对象类名是Script1,GroovyShell类的counter变量会一直自增,后续生成的类名就会是Script2、Script3等。

刨根问底:Java动态运行Groovy可能有哪些坑

Groovy脚本文件中没有任何类定义

直接说结论,如果Groovy脚本文件里只有执行代码,没有定义任何类(Class),则编译器会生成一个Script的子类,类名和脚本文件的文件名一样,而脚本的代码会被包含在一个名为run的方法中,同时还会生成一个main方法,作为整个脚本的入口。

例如以下FunArgGroove.groovy文件,文件中没有定义类,只有函数定义与执行代码。

package com.alipay.cci
String printArg(String name){
    System.out.println("参数:"+name);
    return "返回结果:"+name;
}
//执行方法
printArg(arg);           

以下通过GroovyShell类解析FunArgGroove.groovy文件,并执行文件中的执行代码。

@Test
public void testGroovyShellParseFile() throws Exception {
    // 脚本写在文件中,先解析成Class对象,再Script对象,再运行
    GroovyShell groovyShell = new GroovyShell();
    // 下面这行parse是关键,根据文件生成Script对象
    Script groovyScript = groovyShell.parse(new File("src/test/java/com/alipay/cci/FunArgGroove.groovy"));
    Object[] args = {"ZhangSan"};
    Object result = groovyScript.invokeMethod("printArg", args);
    System.out.println(result);
}           

以上代码中groovyShell.parse是关键,根据文件生成Script对象,利用该对象可执行脚本文件中的可执行代码。groovyShell.parse会调用到的关键方法如下。

public class GroovyShell extends GroovyObjectSupport {
	//...省略
	private GroovyClassLoader loader;
	//...省略
	
	// 根据传入的文件生成Script实例
	public Script parse(File file) throws CompilationFailedException, IOException {
	    return this.parse(new GroovyCodeSource(file, this.config.getSourceEncoding()));
	}

	// 先根据传入的文件生成Class对象实例,然后再利用class的newInstance()方法反射生成Script类型实例
	public Script parse(GroovyCodeSource codeSource) throws CompilationFailedException {
	    return InvokerHelper.createScript(this.parseClass(codeSource), this.context);
	}

	// 根据传入的文件生成Class对象实例
	private Class parseClass(GroovyCodeSource codeSource) throws CompilationFailedException {
	    return this.loader.parseClass(codeSource, false);
	}

}           

一路Debug追踪到GroovyClassLoader的parseClass方法中去,查看根据脚本文件生成的Class对象,如下图所示,可以发现生成的Class对象全路径类名和脚本文件的全路径名一样。

刨根问底:Java动态运行Groovy可能有哪些坑

进入IDEA的target目录,查看FunArgGroove.groovy文件编译后的FunArgGroove.class文件详情如下,编译器生成了一个类名与脚本文件的文件名相同的类,这个类是Script的子类,脚本文件中定义的printArg函数在类中也被重新定义,同时还生成了一个main方法,作为整个脚本的入口。

刨根问底:Java动态运行Groovy可能有哪些坑

Groovy脚本文件中定义了一个与文件名同名的类

直接说结论,如果Groovy脚本文件里仅含有一个类,而这个类的名字又和脚本文件的名字一致,这种情况下就和写了Java类是一样的,编译器会生成与所定义的类一致的class文件。

Groovy脚本文件中定义了多个类

如果Groovy脚本文件含有多个类,Groovy编译器会很乐意地为每个类生成一个对应的class文件。如果想直接执行这个脚本,则脚本里的第一个类必须有一个static的main方法。

运行时动态执行Groovy脚本常见的坑

使用GroovyClassLoader的parseClass方法导致元数据区OOM的问题

如果应用中内嵌Groovy引擎,会动态执行传入的表达式并返回执行结果,而Groovy每执行一次脚本,都会生成一个脚本对应的Class对象,并new一个InnerLoader(继承了GroovyClassLoader)去加载,而InnerLoader和脚本对象都无法在GC的时候被回收,运行一段时间后将metaspace占满,触发Full GC。

@Test
public void testGroovyClassLoader() throws Exception {
	// 脚本表达式
    String scriptText = "def sum(int a, int b) {println a + b; return a + b;}";
    GroovyClassLoader groovyLoader = new GroovyClassLoader();
    // 将传入的scriptText字符串解析成Class实例
    Class<Script> groovyClass = (Class<Script>) groovyLoader.parseClass(scriptText);
    Script groovyScript = groovyClass.newInstance();
    Object[] args = {111, 222};
    Object res = groovyScript.invokeMethod("sum", args);
    System.out.println(res);
}           

为什么Groovy每执行一次脚本,都会生成一个脚本对应的Class对象?

一个ClassLoader对于同一个名字的类只能加载一次,如果都由GroovyClassLoader来加载,那么当一个脚本里定义了C这个类之后,另外一个脚本再定义一个C类的话,GroovyClassLoader就无法加载了。为什么这里会每次执行都会加载?这是因为对于同一段groovy脚本,groovy执行引擎都会有不同的命名,且命名与时间戳有关系。当GroovyClassLoader的parseClass传入text时,Class对象的命名规则见如下源代码。

public class GroovyClassLoader extends URLClassLoader {
	//...省略

	// 根据传入的脚本字符串生成Class实例
    public Class parseClass(String text) throws CompilationFailedException {
        return this.parseClass(text, "script" + System.currentTimeMillis() + Math.abs(text.hashCode()) + ".groovy");
    }

    public Class parseClass(final String text, final String fileName) throws CompilationFailedException {
	    GroovyCodeSource gcs = (GroovyCodeSource)AccessController.doPrivileged(new PrivilegedAction<GroovyCodeSource>() {
	        public GroovyCodeSource run() {
	            return new GroovyCodeSource(text, fileName, "/groovy/script");
	        }
	    });
	    gcs.setCachable(false);
    	return this.parseClass(gcs);
	}
	//...省略
}           

这就导致就算Groovy脚本未发生任何变化,每次执行parseClass方法都会新生成一个脚本对应的Class对象,且由InnerLoader(继承了GroovyClassLoader)进行加载,不断增大metaspace区。

为什么InnerLoader加载的类无法通过GC清理掉?

JVM中的Class只有满足以下三个条件,才可以被GC回收:

该类所有的实例都已经被GC,也就是JVM中不存在该Class的任何实例;

加载该类的ClassLoader已经被GC;

该类的java.lang.Class对象没有在任何地方被引用,如不能在任何地方通过反射访问该类的方法。

逐条检查以上GC的条件,Groovy会把脚本编译为一个名为scriptxxxxxx的类,这个脚本类运行时用反射生成一个实例并调用它的main函数执行,这个动作只会被执行一次,在应用里面不会有其他地方引用该类或它生成的实例。上面已经讲过,Groovy专门在编译每个脚本时new一个InnerLoader就是为了解决GC的问题,所以InnerLoader应该是独立的,并且在应用中不会被引用。只剩下第三种可能:该类的Class对象有被引用,事实也确实如此。在GroovyClassLoader代码中有一个class对象的缓存,进一步跟下去,发现每次编译脚本时都会在Map中缓存这个对象,即:setClassCacheEntry(clazz)。

刨根问底:Java动态运行Groovy可能有哪些坑

每次groovy编译脚本后,都会缓存该脚本的Class对象,缓存就是一个Map,下次编译该脚本时,会优先从缓存中读取,这样节省掉编译的时间。这个缓存的Map由GroovyClassLoader持有,key是脚本的类名,这就导致每个脚本对应的class对象都存在引用,无法被GC清理掉。

为了避免以上问题,比较好的做法是:

  • 对于parseClass后生成的Class<Script>对象进行cache,key为 groovyScript脚本的MD5值,重新拉取脚本编译时,如果文件的MD5值不变,就不重新编译
  • 多个Groovy方法尽量合并到一个脚本类,减少脚本数量

CodeCache用满,导致JIT禁用问题

对于大量使用Groovy的应用,尤其是Groovy脚本还会经常更新的应用,由于这些Groovy脚本在执行了很多次后都会被JVM编译为native进行优化,会占据一些CodeCache空间,CodeCache是一块独立于Java堆之外的内存区域。除了JIT编译的代码之外,Java所使用的本地方法代码(JNI)也会存在CodeCache中。如果这样的Groovy脚本很多的话,可能会导致CodeCache被用满,而CodeCache一旦被用满,JVM的Compiler就会被禁用,那性能下降的就不是一点点了。