Java中整合Groovy遇到的问题分析

一、背景

最近在做一个根据给定表达式动态解析得到结果的功能。
例如:给定表达式**“a>0”**,就可以根据给定参数a的值动态解析结果。
对比现在常见的开源规则表达式引擎Fel、Jeval、Jsel、Aviator、QLExpress、Groovy等之后,最终选定Groovy作为脚本引擎开发。

二、实现过程

(1)整合Groovy

基本上有三种途径:GroovyShell(以及Eval)、GroovyClassLoader和GroovyScriptEngine。
代码示例1:

GroovyClassLoader groovyLoader = new GroovyClassLoader();
Class<Script> groovyClass = (Class<Script>) groovyLoader.parseClass("a>0");
Script script = groovyClass.newInstance();
Binding bind = new Binding();
bind.setVariable("a", 1);
script.setBinding(bind);
script.run();

代码示例2:

Binding binging = new Binding();
bind.setVariable("a", 1);
GroovyShell shell = new GroovyShell(binging);
String express = "a>0" ;
Script script = shell.parse(express);
System.out.println(script.run());

代码示例3:

String express = "a>0" ;
GroovyShell shell = new GroovyShell();
Script script = shell.parse(express);
Binding binging = new Binding();
bind.setVariable("a", 1);
script.setBinding(binding);
System.out.println(script.run());

(2)发现问题

其实上面代码大致的思路都是: 为Groovy脚本代码包装生成class,然后产生该类实例对象,在具体执行其包装的逻辑代码。但是对于groovy脚本, 它默认会生成名字为script + System.currentTimeMillis() + Math.abs(text.hashCode())的class类, 也就是说传入脚本, 它都会生成一个新类, 就算同一段groovy脚本代码, 每调用一次, 都会生成一个新类
这就会存在一系列的问题:

问题1:耗时问题!!!
parse()方法是非常耗时的,如果每一次有表达式都需要解析将会非常的慢。根据自己测试的结果来看:shell.parse(express);耗时在200ms-300ms之间,但是script.run()耗时却几乎接近0ms。

问题2:Full GC问题!!!
当JVM中运行的Groovy脚本存在大量并发时,如果按照默认的策略,每次运行都会重新编译脚本,调用类加载器进行类加载。临时加载的类未能及时被释放,进而导致PermGen OutOfMemoryError;没那么严重的时候也会引发比较频繁的full GC从而影响稳定运行时的性能。

问题3:线程安全问题!!!
高并发情况下,执行赋值binding对象后,真正执行run操作时,拿到的Binding对象可能是其它线程赋值的对象,会出现执行脚本结果混乱的情况。

(3)问题解决

解决问题1:耗时问题
根据表达式expression执行完parse()后,使用Map缓存得到的Script对象。

解决问题2:Full GC问题
首先需要通过缓存来保证每个表达式只需要编译一次即可,
另外每次都通过 new GroovyClassLoader() 或者 new GroovyShell() 使用新的shell进行parse()

解决问题3:线程安全问题
(1)加锁
(2)每次生成新的Script对象

代码演示1:
这段代码主要是为了记录一下我的解决问题过程中的思路而已。
(1)同一个GroovyShell 进行编译;
(2)通过HashMap维护着<expression, Script>对应关系的缓存,每次新的表达式解析的请求都会先去判断是否已经存在对应的Script对象。
(3)通过synchronized 实现线程同步

/**
 * @author Caocs
 * @date 2020/3/10
 */
public class RuleExecutor {
	private static final Object lock = new Object();
    private static final GroovyShell groovyShell = new GroovyShell();

    /**
     * 存放该规则组下的每个<表达式, Script>的对应映射关系
     */
    private Map<String, Script> expressionScriptCacheMap = new HashMap<>();
    
