前提

其实在前面写过的《深入分析Java反射(一)-核心类库和方法》已经介绍过通过类名或者java.lang.Class实例去实例化一个对象,在《浅析Java中的资源加载》中也比较详细地介绍过类加载过程中的双亲委派模型,这篇文章主要是加深一些对类实例化和类加载的认识。

类实例化

在反射类库中,用于实例化对象只有两个方法:

  • T java.lang.Class#newInstance():这个方法只需要提供java.lang.Class<T>的实例就可以实例化对象,如果提供的是无限定类型Class<?>则得到的是Object类型的返回值,可以进行强转。这个方法不支持任何入参,底层实际上也是依赖无参数的构造器Constructor进行实例化。
  • T java.lang.reflect.Constructor#newInstance(Object ... initargs):这个方法需要提供java.lang.reflect.Constructor<T>实例和一个可变参数数组进行对象的实例化,上面提到的T java.lang.Class#newInstance()底层也是依赖此方法。这个方法除了可以传入构造参数之外,还有一个好处就是可以通过``抑制修饰符访问权限检查,也就是私有的构造器也可以用于实例化对象。

在编写反射类库的时候,优先选择T java.lang.reflect.Constructor#newInstance(Object ... initargs)进行对象实例化,目前参考很多优秀的框架(例如Spring)都是用这个方法进行对象实例化。

类加载

类加载实际上由类加载器(ClassLoader)完成,protected Class<?> java.lang.ClassLoader#loadClass(String name, boolean resolve)方法提现了类加载过程中遵循了双亲委派模型,实际上,我们可以覆写此方法完全不遵循双亲委派模型,实现同一个类(这里指的是全类名完全相同)重新加载。JDK中提供类加载相关的特性有两个方法:

  • protected Class<?> java.lang.ClassLoader#loadClass(String name, boolean resolve):通过类加载器实例去加载类,一般应用类路径下的类是由jdk.internal.loader.ClassLoaders$AppClassLoader加载,也可以自行继承java.lang.ClassLoader实现自己的类加载器。
  • public static Class<?> forName(String name, boolean initialize, ClassLoader loader):通过全类名进行类加载,可以通过参数控制类初始化行为。

ClassLoader中的类加载

类加载过程其实是一个很复杂的过程,主要包括下面的步骤:

  • 1、加载过程:使用(自定义)类加载器去获取类文件字节码字节类的过程,Class实例在这一步生成,作为方法区的各种数据类型的访问入口。
  • 2、验证过程:JVM验证字节码的合法性。
  • 3、准备过程:为类变量分配内存并且设置初始值。
  • 4、解析过程:JVM把常量池中的符号替换为直接引用。
  • 5、初始化过程:执行类构造器<cinit>()方法,<cinit>()方法是编译器自动收集所有类变量的赋值动作和静态代码块中的语句合并生成,收集顺序由语句在源文件中出现的顺序决定,JVM保证在子类<cinit>()方法调用前父类的<cinit>()方法已经执行完毕。

ClassLoader#loadClass()方法就是用于控制类加载过程的第一步-加载过程,也就是控制字节码字节数组和类名生成Class实例的过程。ClassLoader中还有一个protected final Class<?> defineClass(String name, byte[] b, int off, int len)方法用于指定全类名和字节码字节数组去定义一个类,我们再次看下loadClass()的源码:

protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// 检查类是否已经加载过,如果已经加载过,则直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
// 委派父类加载器去加载类
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
// 委派父类加载器如果加载失败则调用findClass方法进行加载动作
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
// 扩展点-1
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
// 扩展点-2
protected final void resolveClass(Class<?> c) {
if (c == null) {
throw new NullPointerException();
}
}

实际上,loadClass()方法留下了两个扩展点用于改变类加载的行为,而findClass()方法就是用于扩展父类加载器加载失败的情况下,子类加载器的行为。当然,实际上Class<?> loadClass(String name, boolean resolve)方法是非final的方法,可以整个方法覆写掉,这样子就有办法完全打破双亲委派机制。但是注意一点,即使打破双亲委派机制,子类加载器也不可能重新加载一些由Bootstrap类加载器加载的类库如java.lang.String,这些是由JVM验证和保证的。自定义类加载器的使用在下一节的”类重新加载”中详细展开。

最后还有两点十分重要:

  • 1、对于任意一个类,都需要由加载它的类加载器和这个类本身一起确立其在Java虚拟机中的唯一性,也就是一个类在JVM中的签名是加载它的类加载器和它本身,对于每一个类加载器,都拥有一个独立的类命名空间
  • 2、比较两个类是否”相等”,只有这两个类是由同一个类加载器加载的前提下才有意义。即使这两个类的全类名一致、来源于同一个字节码文件、被同一个Java虚拟机加载,但是加载它们的类加载器不同,那么它们必定不相等。这里相等的范畴包括:Class对象的equals()方法、isAssignableForm()方法、isInstance()方法的返回结果以及使用instanceof关键字做对象所属关系时候的判定等情况。

