JVM类加载系列-N种类加载、初始化的时机(全面知识梳理)
一、开篇
背景
JVM 类加载常见问题
工作中,我们常遇到下面的问题和困惑:
1.为何程序运行期间抛出ClassNotFoundException、NoSuchMethodException等错误?
2.本机上跑的好好的,无ClassNotFoundException等错误,为何在服务器上运行,就出现找不到ClassNotFoundException等错误?
3.本机、服务器上类加载的顺序为何不一样?
4.为何IDE上和服务端运行的SpringBoot应用的ClassLoader不一样?
5.常提到双亲委派机制到底是什么?为何有的场景竟然没有遵循双亲委派机制?
6.Arthas这个监控诊断神器是如何实现输入命令就能控制目标进程的?
写作计划
上面的这些问题都是与 JVM 类加载机制有关。为完整的解答这些问题,后续将从下面的这些方面进行知识梳理:
1.类加载时机。类加载中的类是什么?何时将触发类加载?
2.类加载过程。类加载是怎样一步步加载到 JVM?
3.ClassLoader和双亲委派机制。ClassLoader到底是什么,它是如何工作的?双亲委派机制的原理、作用、缺陷是怎样的?标准SpringBoot应用的类加载机制是怎样的?什么场景下将打破双亲委派机制?哪些方式可以打破双亲委派机制?
4.类隔离机制。为何需要类隔离?外置的Tomcat容器内不同应用之间、SkyWalking Agent和宿主应用之间、Arthas Agent和宿主应用之间,他们是如何实现类的相同隔离?
5.Class的热更新。Class的热更新有哪些方式?外置的Tomcat、IDEA、Arthas等是如何实现Class的热更新的?
这些知识有先后顺序,所以将分多篇文章进行阐述。除了理论,还会讲解容易出现的问题、问题排查思路。
本篇将讲解类加载时机。
注意:
为不误导大家,提前声明,文中内容主要是基于JDK8和HotSpot虚拟机来讲解,相对其他的JDK版本和虚拟机实现可能有所差异,但JDK各版本大部分场景相互兼容,且虚拟机的实现都会遵循《Java虚拟机规范》规定的要求,所以大部分原理还是相同的。
二、从Class文件开始了解类加载
分析类加载,我们得先了解类加载中类是什么?
类加载中的类即Class文件,类加载即Class文件加载到 Java 虚拟机。
1.Class 文件是什么
Class文件是二进制字节流。为更好地理解Class文件的结构,将举例展示。
通过 javac 编译下面的 Java 源码 Main.java,
package com.skyme;
public class Main {
public static void main(String[] args) {
System.out.println("hello!");
}
}
复制代码
产生 Class 文件 com.skyme.Main.class,直接通过文本编辑器以16进制打开是这样的:
Class文件内部的数据结构属于比较底层的知识,不易理解。一般我们想知道Class文件的内部功能,都是通过反编译获取源码进行解读。
反编译获取源码
下面列举反编译Class文件的工具。
JDK自带命令行工具:javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。
javap -c com.skyme.Main
Compiled from "Main.java"
public class com.skyme.Main {
public com.skyme.Main();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
复制代码
我们可以留意一下上面的指令,例如getstatic指令,这个命令在下面讲解类加载时机时,也会出现。
javap命令的输出结果不容易理解,推荐下列直接获取java源码的反编译工具。
个人电脑的反编译Class文件工具:个人电脑上有很多可视化界面工具,如luyten、JD-GUI、jadx、jad等。
服务端的工具:推荐Arthas的jad命令,该命令将 JVM 中实际运行的 class 字节码反编译成 java 代码。此外,如果在服务端反编译的源码发现代码有问题,还可以结合 Arthas 的mc、redefine、retransform命令,实现Class的热更新,使得服务端不停JVM进程也可以调试代码、更新代码,将大大减少了频繁编译部署应用、调试代码的耗时。
Class文件与类或接口的关系是怎样的?
任何一个Class文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或接口并不一定都得定义在文件里。
因为类或接口也可以动态生成,直接注入到类加载器中,常见场景如SpringAOP生成的代理类,是没有对应的Class文件的。
Class文件一定是磁盘文件吗?
Class文件大部分情况是作为磁盘文件形式存在,即常见的.class文件,此外,从网络上获取的Class字节码、动态产生的类、接口的Class字节码,也都属于Class文件。所以,严格地讲,Class文件是符合Class文件规范的二进制字节流。
2.Class文件是 JVM 跨语言、跨平台的基石
JVM (Java 虚拟机) 跨语言、跨平台的特性是 Java 最初能占领广大市场的重要因素,而Class文件的设计是 JVM 跨语言、跨平台的基石。
Class文件是如何实现 JVM 跨语言、跨平台:
跨语言: Java、Kotlin、JRuby、Groovy、Scala等语言的程序,通过各自语言对应的编译器编译成字节码文件(.class格式的Class文件),这些字节码文件都可以被 Java 虚拟机(JVM, Java Virtual Machine)加载、运行。
跨平台: 字节码与操作系统无关,Java虚拟机在各种操作系统(MacOS、Windows、Linux、Solaris、NetWare、HP-UX等)上都有对应的JVM实现,这些JVM尽可能地屏蔽不同硬件平台和操作系统上的差异,使得相同的字节码在这些系统的对应的JVM上都可运行,达到**"一次编写,到处运行 (Write Once,Run Anywhere)"**的效果。
三、类的生命周期
Class文件从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称
为连接(Linking)。顺序如下图:
四、类的加载时机一-加载与初始化同时发生
何时触发类加载呢?更精确的说法是,何时触发类加载的第一个阶段**"加载"**?《Java虚拟机规范》中并没有进行强制约束,不同的虚拟机实现可能触发的时机有所不同。
(敲黑板!划重点了!)但是,对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种(截止当前是这样,规范里写了这些场景,但是未来JDK可能还会扩展场景)情况必须立即对类进行"初始化"(而加载、验证、准备自然需要在此之前开始)。而大部分情况下是同时触发的,少数情况下只有类的加载,没有类的初始化。下面列举的这些触发"初始化阶段"的情况,大部分情况,可以作为触发类加载的时机。
下面补充一些前置说明:
下面这些初始化触发场景主要是参考了周志明大大的《深入理解Java虚拟机: JVM高级特性与最佳实践(第3版)》,并加上自己的理解。
(敲黑板!) 但是,类的加载场景与类的初始化场景,还是有些区别的,后面会讲解。
补充一个初学类加载需要知道的知识点:
Java中import xxx.xxx这种引import包和类的方式,并不会直接导致类的加载。Java虚拟机加载类,大部分情况还是延迟加载(懒加载)的策略,少部分会提前加载(JVM启动时会加载JDK核心包、类加载校验过程间接导致的类加载等,后面会讲解),因为类加载还是比较重的操作,还有应用中的类往往很多,批量加载太耗时、耗性能。
场景一、
【重点】遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
1.使用new关键字实例化对象的时候。
2.读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
3.调用一个类型的静态方法的时候。
场景二、
【重点】使用java.lang.reflect包的方法对类型(Class)进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
上面这段是出自《深入理解Java虚拟机: JVM高级特性与最佳实践(第3版)》,java.lang.reflect包中对Class进行反射的方法,例如:Method.invoke(Object obj, Object... args)、Field.set(Object obj, Object value)等,确实会触发类的初始化。
但是,验证HotSpot虚拟机时,发现触发初始化的反射调用场景,不仅仅是java.lang.reflect包的方法,其中也包括Class.forName和Class.newInstance()这些非java.lang.reflect包的方法,他们属于java.lang包的方法。(-_-)不知周志明大大为何没有描述这些场景。以后再慢慢琢磨这块。
如果使用Class.forName的两个重载方法的实现Class.forName(String className)和Class.forName(String name, boolean initialize, ClassLoader loader),都会触发类的加载,其中Class.forName(String className)会触发类的加载和初始化,而Class.forName(String name, boolean initialize, ClassLoader loader)中initialize参数为true时,会触发初始化;为false,则不触发初始化。此外,Class.newInstance()创建对象的方法,也会触发类的初始化。
所以,针对HotSpot虚拟机,Class.forName(String name, boolean initialize, ClassLoader loader)中initialize为false时,就是类的加载和初始化并非一起执行的特殊场景之一!还有其他特例,下面的章节会进行补充。
场景三、
【重点】当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
场景四、
当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
场景五、
当使用JDK 7新加入的动态语言支持时,如果一个
java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
该场景比较少见,能找到的资料也比较少。在java.lang.invoke包对应的JDK8版本javadoc(
docs.oracle.com/javase/8/do…
从上图的表格中可以看出,REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型其实刚好对应场景一中的getstatic、putstatic、invokestatic、new四条字节码指令,也就是对应着读取或设置一个类型的静态字段、调用一个类型的静态方法、new关键字实例化对象这几种情况。更深层次的差异,暂时不再深究。
场景六、
当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
从上面的场景也可知,类加载是延迟加载的策略,简单来说,主动用到类时才进行加载,毕竟类加载的过程消耗时间,类加载好后还需占用内存存储类的数据。
五、类的加载时机二-其他触发类加载的时机
大部分博文写类的加载时机都会写上文中这些引用自《深入理解Java虚拟机》的这些场景,我们借以类的初始化和类的加载大部分都是同时加载的前提,推导出触发类的加载的场景。
但是在使用HotSpot虚拟机时,发现仅仅通过初始化的场景反推类的加载的时机是不够的,实际还需要关注这些显示加载类的场景:如Class.forName、Class.class(例如: User.class)、ClassLoader.loadClass,以及一些不为人知的加载类的场景,如校验类型转换过程中间接导致的类加载。
【重点】显示触发类的加载
Class.forName
上文中讲解调用反射方法会触发类的初始化时,提到了Class.forName会触发类的加载。其实,Class.forName的本质也是指定ClassLoader来加载类,从方法的源码就可看出:
public static Class> forName(String className)
throws ClassNotFoundException {
Class> caller = Reflection.getCallerClass();
// 使用调用方所属ClassLoader,来加载对应的Class,其中第二个参数initialize为true,说明会进行初始化
return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
复制代码
Class.class
Class.class的示例:
public class ClassA {
static {
System.out.println("ClassA初始化...");
}
}
public class ClassDemoA {
public static void main(String[] args) {
// 通过Class.class方式获取Class对象,触发了类的加载,但是不会触发初始化
Class classA = ClassA.class;
}
}
复制代码
该例子中,Class classA = ClassA.class;触发了ClassA的类的加载,但是并未触发ClassA的初始化。
ClassLoader.loadClass
示例:
public class ClassD {
static {
System.out.println("ClassD初始化...");
}
}
public class ClassDemoD {
public static void main(String[] args) throws ClassNotFoundException {
// 通过ClassLoader.loadClass方式获取Class对象,触发了类的加载,但是不会触发初始化
Class classD = ClassLoader.getSystemClassLoader().loadClass("com.skyme.jvm.ClassD");
}
}
复制代码
该例子中,ClassLoader.loadClass触发了ClassA的类的加载,但是并未触发ClassA的初始化。为何没有触发类的初始化?我们从源码可以找到答案:
public abstract class ClassLoader {
public Class> loadClass(String name) throws ClassNotFoundException {
// 第二个参数resolve为false
return loadClass(name, false);
}
protected Class> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// ....双亲委派机制加载类....
}
// 如果为true,执行resolveClass(c)
if (resolve) {
resolveClass(c);
}
return c;
}
}
/**
* Links the specified class. This (misleadingly named) method may be
* used by a class loader to link a class. If the class c has
* already been linked, then this method simply returns. Otherwise, the
* class is linked as described in the "Execution" chapter of
* The Java? Language Specification.
* 连接一个类,如果已经连接,直接返回。如果没有连接,则执行连接,而连接阶段包括了初始化。
* @param c
* The class to link
*
* @throws NullPointerException
* If c is null.
*
* @see #defineClass(String, byte[], int, int)
*/
protected final void resolveClass(Class> c) {
resolveClass0(c);
}
}
复制代码
【重点】类加载验证阶段间接触发类的加载
下面这些场景,遇见了,绝对让人头疼!
上面我们讲到类加载的有一个验证阶段,该阶段主要是防止Class文件不合法,毕竟Class文件是可以篡改的,所以需要做一些安全校验,例如,检查这个类是否继承了被final修饰的类;这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
此外,这个验证阶段,还会校验下面的这些问题:
1)如果这个类中的代码存在类型转换的情况,会校验转换类和被转换类的父子关系。
2)如果这个类中的方法throws异常或者catch异常时,会校验该异常类必须是Throwable或者Throwable`的子类。
校验类的父子关系
下面示例类型转换的场景,准备测试类如下:
public class Parent {
static {
System.out.println("Parent 初始化...");
}
}
public class Child extends Parent {
static {
System.out.println("Child 初始化...");
}
}
public class ChildPassChild {
/** 入参是Child类型 */
public ChildPassChild(Child child) {
System.out.println(child.getClass().getSimpleName());
}
}
public class ParentPassChild {
/** 入参是Parent类型 */
public ParentPassChild(Parent parent) {
System.out.println(parent.getClass().getSimpleName());
}
}
复制代码
测试案例:
public class CastTest1 {
public static void foo() {
System.out.println("foo");
}
public void parentPassChild() {
// 创建ParentPassChild对象时,传入Child类型的对象
ParentPassChild parentPassChild = new ParentPassChild(new Child());
System.out.println(parentPassChild);
}
public static void main(String[] args) {
// 只执行CastTest1.foo()。为执行parentPassChild()
CastTest1.foo();
}
}
复制代码
执行结果如下:
上面的示例只执行了CastTest1.foo()方法,并未执行parentPassChild()方法,但是类加载的结果让人出乎意料,竟然加载了Parent类和Child类,不是说好了用到时才加载?
这个问题在《JVM虚拟机规范》中可以找到答案,
JVM类加载是比较重的操作,其中验证阶段在整个过程中是占比工作量比较大。但是,如果class A、Class B存在类型cast,那么必须加载相应的class A、Class B,来判断这是不是一个安全的行为。这种场景属于类被提前加载。
当然,如果我们的应用是经过大量验证、测试的,也可以关闭类校验。方法就是运行java应用时,加上参数-Xverify:none或者 -noverify 关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
通过日志我们发现,这种场景,类被提前加载,但被加载的类并未触发初始化。
抛出异常
下面示例抛出的场景,准备测试类如下:
public class MyException extends Exception {
static {
System.out.println("MyException初始化...");
}
}
public class ExceptionHandler {
public static void throwException(boolean b) throws MyException {
if (b) {
throw new MyException();
}
}
}
复制代码
测试案例:
public class ExceptionTest {
public static void foo() {
System.out.println("foo");
}
public void throwException1(boolean b) throws MyException {
if (b) {
throw new MyException();
}
}
public static void main(String[] args) {
ExceptionTest.foo();
}
}
复制代码
执行结果如下:
上面的示例只执行了ExceptionTest.foo()方法,并未执行throwException1()方法,但是类加载的结果让人出乎意料,竟然加载了MyException类。
这个问题也能在《JVM虚拟机规范》中可以找到答案,
如果这个类中的方法throws异常或者catch异常时,会校验该异常类必须是Throwable或者Throwable`的子类,所以此时会触发异常的加载。
思考题: 一个类中有下面的方法,哪种方法会导致MyException被加载?
/** 方法2 */
public void throwException2(boolean b) {
try {
ExceptionHandler.throwException(b);
} catch (MyException exception) {
exception.printStackTrace();
}
}
/** 方法3 */
public void throwException3(boolean b) {
try {
ExceptionHandler.throwException(b);
} catch (Exception exception) {
exception.printStackTrace();
}
}
复制代码
答案: 方法2会导致MyException被加载,因为catch了MyException,而方法3 catch的是Exception,不会导致MyException被加载。
通过日志我们发现,这种场景,类被提前加载,但被加载的类并未触发初始化。
六、总结
上文列举的这些场景,涵盖了大部分的触发类加载的场景。如果还有其他场景,欢迎留言补充。
这些知识点看上去比较生硬,但是当中很多都是踩坑积累的经验,网上也很少这么全面的讲解这些知识的文章。我们可以把这些内容浏览一遍,大致有一个印象。等遇到类加载相关的问题时,再翻看这篇文章。
这篇文章也有助于理解后续类加载的过程、ClassLoader、类隔离等文章,循序渐进地理解这些知识,再将这些知识点融合在一起,可以编排出多套组合拳,再往后处理ClassNotFoundException等异常、理解Arthas基本原理、SkyWalking agent的插件机制都会比较容易。