    /**
     * 只在第一次执行表达式expression时,实例化Script。
     * 然后多个线程同时操作scriptCache,需要保证线程安全。
     */
    private Script getScriptFromCache(String expression) {
        if (expressionScriptCacheMap.containsKey(expression)) {
            return expressionScriptCacheMap.get(expression);
        }
        synchronized (lock) {
            if (expressionScriptCacheMap.containsKey(expression)) {
                return expressionScriptCacheMap.get(expression);
            }
            // 同一个GroovyShell创建Script,容易发生full GC
            Script script = groovyShell.parse(expression);
            expressionScriptCacheMap.put(expression, script);
            return script;
        }
    }
    
	public Object ruleParse(String expression, Map<String, Object> paramMap) {
        Binding binding = new Binding(paramMap);
        Script script = getScriptFromCache(expression);
		// 通过加锁保证线程安全,但是当线程数过多时会变慢,线程阻塞
        synchronized (this){
            script.setBinding(binding);
            return script.run();
        }
    }
}

代码演示2:
(1)每一次都new GroovyClassLoader()进行编译;
我们不要采用一个全局的GroovyClassLoader,parseClass方法得到的Class<Script>对象会保存在PermGen,而Class对象被GC的条件之一是其ClassLoader先被GC,这就会导致PermGen的Class<Script>对象越来越多,最后被打满的情况。
(2)通过HashMap维护着<expression, Class<Script>>对应关系的缓存,每次新的表达式解析的请求都会先去判断是否已经存在对应的Script类。
我还看到有文章是根据表达式生成一个md5作为key,目前我的需求上表达式并不会很长,所以没有这么做。
(3)通过Script script = InvokerHelper.createScript(scriptClass, binding);,每次运行时都生成新的Script对象,这样就不存在共享资源问题了,自然而然就没有线程安全问题。

/**
 * @author Caocs
 * @date 2020/3/10
 */
public class RuleExecutor {
    private static final Object lock = new Object();
    /**
     * 存放该规则组下的每个<表达式, Script>的对应映射关系
     */
    private Map<String, Class<Script>> expressionScriptCacheMap = new HashMap<>();

    /**
     * 只在第一次执行表达式expression时,实例化Script。
     * 然后多个线程同时操作scriptCache,需要保证线程安全。
     */
    private Class<Script> getScriptFromCache(String expression) {
        if (expressionScriptCacheMap.containsKey(expression)) {
            return expressionScriptCacheMap.get(expression);
        }
        synchronized (lock) {
            if (expressionScriptCacheMap.containsKey(expression)) {
                return expressionScriptCacheMap.get(expression);
            }
            // 每次使用新的GroovyClassLoader创建Script
            GroovyClassLoader classLoader = new GroovyClassLoader();
            Class<Script> classScript = (Class<Script>) classLoader.parseClass(expression);
            expressionScriptCacheMap.put(expression, classScript);
            return classScript;
        }
    }
    
	private Object ruleParse(String expression, Map<String, Object> paramMap) {
        Binding binding = new Binding(paramMap);
        Class<Script> classScript = getScriptFromCache(expression);
        // 每次都生成新得Script对象,避免线程安全问题
        Script script = InvokerHelper.createScript(classScript, binding);
        return script.run();
    }
}

三、分析

1、GroovyClassLoader分析

(1)Java的ClassLoader

Java的ClassLoader就是类的装载器,它使JVM可以动态的载入Java类。可以说,ClassLoader是Class的命名空间。同一个名字的类可以由多个ClassLoader载入,由不同ClassLoader载入的相同名字的类将被认为是不同的类;而同一个ClassLoader对同一个名字的类只能载入一次。

Java的ClassLoader有一个著名的双亲委派模型(Parent Delegation Model):除了Bootstrap ClassLoader外,每个ClassLoader都有一个parent的ClassLoader,沿着parent最终会追索到Bootstrap ClassLoader;当一个ClassLoader要载入一个类时,会首先委派给parent,如果parent能载入这个类,则返回,否则这个ClassLoader才会尝试去载入这个类。

Java的ClassLoader体系如下,其中箭头指向的是该ClassLoader的parent:

Bootstrap ClassLoader  
         ↑  
Extension ClassLoader  
         ↑  
