继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

从Java的类加载机制谈起:聊聊Java中如何实现热部署(热加载)

程序员黄小斜
关注TA
已关注
手记 300
粉丝 124
获赞 549

一 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的卸载也是不可控的。

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

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

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

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

Tomcat启动的时候,ClassLoader加载的流程:

1 Tomcat启动的时候,用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更新步骤:

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

在一个有一定规模的应用中,如果文件修改多次,重启多次的话,java.lang.OutOfMemoryErrorPermGen space这个错误的的出现非常频繁。主要就是因为每次重启重新加载大量的class,超过了PermGen space设置的大小。两种情况可能导致PermGen space溢出。 一、GC(Garbage Collection)在主程序运行期对PermGen space没有进行清理(GC的不可控行), 二、重启之前WebClassLoader加载的class在别的地方还存在着引用。

在 Java 开发领域,热部署一直是一个难以解决的问题,目前的 Java 虚拟机只能实现方法体的修改热部署,对于整个类的结构修改,仍然需要重启虚拟机,对类重新加载才能完成更新操作。对于某些大型的应用来说,每次的重启都需要花费大量的时间成本。虽然 osgi 架构的出现,让模块重启成为可能,但是如果模块之间有调用关系的话,这样的操作依然会让应用出现短暂的功能性休克。本文将探索如何在不破坏 Java 虚拟机现有行为的前提下,实现某个单一类的热部署,让系统无需重启就完成某个类的更新。

类加载的探索

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

另一种友好的方法是创建自己的 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,然后动态创建该对象。
清单 4. 替换后的指令集所对应的逻辑

// 原始逻辑
Greeter p = new Greeter();
// 改变后的逻辑
IGreeter p = (IGreeter)MyClassLoader.getInstance().
findClass(“com.example.Greeter”).newInstance();

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

清单 5. 利用 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 的方法。
清单 6. 一个简单的 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 替换的功能。
清单 7. 替换 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; 
 }

至此,所有的工作大功告成,欣赏一下 hotswap 的结果吧。

java的热部署和热加载

ps:热部署和热加载其实是两个类似但不同的概念,之前理解不深,so,这篇文章重构了下。

一、热部署与热加载

在应用运行的时升级软件,无需重新启动的方式有两种,热部署和热加载。

对于Java应用程序来说,热部署就是在服务器运行时重新部署项目,热加载即在在运行时重新加载class,从而升级应用。

二、实现原理

热加载的实现原理主要依赖java的类加载机制,在实现方式可以概括为在容器启动的时候起一条后台线程,定时的检测类文件的时间戳变化,如果类的时间戳变掉了,则将类重新载入。

对比反射机制,反射是在运行时获取类信息,通过动态的调用来改变程序行为;
热加载则是在运行时通过重新加载改变类信息,直接改变程序行为。

热部署原理类似,但它是直接重新加载整个应用,这种方式会释放内存,比热加载更加干净彻底,但同时也更费时间。

三、在java中应用

1.生产环境

热部署作为一个比较灵活的机制,在实际的生产上运用还是有,但相对很少,热加载则基本没有应用。分析如下

  • 一、安全性

热加载这种直接修改jvm中字节码的方式是难以监控的,不同于sql等执行可以记录日志,直接字节码的修改几乎无法记录代码逻辑的变化,对既有代码行为的影响难以控制,对于越注重安全的应用,热加载带来的风险越大,这好比给飞行中的飞机更换发动机。

  • 二、适用的情景

技术大部分是跟需求挂钩的,而需要热部署的情景很少。

  1. 频繁的部署并且启动耗时长的应用
  2. 无法停止服务的应用

在生产中,并没有需要频繁部署的应用,即使是敏捷,再快也是一周一次的迭代,并且通过业务划分和模块化编程,部署的代价完全可以忽略不计,对于现有的应用,启动耗时再长,也并非长到无法忍受,如果真的这么长,那更应该考虑的是如何进行模块拆分,分布式部署了。

对于无法停止服务的应用,比如现在的云计算平台这样分布式应用,采用分批上线也可以满足需求,类似热部署方案应该是放在最后考虑的解决方案。

2.开发环境

在生产中,不会有频繁的部署并且启动耗时长的应用,但由于云计算的兴起,热部署还是有其应用。
而热加载有点玩火,太危险了。但在开发和debug中,频繁启动应用却随处可见,热加载机制可以极大的提升开发效率。这两种机制,在开发中还有另外一种称呼—开发者模式。

对于大型项目:往往启/停需要等待几分钟时间。更浪费时间的是,对于一个类中的方法的调试过程,如果修改多次,需要反复的启停服务器,浪费的时间更多。

