最近在做一个根据给定表达式动态解析得到结果的功能。
例如:给定表达式**“a>0”**,就可以根据给定参数a的值动态解析结果。
对比现在常见的开源规则表达式引擎Fel、Jeval、Jsel、Aviator、QLExpress、Groovy等之后,最终选定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());
其实上面代码大致的思路都是: 为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对象可能是其它线程赋值的对象,会出现执行脚本结果混乱的情况。
解决问题1:耗时问题
根据表达式expression执行完parse()后,使用Map缓存得到的Script对象。
解决问题2:Full GC问题
首先需要通过缓存来保证每个表达式只需要编译一次即可,
另外每次都通过new GroovyClassLoader()
或者new GroovyShell()
使用新的shell进行parse()
解决问题3:线程安全问题
(1)加锁
(2)每次生成新的Script对象
代码演示1:
这段代码主要是为了记录一下我的解决问题过程中的思路而已。
(1)同一个GroovyShell
进行编译;
(2)通过HashMap维护着
对应关系的缓存,每次新的表达式解析的请求都会先去判断是否已经存在对应的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
对象会保存在PermGen,而Class对象被GC的条件之一是其ClassLoader先被GC,这就会导致PermGen的Class
对象越来越多,最后被打满的情况。
(2)通过HashMap
维护着
对应关系的缓存,每次新的表达式解析的请求都会先去判断是否已经存在对应的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();
}
}
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 // 不一定有
首先看一下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的子类,类名和脚本文件的文件名一样
下面我们分别介绍一下RootLoader
、GroovyClassLoader
和GroovyClassLoader.InnerLoader
。
RootLoader作为Groovy的根ClassLoader,负责加载Groovy及其依赖的第三方库中的类。
RootLoader先尝试加载类,如果加载不到,再委派给parent加载,所以即使parent已经载入了GroovyStarter,RootLoader还会再加载一次。
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是如何加载这些类的呢?其实很简单,它将所有的加载工作又委派回给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。
(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);
}
}
该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;
}
虽然是同一份脚本代码,但是都为其每次调用,间接生成了一个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
,相当于用空间换时间的做法。把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区爆满问题定位与解决