System ClassLoader  
         ↑  
User Custom ClassLoader  // 不一定有  

(2)Groovy的ClassLoader

首先看一下Groovy的ClassLoader体系:

            null                      // 即Bootstrap ClassLoader  
             ↑  
sun.misc.Launcher.ExtClassLoader      // 即Extension ClassLoader  
             ↑  
sun.misc.Launcher.AppClassLoader      // 即System ClassLoader  
             ↑  
org.codehaus.groovy.tools.RootLoader  // 以下为User Custom ClassLoader  
             ↑  
groovy.lang.GroovyClassLoader  
             ↑  
groovy.lang.GroovyClassLoader.InnerLoader 

然后我们看最终编译成的文件

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

2.对于仅有一个类
如果Groovy脚本文件里仅含有一个类,而这个类的名字又和脚本文件的名字一致,这种情况下就和Java是一样的,即生成与所定义的类一致的class文件,
Groovy类都会实现groovy.lang.GroovyObject接口。

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

4.对于有定义类的脚本
如果Groovy脚本文件有执行代码, 并且有定义类, 那么所定义的类会生成对应的class文件, 同时, 脚本本身也会被编译成一个Script的子类,类名和脚本文件的文件名一样

下面我们分别介绍一下RootLoaderGroovyClassLoaderGroovyClassLoader.InnerLoader

RootLoader

RootLoader作为Groovy的根ClassLoader,负责加载Groovy及其依赖的第三方库中的类。
RootLoader先尝试加载类,如果加载不到,再委派给parent加载,所以即使parent已经载入了GroovyStarter,RootLoader还会再加载一次。

GroovyClassLoader

GroovyClassLoader主要负责在运行时编译groovy源代码为Class的工作,从而使Groovy实现了将groovy源代码动态加载为Class的功能。

private Class doParseClass(GroovyCodeSource codeSource) {
    validate(codeSource);
    Class answer;  // Was neither already loaded nor compiling, so compile and add to cache.
    CompilationUnit unit = createCompilationUnit(config, codeSource.getCodeSource());
    if (recompile!=null && recompile || recompile==null && config.getRecompileGroovySource()) {
        unit.addFirstPhaseOperation(TimestampAdder.INSTANCE, CompilePhase.CLASS_GENERATION.getPhaseNumber());
    }
    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的作用,就是在编译的过程中,将编译出来的字节码,通过InnerLoader进行加载。
    // 另外,每次编译groovy源代码的时候,都会新建一个InnerLoader的实例。
    ClassCollector collector = createCollector(unit, su);
    unit.setClassgenCallback(collector);
    int goalPhase = Phases.CLASS_GENERATION;
    if (config != null && config.getTargetDirectory() != null) goalPhase = Phases.OUTPUT;
    unit.compile(goalPhase); // 编译groovy源代码
    // 查找源文件中的Main Class  
    answer = collector.generatedClass;
    String mainClass = su.getAST().getMainClassName();
    for (Object o : collector.getLoadedClasses()) {
        Class clazz = (Class) o;
        String clazzName = clazz.getName();
        definePackageInternal(clazzName);
        setClassCacheEntry(clazz);
        if (clazzName.equals(mainClass)) answer = clazz;
    }
    return answer;
}
InnerLoader

InnerLoader是如何加载这些类的呢?其实很简单,它将所有的加载工作又委派回给GroovyClassLoader。

public static class InnerLoader extends GroovyClassLoader {
    private final GroovyClassLoader delegate;
    private final long timeStamp;
    public InnerLoader(GroovyClassLoader delegate) {
        super(delegate);
        this.delegate = delegate;
        timeStamp = System.currentTimeMillis();
    }
	// ...省略
    public Class loadClass(String name, boolean lookupScriptFiles, boolean preferClassOverScript, boolean resolve) throws ClassNotFoundException, CompilationFailedException {
        Class c = findLoadedClass(name);
        if (c != null) return c;
        return delegate.loadClass(name, lookupScriptFiles, preferClassOverScript, resolve);
    }