以目前的crm项目为例,其启动时间为5m,以一天debug重启十次,一个月工作20天来算,每年重启耗时25人日,如果能完全使用热加载,每年节省重启时间近1人月。

crm pool启动耗时

1.struts2热加载

在struts2中热加载即开发者模式,在struts.xml配置

<constant name="struts.devMode" value="true" />

从而当更改struts.xml文件后不需要重新启动服务器就可以进行程序调试。

2.开发时使用tomcat热加载

tomcat本身默认开启了热部署方式,但热部署是直接重新加载整个应用,耗时跟重启服务器差不多,我们需要的其实是热加载,即修改了哪个class,只重新加载这一个class,这样耗时几乎为0。
对于tomcat5.x 以上版本,均已支持一定程度上得热加载,但这种方式只针对代码行级别的,也就是说如果新删方法,注解,类,或者变量时是无效的,只能重启,这是我目前在公司开发时用的方式,可以显著降低debug时的重启次数,提高开发效率

1.将tomcat server.xml文件的context reloadable 值置为false 或者在web modules中编辑取消Auto reloading选项。

    <Context reloadable="false"/>

2.修改eclipse中的server配置

这样做可以在在修改代码之后,不会自动重启服务器,而只加载代码,新增一行java代码ctrl+s后直接刷新页面或调用接口即可看到效果,无需重启tomcat。

3.远程debug中使用tomcat热加载

tomcat的热加载机制不仅可以在本地debug时,tomcat的远程调试也支持热部署,通过eclipse debug远程到远程tomcat上,修改本地代码,ctrl+s后直接刷新页面后调用接口,即可发现远程tomcat已将本地代码进行了热加载。

4.jrebel插件方式

jrebel插件可以进行更彻底的热加载,不仅包括类,甚至支持spring 等配置文件的热加载,但公司项目开发环境复杂,目前在eclipse中配置一直没有成功,只能使用tomcat自带的热加载机制。

总结

在实际生产中热部署在云计算中运用挺多,但热加载没有,而在开发中,热加载可以显著的提升工作效率,强烈推荐使用热加载方式,不仅tomcat,大多数其他servlet容器也支持这种方式,大家可以自行搜索相关技巧。

HotSwap和JRebel原理

HotSwap和Instrumentation

在2002年的时候,Sun在Java 1.4的JVM中引入了一种新的被称作HotSwap的实验性技术,这一技术被合成到了Debugger API内部,其允许调试者使用同一个类标识来更新类的字节码。这意味着所有对象都可以引用一个更新后的类,并在它们的方法被调用的时候执行新的代码,这就避免了无论何时只要有类的字节码被修改就要重载容器的这种要求。所有新式的IDE(包括Eclipse、IDEA和NetBeans)都支持这一技术,从Java 5开始,这一功能还通过Instrumentation API直接提供给Java应用使用。

hotswap

不幸的是,这种重定义仅限于修改方法体——除了方法体之外,它既不能添加方法或域,也不能修改其他任何东西。这限制了HotSwap的实用性,且其还因其他的一些问题而变得更糟:

Java编译器常常会创建合成的方法或是域,尽管你仅是修改了一个方法体(比如说,在添加一个类字面常量(class literal)、匿名的和内部的类的时候等等)。 在调试模式下运行常常会降低应用的速度或是引入其他的问题。

这些情况导致了HotSwap很少被使用,较之应该可能被使用的频度要低。

为什么HotSwap仅限于对方法体起作用?

自从引入了HotSwap之后,在最近的10年,这一问题已经被问了非常多次。在支持做整组改变的JVM调用的bug中,这是一个得票率最高的bug ,但到目前为止,这一问题一直没有被落实。

一个声明:我不能说是一个JVM专家,我对JVM是如何实现的在总体上有着一个很好的理解,这几年来我有和少数几个(前)Sun工程师谈过,不过我并没有验证我在这里说的每一件事情。不过话虽如此,对于这个bug依然处开发状态的原因我确实是有一些想法的(不过如果你更清楚其中的原因的话,欢迎指正)。

JVM是一种做了重度优化的软件,运行在多个平台上。性能和稳定性是其最高的优先事项。为了在不同的环境中支持这些事项,Sun的JVM提供了这样的功能特色:

 两个重度优化的即时编译器(-client和-server)
 几个多代(multi-generational )垃圾收集器

这些功能特性使得类模式(schema)的发展变成了一个相当大的挑战。为了理解这其中的原因,我们需要稍微靠近一点看一看,到底是需要用什么来支持方法和域的添加操作(甚至更深入一些,修改继承的层次结构)。

