更多博客请见 我的语雀知识库
原文地址:从Java类加载机制谈起:如何实现热部署

一 、class的热替换

ClassLoader中重要的方法:

loadClass:

**ClassLoader.loadClass(…) **是ClassLoader的入口点。当一个类没有指明用什么加载器加载的时候,JVM默认采用AppClassLoader加载器加载没有加载过的class,调用的方法的入口就是loadClass(…)。如果一个class被自定义的ClassLoader加载,那么JVM也会调用这个自定义的ClassLoader.loadClass(…)方法来加载class内部引用的一些别的class文件。重载这个方法,能实现自定义加载class的方式,抛弃双亲委托机制,但是即使不采用双亲委托机制,比如java.lang包中的相关类还是不能自定义一个同名的类来代替,主要因为JVM解析、验证class的时候,会进行相关判断。

defineClass:

系统自带的ClassLoader,默认加载程序的是AppClassLoader,ClassLoader加载一个class,最终调用的是defineClass(…)方法,这时候就在想是否可以重复调用defineClass(…)方法加载同一个类(或者修改过),最后发现调用多次的话会有相关错误:

java.lang.LinkageError 
attempted duplicate class definition

所以一个class被一个ClassLoader实例加载过的话,就不能再被这个ClassLoader实例再次加载(这里的加载指的是,调用了defileClass(…)放方法,重新加载字节码、解析、验证。)。而系统默认的AppClassLoader加载器,他们内部会缓存加载过的class,重新加载的话,就直接取缓存。所与对于热加载的话,只能重新创建一个ClassLoader,然后再去加载已经被加载过的class文件。

二、class的卸载

在Java中class也是可以unload。JVM中class和Meta信息存放在PermGen space区域。如果加载的class文件很多,那么可能导致PermGen space区域空间溢出。引起:java.lang.OutOfMemoryErrorPermGen space. 对于有些Class我们可能只需要使用一次,就不再需要了,也可能我们修改了class文件,我们需要重新加载 newclass,那么oldclass就不再需要了。那么JVM怎么样才能卸载Class呢。 JVM中的Class只有满足以下三个条件,才能被GC回收,也就是该Class被卸载(unload):

  • 该类所有的实例都已经被GC。
  • 加载该类的ClassLoader实例已经被GC。
  • 该类的java.lang.Class对象没有在任何地方被引用。

GC的时机我们是不可控的,那么同样的我们对于Class的卸载也是不可控的。

有启动类加载器加载的类型在整个运行期间是不可能被卸载的(jvm和jls规范).
被系统类加载器和标准扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者标准扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到unreachable的可能性极小.(当然,在虚拟机快退出的时候可以,因为不管ClassLoader实例或者Class(java.lang.Class)实例也都是在堆中存在,同样遵循垃圾收集的规则).
被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到.可以预想,稍微复杂点的应用场景中(尤其很多时候,用户在开发自定义类加载器实例的时候采用缓存的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的).

综合以上三点, 一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的.同时,我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下来实现系统中的特定功能.


三、Tomcat中关于类的加载与卸载