    public Class parseClass(GroovyCodeSource codeSource, boolean shouldCache) throws CompilationFailedException {
        return delegate.parseClass(codeSource, shouldCache);
    }
}
那有了GroovyClassLoader,为什么还需要InnerLoader呢?

(1)由于一个ClassLoader对于同一个名字的类只能加载一次,如果都由GroovyClassLoader加载,那么当一个脚本里定义了C这个类之后,另外一个脚本再定义一个C类的话,GroovyClassLoader就无法加载了。
(2)由于当一个类的ClassLoader被GC之后,这个类才能被GC,如果由GroovyClassLoader加载所有的类,那么只有当GroovyClassLoader被GC了,所有这些类才能被GC,而如果用InnerLoader的话,由于编译完源代码之后,已经没有对它的外部引用,除了它加载的类,所以只要它加载的类没有被引用之后,它以及它加载的类就都可以被GC了。

小结

本节介绍Groovy中最主要的3个ClassLoader:
(1)RootLoader:管理了Groovy的classpath,负责加载Groovy及其依赖的第三方库中的类,它不是使用双亲委派模型。
(2)GroovyClassLoader:负责在运行时编译groovy源代码为Class的工作,从而使Groovy实现了将groovy源代码动态加载为Class的功能。
(3)GroovyClassLoader.InnerLoader:Groovy脚本类的直接ClassLoader,它将加载工作委派给GroovyClassLoader,它的存在是为了支持不同源码里使用相同的类名,以及加载的类能顺利被GC。

2、GroovyClassLoader、GroovyShell 、Script 之间关系

(1)通过上一小节分析,我们可以知道,将文本、文件等groovy源代码编译成Class的工作很显然是属于GroovyClassLoader类的啦。
(2)然后,我们常用的GroovyShell到底是什么呢?
首先看一下evaluate()方法在干什么?其实只是调用parse()方法得到Script对象然后执行而已。

public Object evaluate(GroovyCodeSource codeSource) throws CompilationFailedException {
	// 先调用parse()方法得到Script对象
    Script script = parse(codeSource);
    // 然后通过script.run()执行
    return script.run();
}

那么parse()方法在干什么呢?这里有一个很重要的类InvokerHelper,在后面会详细分析。这里还有一个parseClass()方法。

public Script parse(final GroovyCodeSource codeSource) throws CompilationFailedException {
   return InvokerHelper.createScript(parseClass(codeSource), context);
}

那么parseClass()方法在干什么呢?其实它只是调用的GroovyClassLoader中的parseClass(codeSource, false)方法。

private Class parseClass(final GroovyCodeSource codeSource) throws CompilationFailedException {
    // Don't cache scripts
    return loader.parseClass(codeSource, false);
}

总结,就是GroovyShell使用GroovyClassLoader来加载类,而该GroovyClassLoader的parent即为GroovyShell的ClassLoader,也就是GroovyMain的ClassLoader,也就是RootLoader。
(3)最后,Script是什么呢?
Script是一个抽象类,最最最重要的run()方法是抽象方法,由编译加载后的类实现后执行。

public abstract class Script extends GroovyObjectSupport {
    private Binding binding;

    public abstract Object run();
	// ...省略
    public Object evaluate(String expression) throws CompilationFailedException {
        GroovyShell shell = new GroovyShell(getClass().getClassLoader(), binding);
        return shell.evaluate(expression);
    }

    public void run(File file, String[] arguments) throws CompilationFailedException, IOException {
        GroovyShell shell = new GroovyShell(getClass().getClassLoader(), binding);
        shell.run(file, arguments);
    }
}

3、InvokerHelper类分析

InvokerHelper工具类中包含很多invoke***方法,用来根据类或者对象和方法名通过反射调用执行。
下面主要介绍一下createScript(Class scriptClass, Binding context)方法。使用该方法就可以根据Class生成新的对应的Script对象。这一特点完美的解决线程安全问题。另外,因为Class是通过GroovyClassLoader的parseClass()得到。这样就可以做到一次编译表达式,处处使用新Script对象运行

