一、含义
类的热替换,是指程序在运行的时候,对内存方法区中类定义进行替换。因为堆中的 Class 对象是对方法区对象的封装,所以可以理解为对Class对象的替换,当一个Class被替换后,系统无需重启,替换的类会立即生效。
说明:在类的加载过程中,类的结构信息会存在在JVM的方法区中,类的具体对象会在堆中分配内存空间。
可以看另一篇博文: Java虚拟机的类加载机制
二、类的加载
在Java中,类的实例化分为两部分:类的加载和类的实例化。而类的加载又分为显式加载和隐式加载。
我们平时使用new创建类实例时,其实就是隐式地包含了类的加载过程。对于类的显式加载,比较常用的是用 Class.forName()
方法。其实,它们都是通过调用 ClassLoader
类的 loadClass()
方法来完成类的实际加载工作。直接调用 ClassLoader
的loadClass()
方法是另外一种不常用的显式加载类的方法。
下面来介绍一下ClassLoader类:
- ClassLoader 是一个抽象类
- ClassLoader 的实例把读入的Java字节码类装载到JVM中
- ClassLoader 可以定制,满足不同的字节码流获取方式
- ClassLoader 负责类装载过程中的加载阶段
三、类加载器 ClassLoader
ClassLoader在加载类时有一定的层次关系和规则。在Java中,有四种类型的类加载器,分别为:
- BootStrapClassLoader(启动ClassLoader)
- ExtClassLoader(扩展ClassLoader)
- AppClassLoader(应用ClassLoader)
- Custom ClassLoader(自定义ClassLoader)
这四种类加载器分别负责不同路径的类的加载,并形成了一个类加载的层次结构。见下图
说明:
- BootStrapClassLoader:处于类加载器层次结构的最高层,默认负责加载 jre/lib/rt.jar 路径下的核心类,或 -Xbootclasspath 选项指定的jar包
- ExtClassLoader:默认加载路径为 %JAVA_HOME%/lib/ext/*.jar
- AppClassLoader:默认加载路径为环境变量 CLASSPATH 中设定的值。也可以通过 -classpath 选项进行指定
- Custom ClassLoader:可以根据用户的需要定制自己的类加载过程,在运行期进行指定类的动态实时加载;
热替换也是基于该类,来绕过Java类的既定加载过程
一般来说,这四种类加载器会形成一种父子关系,高层为低层的父加载器。在类进行加载时,首先会自底向上挨个检查是否已经加载了指定类,如果已经加载,则直接返回该类的引用。如果到最高层也没有找到加载过指定类,那么会自顶向下挨个尝试加载,直到用户自定义类加载器,如果还不能成功,就会抛出异常。过程如下图:
每个类加载器有自己的名字空间,对于同一个类加载器实例来说,名字相同的类只能存在一个,并且仅加载一次。不管该类有没有变化,下次再需要加载时,它只是从自己的缓存中直接返回已经加载过的类引用。
我们编写的应用类,默认情况下都是通过 AppClassLoader 进行加载的。当我们使用 new 关键字或使用 Class.forName() 来加载类时,所要加载的类都是由调用 new(Class.forName)类的类加载器(也是AppClassLoader)进行加载的。
要想实现Java类的热替换,首先必须要让系统中同名类的不同版本实例的共存,要想实现同一个类的不同版本的共存,必须要通过不同的类加载器来加载该类的不同版本。另外,为了能够绕过Java类的既定加载过程,需要实现自己的类加载器。
四、自定义类加载器CustomLoader
为什么要自定义类加载器?
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄漏
1、ClassLoader方法介绍
为了能够完全掌控类的加载过程,需要自定义类加载器,且需要从ClassLoader继承。下面来介绍一下ClassLoader类中和热替换有关的一些重要方法。
findLoadedClass()
该方法会在对应加载器的名字空间中寻找指定的类是否已存在,如果存在就返回给类的引用,否则就返回null。每个类加载器都维护有自己的一份已加载类名字空间,其中不能出现两个同名的类。凡是通过该类加载器加载的类,无论是直接的还是间接的,都保存在自己的名字空间中,这里的直接是指,存在于该类加载器的加载路径上并由该加载器完成加载,间接是指,由该类加载器把类的加载工作委托给其他类加载器完成类的实际加载。
getSystemClassLoader()
该方法返回系统使用的 ClassLoader。可以在自定义的类加载器中通过该方法把一部分工作转交给系统类加载器去处理。
defineClass()
该方法接收以字节数组表示的类字节码,并把它转换成Class实例。该方法转换一个类的同时,会先要求装载该类的父类以及实现的接口类。
loadClass()
加载类的入口方法,调用该方法完成类的显式加载。通过对该方法的重写,可以完全控制和管理类的加载过程。执行loadClass方法,只是单纯的把类加载到内存,并不是对类的主动使用,不会引起类的初始化。
resolveClass()
链接一个指定的类。这是一个在某些情况下确保类可用的必要方法。
2、自定义加载器
了解了上面的这些方法,接下来实现一个自定义的类加载器来实现热替换,在给出示例代码前,再重申两点内容:
- 要想实现同一个类的不同版本的共存,那么这些不同版本必须由不同的类加载器进行加载,因此就不能把这些类的加载工作委托给系统加载器来完成,因为它们只有一份。
- 为了做到这一点,就不能采用系统默认的类加载器委托规则,也就是说我们定制的类加载器的父加载器必须设置为null。
该定制的类加载器的实现代码如下:
public class CustomClassLoader extends ClassLoader {
private String basedir; // 需要该类加载器直接加载的类文件的基目录
private HashSet className; // 需要由该类加载器直接加载的类名
public CustomClassLoader(String basedir, String[] clazns) throws Exception {
super(null); // 指定父类加载器为 null
this.basedir = basedir;
className = new HashSet();
loadClassByMe(clazns);
}
private void loadClassByMe(String[] clazns) throws Exception {
for (int i = 0; i < clazns.length; i++) {
loadDirectly(clazns[i]);
className.add(clazns[i]);
}
}
private Class loadDirectly(String name) throws Exception, Exception {
Class cls = null;
StringBuffer sb = new StringBuffer(basedir);
String classname = name.replace('.', File.separatorChar) + ".class";
sb.append(File.separator + classname);
System.out.println(sb.toString());
File classF = new File(sb.toString());
cls = instantiateClass(name, new FileInputStream(classF), classF.length());
return cls;
}
private Class instantiateClass(String name, InputStream fin, long len) throws Exception {
byte[] raw = new byte[(int) len];
fin.read(raw);
fin.close();
return defineClass(name, raw, 0, raw.length);
}
protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class cls = null;
cls = findLoadedClass(name);
if (!this.className.contains(name) && cls == null)
cls = getSystemClassLoader().loadClass(name);
if (cls == null)
throw new ClassNotFoundException(name);
if (resolve)
resolveClass(cls);
return cls;
}
public static void main(String[] args) throws FileNotFoundException, IOException {
new Timer().schedule(new TimerTask() {
@Override
public void run() {
try {
// 每次都创建出一个新的类加载器
CustomClassLoader customClassLoader = new CustomClassLoader(
CustomClassLoader.class.getResource("").getFile(), new String[] { "Foo" });
Class<?> cls = customClassLoader.loadClass("Foo");
Object foo = cls.newInstance();
Method m = foo.getClass().getMethod("sayHi", new Class[] {});
m.invoke(foo, new Object[] {});
} catch (Exception ex) {
ex.printStackTrace();
}
}
}, 0, 1000L);
}}
在main方法中:编写一个定时器任务,每隔1秒钟执行一次。其中,程序会创建新的类加载器实例加载Foo类,生成实例,并调用sayHi()方法。此处第一次加载的事Foo.java文件,该文件内容如下:
public class Foo implements FooInterface {
@Override
public void sayHi() {
// TODO Auto-generated method stub
System.out.println("hi\tv1");
}
}
对应接口文件为:
public interface FooInterface {
public void sayHi();
}
运行结果如下:
接下来,重写编写一份 Foo.java 类(连同接口),修改其中的 sayHi() 方法的打印内容,在 cmd 中用 javac 重新编译后并拷贝到项目的对应目录下,在系统正常运行的情况下,替换掉原来的 Foo.class,会看到系统会打印出更改后的内容。
这里需要分析的是:
如果把main函数中的代码改为:Foo foo = (Foo)cls.newInstance(); 会发现会抛出 ClassCastException 异常。这是因为
在上面的例子中 cls 是由CustomClassLoader 加载的,而 foo 变量类型声名类却是由 run 方法所属的类的加载器(默认为 AppClassLoader)加载的,因此是完全不同的类型。
如果把main函数中的代码改为:FooInterface foo = (FooInterface )cls.newInstance(); 会发现还会抛出 ClassCastException 异常。这是因为外部声名和转型部分的 FooInterface 是由 run 方法所属的类加载器加载的,而 Foo 类定义中 implements FooInterface 中的 FooInterface 是由 CustomClassLoader 加载的,因此属于不同的类型转型还是会抛出异常的,但是由于我们在实例化 CustomClassLoader 时是这样的:
String path = CustomClassLoader.class.getResource("").getFile();
CustomClassLoader ccl = new CustomClassLoader (path, new String[]{"Foo"});
其中 仅仅指定 Foo 类由 CustomClassLoader 加载(因为在Foo用javac编译的时候,需要用到它实现的接口,但在拷贝Foo.class文件的时候,只拷贝了Foo.class一个文件,并没有拷贝它的接口文件),而其实现的 FooInterface 接口文件会委托给系统类加载器加载,因此转型成功,采用接口调用的代码如下:
Object foo = ccl.newInstance();
FooInterface foo = (FooInterface )ccl.newInstance();
foo.sayHello();
五、总结
上面介绍了类的加载过程和加载的原理,并阐述了Java热替换。其实上面的程序可以写的再完美一点,在进行替换后,可以把老的Class给卸载掉,但需要注意的是: 只有自定义类加载器加载的类才可以卸载。卸载的办法很简单,把类对象,Class对象,classloader对象的引用设置为null,JVM就会把它们当作是垃圾(此处可以了解JVM的垃圾回收机制),会在适当的时候,卸载掉内存方法区中的二进制数据。
最后补充一点 类加载器的命名空间:
- 命名空间由加载器和所有的父加载器所加载的类构成
- 在同一个命名空间中,不可能出现类名相同的两个类
- 在不同的命名空间中,可能出现类名相同的两个类(类名指类全称)
- 由子加载器加载的类能看见父加载器加载的类,反之不可以;(比如java.lang.String类,我们自己写的类肯定能看见,但是父加载器肯定看不见我们自己定义的类)
- 如果两个加载器之间没有直接或者间接的父子关系,那么两个加载器加载的类是相互不可见的