之前实践了Java 热加载,听说“Groovy 天生支持热加载”,所以简单了解了一下 Groovy,对比两者的优劣。
这里只讨论热加载的关键点,具体到工程中的使用万变不离其宗。
0 太长不看版
先简单说一下结论,对操作细节不感兴趣的看这一小节就好。
对比项 | Java | Groovy |
---|---|---|
原理 | 自己实现类加载器,重定义 loadClass 方法,来避开双亲委派 | 通过将源代码动态编译成.class文件,使用 GDK 自带的 GroovyClassLoader(每次加载会新建一个 InnerLoader) 来避开双亲委派 |
灵活性 | 需要事先编译 .class 文件 | 可以直接加载 java 文件、groovy 文件 |
操作对象 | 一般通过 jar 包加载 ,可以一次性加载很多相关 .class 文件 | 一般指定单个文件加载,或指定目录遍历加载;甚至可以将文本放在数据库,或者直接在程序里拼接 |
效率 | 热加载 0-1ms | 因为要实时编译,耗时10-20ms |
风险 | 使用不恰当(未及时释放类加载器)容易出现 GC 问题 | 如果不清除类缓存,也容易出现GC问题;没有事先编译校验,热加载前一定要充分测试 |
相关性 | Java 也可以手动实现动态编译,加载.java文件 | Groovy 也可以加载 .class 文件,不过就变得跟 Java 一样得自己实现类加载器 |
Groovy 兼容 99.9% 的 Java 语法,直接修改文件后缀将.java
改为.groovy
就可以得到一个 Groovy 文件,GroovyClassLoader 甚至可以直接加载 .java 文件。
class 对象实例化之后两者使用起来是没什么区别的,主要差异就是 Groovy 有动态编译的耗时,但是在热加载这个离线场景下影响不大。
总的来说 Groovy 的热加载,方式更灵活,熟悉 Java 的话入手门槛也比较低,甚至可以实现 Java 接口跟 Java 代码一起使用,如果对 Java 的类加载机制没那么熟悉,可以直接使用 Groovy 的 GroovyClassLoader。
1 使用 Groovy 的准备
要在 Java 程序中使用 Groovy 能力,只需根据需要 Maven 引入就好
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
<version>3.0.7</version>
</dependency>
Groovy 语法被设计为与 Java 几乎一致,除了一下个别差异点(细节请查看官方文档:http://groovy-lang.org/differences.html ),Java 文件改个后缀就可以变成 Groovy文件。
2 体验 Groovy 热加载
2.1 准备实现类
还是使用之前测试Java热加载时用的 HelloWorldService.java
接口
public interface HelloWorldService {
String hello();
}
写一个 Java 实现类 HelloJavaServiceImpl.java
public class HelloJavaServiceImpl implements HelloWorldService {
public String hello() {
String result = "Java";
System.out.println(result);
return result;
}
}
Copy HelloJavaServiceImpl.java
,重命名为HelloGroovyServiceImpl.groovy
,修改一下 result
的值作为区分
public class HelloGroovyServiceImpl implements HelloWorldService {
public String hello() {
String result = "Groovy";
System.out.println(result);
return result;
}
}
可以看到直接重命名,语法是完全兼容的
2.2 准备热加载测试方法
还是按测试 Java 热加载的风格,写一个 controller,参数是要热加载的文件地址
@GetMapping("/reloadGroovy")
public String reloadGroovy(@RequestParam("filePath") String filePath) {
try {
long start = System.currentTimeMillis();
URL url = new URL(filePath);
File file = new File(url.getPath());
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(Thread.currentThread().getContextClassLoader());
Class classToLoad = groovyClassLoader.parseClass(file);
Object instance = classToLoad.newInstance();
if (instance instanceof HelloWorldService) {
helloWorldService = (HelloWorldService) instance;
return helloWorldService.hello() + ", cost:" + (System.currentTimeMillis() - start);
} else {
return "reload err, not instanceof HelloWorldService";
}
} catch (Exception e) {
e.printStackTrace();
return "reload err, " + e.getMessage();
}
}
再回顾一下 Java 热加载的代码
@GetMapping("/reloadJava")
public String reloadJava(@RequestParam("jarPath") String jarPath) {
try {
long start = System.currentTimeMillis();
URL url = new URL(jarPath);
HelloWorldClassLoader child = new HelloWorldClassLoader(new URL[]{url},
Thread.currentThread().getContextClassLoader());
Class classToLoad = Class.forName("com.tencent.srmp.jarmaker.HelloWorldServiceImpl2", true, child);
Object instance = classToLoad.newInstance();
if (instance instanceof HelloWorldService) {
helloWorldService = (HelloWorldService) instance;
return helloWorldService.hello() + ", cost:" + (System.currentTimeMillis() - start);
} else {
return "reload err, not instanceof HelloWorldService";
}
} catch (Exception e) {
e.printStackTrace();
return "reload err, " + e.getMessage();
}
}
主要区别是 Java 用的自定义的HelloWorldClassLoader
,加载 Jar 包,通过Class.forName
重新加载指定 class;Groovy 用 GDK 自带的GroovyClassLoader
加载 Java/Groovy 文件,通过parseClass
方法动态编译 class,parseClass
中隐含了新建InnerClassLoader
重新加载的步骤。
# Java
HelloWorldClassLoader child = new HelloWorldClassLoader(new URL[]{url},
Thread.currentThread().getContextClassLoader());
Class classToLoad = Class.forName("com.tencent.srmp.jarmaker.HelloWorldServiceImpl2", true, child);
# Groovy
File file = new File(url.getPath());
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(Thread.currentThread().getContextClassLoader());
Class classToLoad = groovyClassLoader.parseClass(file);
2.3 测试 Groovy、Java 热加载性能
为了避免 IDEA 的干扰,将测试项目打包成 Jar 包运行,来测试上面的两个 controller。
java -jar target/jar-loader-0.0.1-SNAPSHOT.jar
1)测试 Java 热加载耗时
# 测试 Java 热加载耗时
# 热加载第一个jar包,耗时1ms
curl http://localhost:8080/reloadJava?jarPath=file:/Users/joinwu/IdeaProjects/jar-loader/src/main/resources/lib/jar-maker2.jar
World II, cost:1
# 热加载修改后的第二个jar包,耗时1ms
curl http://localhost:8080/reloadJava?jarPath=file:/Users/joinwu/IdeaProjects/jar-loader/src/main/resources/lib/jar-maker2.2.jar
World II.2, cost:1
2)测试 Groovy 热加载耗时
第一次加载 Groovy 文件,耗时 589ms
curl http://localhost:8080/reloadGroovy?filePath=file:/Users/joinwu/IdeaProjects/jar-loader/src/main/java/com/tencent/srmp/jarloader/groovy/impl/HelloGroovyServiceImpl.groovy
Groovy, cost:589
修改文件内容,第二次加载 Groovy 文件,耗时 20ms。第一次加载之后,后面都是10-20ms,猜测是第一次加载时有很多初始化的耗时,还没细看。
curl http://localhost:8080/reloadGroovy?filePath=file:/Users/joinwu/IdeaProjects/jar-loader/src/main/java/com/tencent/srmp/jarloader/groovy/impl/HelloGroovyServiceImpl.groovy
Groovy111, cost:20
甚至 Groovy 可以直接加载 Java 文件
curl http://localhost:8080/reloadGroovy?filePath=file:/Users/joinwu/IdeaProjects/jar-loader/src/main/java/com/tencent/srmp/jarloader/groovy/impl/HelloJavaServiceImpl.java
Java, cost:21
3 Groovy 热加载的原理
Groovy 热加载的关键就在于GroovyClassLoader
没有实现双亲委派机制,它首先自己加载,找不到的类才让父加载器加载。加载的入口是parseClass
方法,入参是一个File
对象,由具体的.groovy
、.java
文件内容实例化。
3.1 实例化 GroovyClassLoader
用当前线程的类加载器作为父加载器,新建一个 GroovyClassLoader,具体实现时,可以缓存这个 GroovyClassLoader,避免每次都要新建。
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(Thread.currentThread().getContextClassLoader());
3.2 调用 parseClass 方法
可以由各种来源获取要加载的类定义,例如 url、本地文件、数据库、配置中心、代码拼接,构造成一个 File。
File file = xxx;
Class classToLoad = groovyClassLoader.parseClass(file);
3.3 类的缓存
首先从缓存取,取不到就直接加载。
public Class parseClass(GroovyCodeSource codeSource, boolean shouldCacheSource) throws CompilationFailedException {
String cacheKey = this.genSourceCacheKey(codeSource);
return (Class)this.sourceCache.getAndPut(cacheKey, (key) -> {
return this.doParseClass(codeSource);
}, shouldCacheSource);
}
genSourceCacheKey
生成一个缓存 key,生成原理是按脚本内容生成 md5。不管类名,如果内容改变了,就会重新解析、加载。
3.4 类的热加载
doParseClass
中,每次都会以GroovyClassLoader
为父类加载器新建一个子类加载器InnerLoader
,并以此实例化了ClassCollector
。
protected GroovyClassLoader.ClassCollector createCollector(CompilationUnit unit, SourceUnit su) {
GroovyClassLoader.InnerLoader loader = (GroovyClassLoader.InnerLoader)AccessController.doPrivileged(() -> {
return new GroovyClassLoader.InnerLoader(this);
});
return new GroovyClassLoader.ClassCollector(loader, unit, su);
}
在ClassCollector
中不管双亲,直接编译 class:调用createClass
方法,使用类加载器InnerLoader
的defineClass
方法来将传入的源代码编译为 class 对象,并作为新加载的 class 返回。
protected Class createClass(byte[] code, ClassNode classNode) {
//...
GroovyClassLoader cl = this.getDefiningClassLoader();
Class theClass = cl.defineClass(classNode.getName(), fcode, 0, fcode.length, this.unit.getAST().getCodeSource());
//...
return theClass;
}
每次都新建一个InnerLoader
,就是为了避免defineClass
中的缓存,导致同名类不能再次重新加载。
3.5 类的卸载
反复热加载之后会产生很多废弃的 class,为了避免 OOM,需要对 class 进行卸载。
回顾一下 Class 被 GC 的三个条件:
条件1:Class 的所有实例对象都已回收
很好满足。
条件2:加载这个类的 ClassLoader 已经被回收
每次加载都新建了InnerLoader
,而InnerLoader
中实际上又用的是父加载器GroovyClassLoader
来实际加载,这样加载完成之后,每次新建的InnerLoader
也不再被引用,可以被 GC。
可以满足条件。
条件3:该类的 Class 对象没有任何引用
而由于上面讲到的类的缓存机制,在缓存中会有 Class 对象的引用。为了满足条件3,避免多次加载脚本导致频繁 GC 以及 OOM,最好每次热加载时清理一下缓存。
groovyClassLoader.clearCache();