public static Script createScript(Class scriptClass, Binding context) {
        Script script;

        if (scriptClass == null) {
            script = new NullScript(context);
        } else {
            try {
                if (Script.class.isAssignableFrom(scriptClass)) {
                    script = newScript(scriptClass, context);
                } else {
                    final GroovyObject object = (GroovyObject) scriptClass.newInstance();
                    // it could just be a class, so let's wrap it in a Script
                    // wrapper; though the bindings will be ignored
                    script = new Script(context) {
                        public Object run() {
                            Object argsToPass = EMPTY_MAIN_ARGS;
                            try {
                                Object args = getProperty("args");
                                if (args instanceof String[]) {
                                    argsToPass = args;
                                }
                            } catch (MissingPropertyException e) {
                                // They'll get empty args since none exist in the context.
                            }
                            object.invokeMethod("main", argsToPass);
                            return null;
                        }
                    };
                    Map variables = context.getVariables();
                    MetaClass mc = getMetaClass(object);
                    for (Object o : variables.entrySet()) {
                        Map.Entry entry = (Map.Entry) o;
                        String key = entry.getKey().toString();
                        // assume underscore variables are for the wrapper script
                        setPropertySafe(key.startsWith("_") ? script : object, mc, key, entry.getValue());
                    }
                }
            } catch (Exception e) {
                throw new GroovyRuntimeException(
                        "Failed to create Script instance for class: "
                                + scriptClass + ". Reason: " + e, e);
            }
        }
        return script;
    }

4、Full GC问题分析:

虽然是同一份脚本代码,但是都为其每次调用,间接生成了一个class类。对于full gc,除了清理老年代,也会顺便清理永久代(PermGen),但为何不清理这些一次性的class呢? 答案是gc条件不成立。
  引用下class被gc, 需满足的三个条件:
  1). 该类所有的实例都已经被GC
  2). 加载该类的ClassLoader已经被GC
  3). 该类的java.lang.Class对象没有在任何地方被引用
  加载类的ClassLoader实例被GroovyShell所持有, 作为静态变量(gc root), 条件2不成立, GroovyClassLoader有个map成员, 会缓存编译的class, 因此条件3都不成立.
  有人会问, 为何不把GroovyShell对象, 作为一个临时变量呢?
  实际上, 还是治标不治本, 只是说class能被gc掉, 但是清理的速度可能赶不上产生的速度, 依旧频繁触发full gc.

四、其他

其实我在遇到这个问题的时候,踩了很多的坑。
开始,就想到需要对Script script = groovyShell.parse(express);得到的Script对象做缓存。嗯~觉得很完美…
然后,就发现代码中存在线程安全问题。
然后,我的思路就被限制在如何解决线程安全问题上了。
当然,最容易的方法当然是直接使用synchronized 加锁操作啦。
但是,这样加锁在高并发量的情况下,性能肯定是不好的啊。我就想一下其他的方法…
第一,我想到避免共享对象,就每次都使用新的Script script = groovyShell.parse(express);对象来run(),因为parse()真滴是慢啊,失败!
第二,我想到对象池,可以每个表达式下,都先初始化一批Script对象,然后需要的时候就从对象池中取出一个,用完再还回去。然后看网上说:对象池适用于:1、资源受限的;数量受限的;创建成本高的对象。我发现其实单纯的new Script()其实开销很小。所以场景并不是很适用。失败!

/**
 * @author Caocs
 * @date 2020/3/10
 * 使用Queue存储Script方式
 * 这个是我想做成对象池的形式调用的,然后做着做着发现不是很可行,然后就没做下去了。
 * 这段代码,运行的话会有异常(超出队列)。
 */
public class RuleExecutor1 {

    private static final Object lock = new Object();
    private static final GroovyShell groovyShell;

    static {
        CompilerConfiguration cfg = new CompilerConfiguration();
        groovyShell = new GroovyShell(cfg);
    }
    
