Groovy 热加载与 Java 热加载的对比

Wu Jun 2020-01-09 10:43:49
Categories: > Tags:

之前实践了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方法,使用类加载器InnerLoaderdefineClass方法来将传入的源代码编译为 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();