在被加载到JVM中时,对象是由内存中的结构来表示的,结构占据了某个特定大小(它的域加上元数据)的连续的内存区域。为了添加一个域,我们需要调整结构的大小,但因为临近的区域可能已被占用,我们就需要把整个结构重新分配到一个不同的区域中,这一区域中有足够可用的空间来把它填写进来。现在,由于我们实际上是更新了一个类(并不仅是某个对象),所以我们不得不对该类的每一个对象都做这样的一件事。

这本身并不难实现——Java垃圾收集器就已经是随时都在做重分配对象的工作的了。问题是,一个“堆”的抽象就仅是一个抽象而已。内存的实际布局取决于当前活动的垃圾收集器,而且,为了能与所有这些对象兼容,重分配应该有可能会被委派给活动的垃圾收集器。JVM在重分配期间还需要挂起,因此其在此期间同时进行GC工作也是合理的。

添加一个方法并不要求更新对象的结构,但确实是需要更新类的结构的,这也会体现在堆上。不过考虑一下这种情况:从类被载入之后的那一刻起,其从本质上来说就是被永久冻结了的。这使得JIT(Just-In-Time)能够完成JVM执行的主要优化操作——内联。应用程序热点中的大多数方法调用会被取消,这些代码会被拷贝到对其做调用的方法中。一个简单的检测会被插进来,用以确保目标对象确实是我们所认为的对象。

于是就有了这样可笑的事:在我们能够添加方法到类中的时候,这种“简单的检查”是不够的。我们需要的是一个相当复杂的检查,需要这样更复杂的检查来确保没有使用了相同名字的方法被添加到目标类以及目标类的超类中。另外,我们也可以跟踪所有的内联点和它们的依赖,并在类被更新时,解除对它们所做的优化。两种方式可选择,或是付出性能方面的代价,或是带来更高的复杂性。

最重要的是,考虑到我们正在讨论的是有着不同的内存模型和指令集的多个平台,它们可能多多少少需要一些特定的处理,因此你给自己带来的是一个代价过高而没有太多投资回报的问题。

jrebel-agent

JRebel介绍

2007年,ZeroTurnaround宣布提供一种被称作JRebel(当时是JavaRebel)的工具,该工具可以在无需动态类加载器的情况下更新类,且只做极少的限制。不像HotSwap要依赖于IDE的集成,这一工具的工作方式是,监控磁盘上实际已编译的.class文件,无论何时只要有文件被更新就更新类。这意味着如果愿意的话,你可以把JRebel和文本编辑器、命令行的编译器放在一起使用。当然,它也被巧妙地整合到了Eclipse、InteliJ和NetBeans中。与动态的类加载器不一样,JRebel保留了所有现有的对象和类的标识和状态,允许开发者继续使用他们的应用而不会产生延迟。

如何使之生效?

对于初学者来说,JRebel工作在与HotSwap不同的一个抽象层面上。鉴于HotSwap是工作在虚拟机层面上,且依赖于JVM的内部运作,JRebel用到了JVM的两个显著的功能特征——抽象的字节码和类加载器。类加载器允许JRebel辨别出类被加载的时刻,然后实时地翻译字节码,用以在虚拟机和可执行代码之间创建另一个抽象层。

也有人使用这一功能特性来提供分析器、性能监控、后续(continuation)、软件事务性内存以及甚至是分布式的堆。 把字节码抽象和类加载器结合在一起,这是一种强大的组合,可被用来实现各种比类重载还要不寻常的功能。当我们越是深入地研究这一问题,我们就会看到面临的挑战并不仅是在类重载这件事上,而且是还要在性能和兼容性方面没有明显退化的情况下来做这件事情,

正如我们在Reloading Java Classes 101 一文中所做的回顾一样,重载类存在的问题是,一旦类被载入,它就不能被卸载或是改变;但是只要我们愿意,我们就可以自由地加载新的类。为了理解在理论上我们是如何重载类的,让我们来研究一下Java平台上的动态语言。具体来说,让我们先来看一看JRudy(我们做了许多的简化,以免对任何重要人物造成折磨)。

尽管JRuby以“类(class)”作为其功能特性,但在运行时,其每个对象都是动态的,任何时候都可以加入新的域和方法。这意味着JRuby对象与Map没有什么两样,有着从方法名字到方法实现的映射,以及域名到其值的映射。这些方法的实现被包含在匿名的类中,在遇到方法时这些类就会被生成。如果你添加了一个方法,则所有JRuby要做的事情就是生成一个新的匿名类,该类包含了这一方法的方法体。因为每个匿名类都有一个唯一的名称,因此在加载该类是不会有问题的,而这样做的结果是,应用被实时动态地更新了。