    private Map<String, ArrayBlockingQueue<Script>> expressionScriptCacheMap = new HashMap<>();

    private Object ruleParse(String expression, Map<String, Object> paramMap) {
        Binding binding = new Binding(paramMap);
        Script script = getScriptFromCache(expression);
        script.setBinding(binding);
        Object t = script.run();
        releaseScript(expression, script);
        return t;
    }

    private Script getScriptFromCache(String expression) {
        ArrayBlockingQueue<Script> sc;
        if ((sc = expressionScriptCacheMap.get(expression)) != null && !sc.isEmpty()) {
            Script t= sc.poll();
            if(t!=null){
                return t;
            }
        }
        synchronized (lock) {
            if ((sc = expressionScriptCacheMap.get(expression)) != null && !sc.isEmpty()) {
                return sc.poll();
            }
            Script script = groovyShell.parse(expression);
            if (sc == null) {
                sc = new ArrayBlockingQueue<Script>(10);
            }
            expressionScriptCacheMap.put(expression, sc);
            return script;
        }
    }

    private void releaseScript(String expression, Script script) {
        ArrayBlockingQueue<Script> sc = expressionScriptCacheMap.get(expression);
        sc.add(script);
    }

}

第三,我想到ThreadLocal,相当于用空间换时间的做法。把<expression,Script>的对应关系在每个线程中都存放一份。但是通过测试证明,这种方法也不快,在1000个线程下实验甚至比synchronized还稍慢一下。失败!

/**
 * @author Caocs
 * @date 2020/3/10
 * 通过ThreadLocal方式,但是现在还有问题。有NullPointException。
 */
public class RuleExecutor2 {

    private static final Object lock = new Object();
    private static final GroovyShell groovyShell;

    static {
        CompilerConfiguration cfg = new CompilerConfiguration();
        groovyShell = new GroovyShell(cfg);
    }

    private ThreadLocal<Map<String,Script>> localScriptMap = new ThreadLocal<>();
 
    private Object ruleParse(String expression, Map<String, Object> paramMap) {
        Binding binding = new Binding(paramMap);
        Script script = getScriptFromCache(expression);
        script.setBinding(binding);
        Object t = script.run();
        return t;
    }

    private Script getScriptFromCache(String expression) {
        if(localScriptMap.get()==null){
            Map<String, Script> expressionScriptMap = new HashMap<>();
            Script script = groovyShell.parse(expression);
            expressionScriptMap.put(expression,script);
            localScriptMap.set(expressionScriptMap);
            return localScriptMap.get().get(expression);
        } else {
            if(localScriptMap.get().containsKey(expression)){
                return localScriptMap.get().get(expression);
            }else{
                Map<String, Script> expressionScriptMap = localScriptMap.get();
                Script script = groovyShell.parse(expression);
                expressionScriptMap.put(expression,script);
                localScriptMap.set(expressionScriptMap);
                return localScriptMap.get().get(expression);
            }
        }
    }

}

最后,我才发现新大陆InvokerHelper,可以直接new出来新的Script对象。哇,柳暗花明又一村!
后来,又看网上的博客建议不要采用一个全局的GroovyClassLoader,容易Full GC。优秀!

五、最后

参考文章
基于Groovy的规则脚本引擎实战
Groovy 教程 - 整合 Groovy 至应用程序
groovy脚本导致的FullGC问题
Groovy与Java集成常见的坑(转)
Groovy脚本极限优化
在Java里整合Groovy脚本的一个陷阱
Java动态调用Groove代码(1)-GroovyClassLoader
Java动态调用Groove代码(2)-GroovyScriptEngine
Java动态调用Groove代码(3)-GroovyShell
Groovy 与应用的集成
JVM执行Groovy脚本导致堆外内存溢出问题排查
groovy——运行方式、基本语法、引入方式、metaClass
Groovy实现代码热载的机制和原理
groovy脚本执行与优化
Groovy深入探索——Groovy的ClassLoader体系
Groovy引发的PermGen区爆满问题定位与解决

  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值