Class中的类加载

java.lang.Class中的类加载主要由public static Class<?> forName(String name, boolean initialize, ClassLoader loader)方法完成,该方法可以指定全类名、是否初始化和类加载器实例。源码如下:

@CallerSensitive
public static Class<?> forName(String name, boolean initialize,
ClassLoader loader)
throws ClassNotFoundException
{
Class<?> caller = null;
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
// Reflective call to get caller class is only needed if a security manager
// is present. Avoid the overhead of making this call otherwise.
caller = Reflection.getCallerClass();
if (loader == null) {
ClassLoader ccl = ClassLoader.getClassLoader(caller);
if (ccl != null) {
sm.checkPermission(
SecurityConstants.GET_CLASSLOADER_PERMISSION);
}
}
}
return forName0(name, initialize, loader, caller);
}

private static native Class<?> forName0(String name, boolean initialize,
ClassLoader loader,
Class<?> caller) throws ClassNotFoundException;

它最终调用的是JVM的本地接口方法,由于暂时没有能力分析JVM的源码,只能通过forName方法的注释理解方法的功能:

返回给定字符串全限定名称、指定类加载器的类或者接口的Class实例,此方法会尝试对类或者接口进行locate、load and link操作,如果loader参数为null,则使用bootstrap类加载器进行加载,如果initialize参数为true同时类或者接口在早期没有被初始化,则会进行初始化操作。

也就是说initialize参数对于已经初始化过的类或者接口来说是没有意义的。这个方法的特性还可以参考Java语言规范的12章中的内容,这里不做展开。

虽然暂时没法分析JVM本地接口方法native Class<?> forName0()的功能,但是它依赖一个类加载器实例入参,可以大胆猜测它也是依赖于类加载器的loadClass()进行类加载的。

类重新加载

先提出一个实验,如果定义一个类如下:

public class Sample {

public void say() {
System.out.println("Hello Doge!");
}
}

如果使用字节码工具修改say()方法的内容为System.out.println("Hello Throwable!");,并且使用自定义的ClassLoader重新加载一个同类名的Sample类,那么通过new关键字实例化出来的Sample对象调用say()到底打印”Hello Doge!”还是”Hello Throwable!”?

先引入字节码工具javassist用于修改类的字节码:

<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.24.0-GA</version>
</dependency>

下面是测试代码:

// 例子
public class Demo {

public void say() {
System.out.println("Hello Doge!");
}
}

// 一次性使用的自定义类加载器
public class CustomClassLoader extends ClassLoader {

private final byte[] data;

public CustomClassLoader(byte[] data) {
this.data = data;
}

@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
if (!Demo.class.getName().equals(name)) {
return super.loadClass(name);
}
return defineClass(name, data, 0, data.length);
}
}

public class Main {

public static void main(String[] args) throws Exception {

String name = Demo.class.getName();
CtClass ctClass = ClassPool.getDefault().getCtClass(name);
CtMethod method = ctClass.getMethod("say", "()V");
method.setBody("{System.out.println(\"Hello Throwable!\");}");
byte[] bytes = ctClass.toBytecode();
CustomClassLoader classLoader = new CustomClassLoader(bytes);
// 新的Demo类,只能反射调用,因为类路径中的Demo类已经被应用类加载器加载
Class<?> newDemoClass = classLoader.loadClass(name);
// 类路径中的Demo类
Demo demo = new Demo();
demo.say();
// 新的Demo类
newDemoClass.getDeclaredMethod("say").invoke(newDemoClass.newInstance());
// 比较
System.out.println(newDemoClass.equals(Demo.class));
}
}

执行后输出:

Hello Doge!
Hello Throwable!
false

这里得出的结论是:

  • new关键字只能使用在当前类路径下的类的实例化,而这些类都是由应用类加载器加载,如果上面的例子中newDemoClass.newInstance()强制转换为Demo类型会报错。
  • 通过自定义类加载器加载的和当前类路径相同名全类名的类只能通过反射去使用,而且即使全类名相同,由于类加载器隔离,它们其实是不相同的类。

如何避免类重新加载导致内存溢出

实际上,JDK没有提供方法去卸载一个已经加载的类,也就是类的生命周期是由JVM管理的,因此要解决类重新加载导致内存溢出的问题归根结底就是解决重新加载的类被回收的问题。由于创建出来是的java.lang.Class对象,如果需要回收它,则要考虑下面几点:

  • 1、java.lang.Class对象反射创建的实例需要被回收。
  • 2、java.lang.Class对象不能被任何地方强引用。
  • 3、加载java.lang.Class对象的ClassLoder已经被回收。