Tomcat中与其说有热加载,还不如说是热部署来的准确些。因为对于一个应用,其中class文件被修改过,那么Tomcat会先卸载这个应用(Context),然后重新加载这个应用,其中关键就在于自定义ClassLoader的应用。这里有篇文章很好的介绍了tomcat中对于ClassLoader的应用。 Tomcat启动的时候,ClassLoader加载的流程:

  1. system classloader即AppClassLoader加载 {catalina.home}/bin里面的jar包,也就是tomcat启动相关的jar包。
  2. Tomcat启动类Bootstrap中有3个classloader属性,catalinaLoader、commonLoader、sharedLoader在Tomcat7中默认他们初始化都为同一个StandardClassLoader实例。具体的也可以在 {catalina.home}/bin/bootstrap.jar包中的catalina.properites中进行配置
  3. StandardClassLoader加载 {catalina.home}/lib下面的所有Tomcat用到的jar包
  4. 一个Context容器,代表了一个app应用。Context–>WebappLoader–>WebClassLoader。并且Thread.contextClassLoader=WebClassLoader。应用程序中的jsp文件、class类、lib/*.jar包,都是WebClassLoader加载的。

当Jsp文件修改的时候,Tomcat更新步骤:

  1. 当访问1.jsp的时候,1.jsp的包装类JspServletWrapper会去比较1.jsp文件最新修改时间和上次的修改时间,以此判断1.jsp是否修改过
  2. 1.jsp修改过的话,那么jspservletWrapper会清除相关引用,包括1.jsp编译后的servlet实例和加载这个servlet的JasperLoader实例
  3. 重新创建一个JasperLoader实例,重新加载修改过后的1.jsp,重新生成一个Servlet实例
  4. 返回修改后的1.jsp内容给用户。
    当app下面的class文件修改的时候,Tomcat更新步骤:

  5. Context容器会有专门线程监控app下面的类的修改情况
  6. 如果发现有类被修改了。那么调用Context.reload()。清楚一系列相关的引用和资源
  7. 然后创新创建一个WebClassLoader实例,重新加载app下面需要的class。

在一个有一定规模的应用中,如果文件修改多次,重启多次的话,java.lang.OutOfMemoryErrorPermGen space这个错误的的出现非常频繁。主要就是因为每次重启重新加载大量的class,超过了PermGen space设置的大小
两种情况可能导致PermGen space溢出

  1. GC(Garbage Collection)在主程序运行期对PermGen space没有进行清理(GC的不可控行),
  2. 重启之前WebClassLoader加载的class在别的地方还存在着引用。

    class卸载、热替换和Tomcat的热部署的分析 - heavensay - BlogJava


类加载的探索

首先谈一下何为热部署(hotswap),热部署是在不重启 Java 虚拟机的前提下,能自动侦测到 class 文件的变化,更新运行时 class 的行为
Java 类是通过 Java 虚拟机加载的,某个类的 class 文件在被 classloader 加载后,会生成对应的 Class 对象,之后就可以创建该类的实例。
默认的虚拟机行为只会在启动时加载类,如果后期有一个类需要更新的话,单纯替换编译的 class 文件,Java 虚拟机是不会更新正在运行的 class。如果要实现热部署,最根本的方式是修改虚拟机的源代码,改变 classloader 的加载行为,使虚拟机能监听 class 文件的更新,重新加载 class 文件。这样的行为破坏性很大。

另一种友好的方法是创建自己的 classloader 来加载需要监听的 class,这样就能控制类加载的时机,从而实现热部署。
本文将具体探索如何实现这个方案。首先需要了解一下 Java 虚拟机现有的加载机制。目前的加载机制,称为双亲委派
系统在使用一个 classloader 来加载类时,会先询问当前 classloader 的父类是否有能力加载,如果父类无法实现加载操作,才会将任务下放到该 classloader 来加载。这种自上而下的加载方式的好处是,让每个 classloader 执行自己的加载任务,不会重复加载类。

但是这种方式却使加载顺序非常难改变,让自定义 classloader 抢先加载需要监听改变的类成为了一个难题。
不过我们可以换一个思路,虽然无法抢先加载该类,但是仍然可以用自定义 classloader 创建一个功能相同的类,让每次实例化的对象都指向这个新的类。当这个类的 class 文件发生改变的时候,再次创建一个更新的类,之后如果系统再次发出实例化请求,创建的对象讲指向这个全新的类。 下面来简单列举一下需要做的工作。

创建自定义的 classloader,加载需要监听改变的类,在 class 文件发生改变的时候,重新加载该类。 改变创建对象的行为,使他们在创建时使用自定义 classloader 加载的 class。


自定义类加载器的实现

自定义加载器仍然需要执行类加载的功能。这里却存在一个问题,同一个类加载器无法同时加载两个相同名称的类,由于不论类的结构如何发生变化,生成的类名不会变,而 classloader 只能在虚拟机停止前销毁已经加载的类,这样 classloader 就无法加载更新后的类了。这里有一个小技巧,让每次加载的类都保存成一个带有版本信息的 class,比如加载 Test.class 时,保存在内存中的类是 Test_v1.class,当类发生改变时,重新加载的类名是 Test_v2.class。但是真正执行加载 class 文件创建 class 的 defineClass 方法是一个 native 的方法,修改起来又变得很困难。所以面前还剩一条路,那就是直接修改编译生成的 class 文件。 利用 ASM 修改 class 文件 可以修改字节码的框架有很多,比如 ASM,CGLIB。本文使用的是 ASM。先来介绍一下 class 文件的结构,class 文件包含了以下几类信息:

第一个是类的基本信息,包含了访问权限信息,类名信息,父类信息,接口信息。 第二个是类的变量信息。 第三个是方法的信息。

ASM 会先加载一个 class 文件,然后严格顺序读取类的各项信息,用户可以按照自己的意愿定义增强组件修改这些信息,最后输出成一个新的 class。 首先看一下如何利用 ASM 修改类信息。

  1. 利用 ASM 修改字节码
     ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); 
     ClassReader cr = null;     
     String enhancedClassName = classSource.getEnhancedName(); 
     try { 
         cr = new ClassReader(new FileInputStream( 
                 classSource.getFile())); 
     } catch (IOException e) { 
         e.printStackTrace(); 
         return null; 
     } 
     ClassVisitor cv = new EnhancedModifier(cw, 
             className.replace(".", "/"), 
             enhancedClassName.replace(".", "/")); 
     cr.accept(cv, 0);
    

    ASM 修改字节码文件的流程是一个责任链模式,首先使用一个 ClassReader 读入字节码,然后利用 ClassVisitor 做个性化的修改,最后利用 ClassWriter 输出修改后的字节码。 之前提过,需要将读取的 class 文件的类名做一些修改,加载成一个全新名字的派生类。这里将之分为了 2 个步骤。 第一步,先将原来的类变成接口。

  2. 重定义的原始类
    public Class<?> redefineClass(String className){ 
     ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); 
     ClassReader cr = null; 
     ClassSource cs = classFiles.get(className); 
     if(cs==null){ 
         return null; 
     } 
     try { 
         cr = new ClassReader(new FileInputStream(cs.getFile())); 
     } catch (IOException e) { 
         e.printStackTrace(); 
         return null; 
     } 
     ClassModifier cm = new ClassModifier(cw); 
     cr.accept(cm, 0); 
     byte[] code = cw.toByteArray(); 
     return defineClass(className, code, 0, code.length); 
    

    首先 load 原始类的 class 文件,此处定义了一个增强组件 ClassModifier,作用是修改原始类的类型,将它转换成接口。原始类的所有方法逻辑都会被去掉。 第二步,生成的派生类都实现这个接口,即原始类,并且复制原始类中的所有方法逻辑。之后如果该类需要更新,会生成一个新的派生类,也会实现这个接口。这样做的目的是不论如何修改,同一个 class 的派生类都有一个共同的接口,他们之间的转换变得对外不透明。

  3. 定义一个派生类
    // 在 class 文件发生改变时重新定义这个类
    private Class<?> redefineClass(String className, ClassSource classSource){ 
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); 
    ClassReader cr = null; 
    classSource.update(); 
    String enhancedClassName = classSource.getEnhancedName();       
    try { 
        cr = new ClassReader( 
                new FileInputStream(classSource.getFile())); 
    } catch (IOException e) { 
        e.printStackTrace(); 
        return null; 
    } 
    EnhancedModifier em = new EnhancedModifier(cw, className.replace(".", "/"), 
            enhancedClassName.replace(".", "/")); 
    ExtendModifier exm = new ExtendModifier(em, className.replace(".", "/"), 
            enhancedClassName.replace(".", "/")); 
    cr.accept(exm, 0); 
    byte[] code = cw.toByteArray(); 
    classSource.setByteCopy(code); 
    Class<?> clazz = defineClass(enhancedClassName, code, 0, code.length); 
    classSource.setClassCopy(clazz); 
    return clazz; 
    

再次 load 原始类的 class 文件,此处定义了两个增强组件,一个是 EnhancedModifier,这个增强组件的作用是改变原有的类名。第二个增强组件是 ExtendModifier,这个增强组件的作用是改变原有类的父类,让这个修改后的派生类能够实现同一个原始类(此时原始类已经转成接口了)。 自定义 classloader 还有一个作用是监听会发生改变的 class 文件,classloader 会管理一个定时器,定时依次扫描这些 class 文件是否改变。


四、改变创建对象的行为

Java 虚拟机常见的创建对象的方法有两种,一种是静态创建,直接 new 一个对象,一种是动态创建,通过反射的方法,创建对象。 由于已经在自定义加载器中更改了原有类的类型,把它从类改成了接口,所以这两种创建方法都无法成立。我们要做的是将实例化原始类的行为变成实例化派生类。 对于第一种方法,需要做的是将静态创建,变为通过 classloader 获取 class,然后动态创建该对象。

  1. 替换后的指令集所对应的逻辑
    // 原始逻辑
    Greeter p = new Greeter();
    // 改变后的逻辑
    IGreeter p = (IGreeter)MyClassLoader.getInstance().
    findClass(com.example.Greeter).newInstance();
    

    这里又需要用到 ASM 来修改 class 文件了。查找到所有 new 对象的语句,替换成通过 classloader 的形式来获取对象的形式。

  2. 利用 ASM 修改方法体
    @Override 
    public void visitTypeInsn(int opcode, String type) { 
     if(opcode==Opcodes.NEW && type.equals(className)){ 
         List<LocalVariableNode> variables = node.localVariables; 
         String compileType = null; 
         for(int i=0;i<variables.size();i++){ 
             LocalVariableNode localVariable = variables.get(i); 
             compileType = formType(localVariable.desc); 
             if(matchType(compileType)&&!valiableIndexUsed[i]){ 
                 valiableIndexUsed[i] = true; 
                 break; 
             } 
         } 
     mv.visitMethodInsn(Opcodes.INVOKESTATIC, CLASSLOAD_TYPE, 
         "getInstance", "()L"+CLASSLOAD_TYPE+";"); 
     mv.visitLdcInsn(type.replace("/", ".")); 
     mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, CLASSLOAD_TYPE, 
         "findClass", "(Ljava/lang/String;)Ljava/lang/Class;"); 
     mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Class", 
         "newInstance", "()Ljava/lang/Object;"); 
     mv.visitTypeInsn(Opcodes.CHECKCAST, compileType); 
     flag = true; 
     } else { 
         mv.visitTypeInsn(opcode, type); 
     } 
     }
    

    对于第二种创建方法,需要通过修改 Class.forName()和 ClassLoader.findClass()的行为,使他们通过自定义加载器加载类。


五、使用 JavaAgent 拦截默认加载器的行为

之前实现的类加载器已经解决了热部署所需要的功能,可是 JVM 启动时,并不会用自定义的加载器加载 classpath 下的所有 class 文件,取而代之的是通过应用加载器去加载。如果在其之后用自定义加载器重新加载已经加载的 class,有可能会出现 LinkageError 的 exception。所以必须在应用启动之前,重新替换已经加载的 class。如果在 jdk1.4 之前,能使用的方法只有一种,改变 jdk 中 classloader 的加载行为,使它指向自定义加载器的加载行为。好在 jdk5.0 之后,我们有了另一种侵略性更小的办法,这就是 JavaAgent 方法,JavaAgent 可以在 JVM 启动之后,应用启动之前的短暂间隙,提供空间给用户做一些特殊行为。比较常见的应用,是利用 JavaAgent 做面向方面的编程,在方法间加入监控日志等。 JavaAgent 的实现很容易,只要在一个类里面,定义一个 premain 的方法。

  1. 一个简单的 JavaAgent
    public class ReloadAgent { 
    public static void premain(String agentArgs, Instrumentation inst){ 
        GeneralTransformer trans = new GeneralTransformer(); 
        inst.addTransformer(trans); 
    } 
     }
    

    然后编写一个 manifest 文件,将 Premain-Class属性设置成定义一个拥有 premain方法的类名即可。 生成一个包含这个 manifest 文件的 jar 包。

    manifest-Version: 1.0 
    Premain-Class: com.example.ReloadAgent 
    Can-Redefine-Classes: true
    

    最后需要在执行应用的参数中增加 -javaagent参数 , 加入这个 jar。同时可以为 Javaagent增加参数,下图中的参数是测试代码中 test project 的绝对路径。这样在执行应用的之前,会优先执行 premain方法中的逻辑,并且预解析需要加载的 class。 这里利用 JavaAgent替换原始字节码,阻止原始字节码被 Java 虚拟机加载。只需要实现 一个 ClassFileTransformer的接口,利用这个实现类完成 class 替换的功能。

  2. 替换 class
    @Override 
    public byte [] transform(ClassLoader paramClassLoader, String paramString, 
                          Class<?> paramClass, ProtectionDomain paramProtectionDomain, 
                          byte [] paramArrayOfByte) throws IllegalClassFormatException { 
     String className = paramString.replace("/", "."); 
     if(className.equals("com.example.Test")){ 
         MyClassLoader cl = MyClassLoader.getInstance(); 
         cl.defineReference(className, "com.example.Greeter"); 
         return cl.getByteCode(className); 
     }else if(className.equals("com.example.Greeter")){ 
         MyClassLoader cl = MyClassLoader.getInstance(); 
         cl.redefineClass(className); 
         return cl.getByteCode(className); 
     } 
     return null; 
    }
    

    至此,所有的工作大功告成。