Groovy 实现代码热载的机制和原理
共 5283字,需浏览 11分钟
·
2021-11-29 20:12
你知道的越多,不知道的就越多,业余的像一棵小草!
你来,我们一起精进!你不来,我和你的竞争对手一起精进!
编辑:业余草
cnblogs.com/mumuxinfei/p/8387349.html
推荐:https://www.xttblog.com/?p=5292
我不太清楚有多少人使用过 Groovy 的热更新功能,目前我们公司不少复杂的业务模块,使用了它。最近还使用了 Groovy 的一个热更新功能,感觉还算顺手。本文总结一下 Groovy 实现代码热载的机制和原理!
前言
最近很忙,现在趁这段空闲的时间,对之前接触的一些工程知识做下总结。先来讲下借用 Groovy 如何来实现代码的热载,以及其中涉及到的原理和需要注意的点。
总的来说, Groovy作为一本动态编译语言, 其对标应该是c/c++体系中的lua, 在一些业务逻辑变动频繁的场景, 其意义非常的重大。
简单入门
本文的主题是Groovy实现代码热载, 其他大背景是java实现主干代码, groovy实现易变动的逻辑代码. 先来看下java是如何调用的groovy脚本的。
import groovy.lang.Binding;
import groovy.lang.GroovyShell;
public class GroovyTest {
public static void main(String\[\] args) {
// \*) groovy 代码
String script = "println 'hello'; 'name = ' + name;";
// \*) 传入参数
Binding binding = new Binding();
binding.setVariable("name", "lilei");
// \*) 执行脚本代码
GroovyShell shell = new GroovyShell(binding);
Object res = shell.evaluate(script);
System.out.println(res);
}
}
这段代码的输出为:
Binding类主要用于传递参数集, 而GroovyShell则主要用于编译执行Groovy代码。是不是比想象中的要简答, ^_^。
当然java调用groovy还有其他的方式, 下文会涉及到.
原理分析
下面这段其实大有文章.
GroovyShell shell = new GroovyShell(binding);
Object res = shell.evaluate(script);
对于函数evaluate, 我们追踪进去, 会有不少的重新认识.
public Object evaluate(GroovyCodeSource codeSource) throws CompilationFailedException {
Script script = this.parse(codeSource);
return script.run();
}
public Script parse(GroovyCodeSource codeSource) throws CompilationFailedException {
return InvokerHelper.createScript(this.parseClass(codeSource), this.context);
}
其大致的思路, 「为Groovy脚本代码包装生成class, 然后产生该类实例对象, 在具体执行其包装的逻辑代码」.
但是这边需要注意的情况:
public Class parseClass(String text) throws CompilationFailedException {
return this.parseClass(text, "script" + System.currentTimeMillis() + Math.abs(text.hashCode()) + ".groovy");
}
对于groovy脚本, 它默认会生成名字为**script + System.currentTimeMillis() + Math.abs(text.hashCode())**的class类, 也就是说传入脚本, 它都会生成一个新类, 「就算同一段groovy脚本代码, 每调用一次, 都会生成一个新类」。
陷阱评估
原理我们基本上理解了, 但是让我们来构造一段代码, 看看是否有哪些陷阱.
import groovy.lang.Binding;
import groovy.lang.GroovyShell;
import groovy.lang.Script;
import java.util.Map;
import java.util.TreeMap;
public class GroovyTest2 {
private static GroovyShell shell = new GroovyShell();
public static Object handle(String script, Map params) {
Binding binding = new Binding();
for ( Map.Entry ent : params.entrySet() ) {
binding.setVariable(ent.getKey(), ent.getValue());
}
Script sci = shell.parse(script);
sci.setBinding(binding);
return sci.run();
}
public static void main(String\[\] args) {
String script = "println 'hello'; 'name = ' + name;";
Map params = new TreeMap();
params.put("name", "lilei");
while(true) {
handle(script, params);
}
}
}
这段代码执行到最后的结果为, 频繁触发full gc, 究其原因为PermGen区爆满. 这是为何呢?
如上所分析的, 虽然是同一份脚本代码, 但是都为其每次调用, 间接生成了一个class类. 对于full gc, 除了清理老年代, 也会顺便清理永久代(PermGen), 但为何不清理这些一次性的class呢? 答案是gc条件不成立。
引用下class被gc, 需满足的三个条件:
该类所有的实例都已经被GC
加载该类的ClassLoader已经被GC
该类的java.lang.Class对象没有在任何地方被引用
加载类的ClassLoader实例被GroovyShell所持有, 作为静态变量(gc root), 条件2不成立, GroovyClassLoader有个map成员, 会缓存编译的class, 因此条件3都不成立.
有人会问, 为何不把GroovyShell对象, 作为一个临时变量呢?
public static Object handle(String script, Map params) {
Binding binding = new Binding();
for ( Map.Entry ent : params.entrySet() ) {
binding.setVariable(ent.getKey(), ent.getValue());
}
GroovyShell shell = new GroovyShell();
Script sci = shell.parse(script);
sci.setBinding(binding);
return sci.run();
}
实际上, 还是治标不治本, 只是说class能被gc掉, 但是清理的速度可能赶不上产生的速度, 依旧频繁触发full gc。
推荐做法
解决上述问题很简单, 就是「引入缓存」, 当然缓存的对象不上Script实例(在多线程环境下, 会遇到数据混乱的问题, 对象有状态), 而是「Script.class本身」. 对应的key为脚本代码的指纹.
大致的代码如下所示:
private static ConcurrentHashMap> zlassMaps
= new ConcurrentHashMap>();
public static Object invoke(String scriptText, Map params) {
String key = fingerKey(scriptText);
Class