基于这几点考虑可以做个试验验证一下:

public class Demo {
// 这里故意建立一个数组占用大量内存
private int[] array = new int[1000];

public void say() {
System.out.println("Hello Doge!");
}
}

public class Main {

private static final Map<ClassLoader, List<Class<?>>> CACHE = new HashMap<>();

public static void main(String[] args) throws Exception {
String name = Demo.class.getName();
CtClass ctClass = ClassPool.getDefault().getCtClass(name);
CtMethod method = ctClass.getMethod("say", "()V");
method.setBody("{System.out.println(\"Hello Throwable!\");}");
for (int i = 0; i < 100000; i++) {
byte[] bytes = ctClass.toBytecode();
CustomClassLoader classLoader = new CustomClassLoader(bytes);
// 新的Demo类,只能反射调用,因为类路径中的Demo类已经被应用类加载器加载
Class<?> newDemoClass = classLoader.loadClass(name);
add(classLoader, newDemoClass);
}
// 清理类加载器和它加载过的类
clear();
System.gc();
Thread.sleep(Integer.MAX_VALUE);
}

private static void add(ClassLoader classLoader, Class<?> clazz) {
if (CACHE.containsKey(classLoader)) {
CACHE.get(classLoader).add(clazz);
} else {
List<Class<?>> classes = new ArrayList<>();
CACHE.put(classLoader, classes);
classes.add(clazz);
}
}

private static void clear() {
CACHE.clear();
}
}

使用VM参数-XX:+PrintGC -XX:+PrintGCDetails执行上面的方法,JDK11默认使用G1收集器,由于Z收集器还在实验阶段,不是很建议使用,执行main方法后输出:

[11.374s][info   ][gc,task       ] GC(17) Using 8 workers of 8 for full compaction
[11.374s][info ][gc,start ] GC(17) Pause Full (System.gc())
[11.374s][info ][gc,phases,start] GC(17) Phase 1: Mark live objects
[11.429s][info ][gc,stringtable ] GC(17) Cleaned string and symbol table, strings: 5637 processed, 0 removed, symbols: 135915 processed, 0 removed
[11.429s][info ][gc,phases ] GC(17) Phase 1: Mark live objects 54.378ms
[11.429s][info ][gc,phases,start] GC(17) Phase 2: Prepare for compaction
[11.429s][info ][gc,phases ] GC(17) Phase 2: Prepare for compaction 0.422ms
[11.429s][info ][gc,phases,start] GC(17) Phase 3: Adjust pointers
[11.430s][info ][gc,phases ] GC(17) Phase 3: Adjust pointers 0.598ms
[11.430s][info ][gc,phases,start] GC(17) Phase 4: Compact heap
[11.430s][info ][gc,phases ] GC(17) Phase 4: Compact heap 0.362ms
[11.648s][info ][gc,heap ] GC(17) Eden regions: 44->0(9)
[11.648s][info ][gc,heap ] GC(17) Survivor regions: 12->0(12)
[11.648s][info ][gc,heap ] GC(17) Old regions: 146->7
[11.648s][info ][gc,heap ] GC(17) Humongous regions: 3->2
[11.648s][info ][gc,metaspace ] GC(17) Metaspace: 141897K->9084K(1062912K)
[11.648s][info ][gc ] GC(17) Pause Full (System.gc()) 205M->3M(30M) 273.440ms
[11.648s][info ][gc,cpu ] GC(17) User=0.31s Sys=0.08s Real=0.27s

可见FullGC之后,元空间(Metaspace)回收了(141897-9084)KB,一共回收了202M的内存空间,初步可以认为元空间的内存被回收了,接下来注释掉main方法中调用的clear()方法,再调用一次main方法:

....
[4.083s][info ][gc,heap ] GC(17) Humongous regions: 3->2
[4.083s][info ][gc,metaspace ] GC(17) Metaspace: 141884K->141884K(1458176K)
[4.083s][info ][gc ] GC(17) Pause Full (System.gc()) 201M->166M(564M) 115.504ms
[4.083s][info ][gc,cpu ] GC(17) User=0.84s Sys=0.00s Real=0.12s

可见元空间在FullGC执行没有进行回收,而堆内存的回收率也比较低,由此可以得出一个经验性的结论:只需要通过ClassLoader对象做映射关系保存使用它加载出来的新的类,只需要确保这些类没有没强引用、类实例都已经销毁,那么只需要移除ClassLoader对象的引用,那么在JVM进行GC的时候会把ClassLoader对象以及使用它加载的类回收,这样做就可以避免元空间的内存泄漏。

小结

通过一些资料和实验,深化了类加载过程的一些认识。

参考资料:

  • 《深入理解Java虚拟机-第二版》
  • JDK11部分源码

(本文完 e-2018129 c-2-d r-20181212)