从理论上来说,由于字节码翻译通常是用来修改类的字节码,因此若仅仅是为了根据需要创建足够多的类来履行类的功能的话,我们没有什么理由不能使用类中的信息。这样的话,我们就可以使用如JRuby所做的相同转换来把所有的Java类分割成持有者类和方法体类。不幸的是,这样的一种做法会遭受(至少是)如下的问题:

**性能。**这样的设置将意味着,每个方法调用都会遭遇重定向。我们可以做优化,但应用程序的速度将会变慢至少一个数量级,内存的使用也会扶摇直上,因为有这么多的类被创建。 Java的SDK类。Java SDK中的类明显地比应用或是库中的类更加难以处理。此外它们通常会以本地的代码来实现,因此不能以“JRuby”的方式做转换。然而,如果我们让它们保持原样的话,那么就会引发各种的不兼容性错误,这些错误有可能是无法绕开的。 **兼容性。**尽管Java是一种静态的语言,但是它包含了一些动态的特性,比如说反射和动态代理等。如果我们采用了“JRuby”式的转换的话,这些功能特性就会失效,除非我们使用自己的类来替换掉Reflection API,而这些类知道这些要做的转换。

因此,JRebel并没有采用这样的做法。相反,其使用了一种更复杂的方法,基于先进的编译技术,留给我们一个主类和几个匿名的支持类,这些类由JIT的转换运行时做支持,其允许所进行的修改不会带来任何明显的性能或是兼容性的退化。它还

留有尽可能多完整的方法调用,这意味着JRebel把性能开销降低到了最小,使其轻量级化。
避免了改编(instrument)Java SDK,除了少数几个需要保持兼容性的地方外。
调整Reflection API的结果,这样我们就能够把这些结果中已添加/已删除的成员正确地包含进来。这也意味着注解(Annotation)的改变对于应用来说是可见的。

除了类重载之外——还有归档文件

重载类是一件Java开发者已经抱怨了很久的事情,不过一旦我们解决了它之后,另外的一些问题就随之而来了。

Java EE标准的制定并未怎么关注开发的周转期(Turnaround)(指的是从对代码做修改到观察到改变在应用中造成的影响这一过程所花费的时间)。其设想的是,所有的应用和它们的模块都被打包到归档文件(JAR、WAR和EAR)中,这意味着在能够更新应用中的任何文件之前,你需要更新归档文件——这通常是一个代价高昂的操作,涉及了诸如Ant或是Maven这一类的构建系统。正如我们在Reloading Java Classes 301 所做的讨论那样,可以通过使用展开式的开发和增量的IDE构建来尽量减少花销,不过对于大型的应用来说,这种做法通常不是一个可行的选择。

为了解决这一问题,在JRebel 2.x中,我们为用户开发了一种方式来把归档的应用和模块映射回到工作区中——用户在每个应用和模块中创建一个rebel.xml配置文件,该文件告诉JRebel在哪里可以找到源文件。JRebel与应用服务器整合在一起,当某个类或是资源被更新时,其被从工作区中而不是从归档文件中读入。

workspace-map

这一做法不仅允许类的即时更新,且允许诸如HTML、XML、JSP、CSS、.properties等之类的任何类型的资源的即时更新。Maven用户甚至不需要创建一个rebel.xml文件,因为Maven插件会自动地生成该文件。

除了类重载之外——还有配置和元数据

在消除周转期的这一过程中,另一个问题变得明显起来:现如今的应用已不仅仅是类和资源,它们还通过大量的配置和元数据绑定在一起。当配置发生改变时,改变应该被反映到那个正在运行的应用上。然而,仅把对配置文件的修改变成是可见的是不够的,具体的框架必须要要重载配置,把改变反映到应用中才行。

conf

为了在JRebel中支持这些类型的改变,我们开发了一个开源的API ,该API允许我们的团队和第三方的捐献者使用框架特有的插件来使用JRebel的功能特性,把配置中所做的改变传播到框架中。例如,我们支持动态实时地在Spring中添加bean和依赖,以及支持在其他框架中所做的各种各样的改变。

结论

本文总结了在未使用动态类加载器情况下的各种重载Java类的方法。我们还讨论了导致HotSwap局限性的原因,揭示了JRebel幕后的工作方式,以及讨论了在解决类重载问题时出现的其他问题。

打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP