开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情
Class文件从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称
为连接(Linking)。顺序如下图:
本篇将讲解类加载的过程,其中类加载的过程包括:加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)。
类加载步骤比较多,重点关注
加载
、验证
、初始化
阶段。(1) 加载和初始化阶段是我们比较可控的阶段,因为我们可以主动加载类、定义类的初始化逻辑(静态变量、静态代码块)。
(2) 验证阶段与类加载相关异常(如:
java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等)相关。
一、加载
"加载"(Loading)阶段是整个"类加载"(Class Loading)过程中的一个阶段。
何时触发类的加载,可以参考本人之前写的文章《深入分析JVM类加载(一)-N种类加载、初始化的时机》。
加载阶段,Java 虚拟机将完成三个子任务:
1)通过一个类的全限定名(例如: org.springframework.boot.SpringBootBanner)来获取定义此类的二进制字节流。
获取二进制字节流的方式非常灵活:
来源可以是网络、磁盘、ZIP包、运行时计算生成(各种动态代理技术)、加密文件等。
加载类的ClassLoader可以是Java虚拟机自带的ClassLoader,也可以自定义ClassLoader加载。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区;JDK 8 及之后,HotSpot使用元数据空间来实现方法区。
不管是永久代,还是元数据空间,当已加载大量的类,且进行了Full GC后这些类依旧无法被回收,则可能导致无额外空间装载新的类,出现OOM。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
该java.lang.Class对象将存放于堆中。
二、验证
验证阶段是防止Class文件的字节流包含的信息不符合《Java虚拟机规范》的全部约束要求。主要是验证下面这些:
1.文件格式验证
如:
主、次版本号是否在当前Java虚拟机接受范围之内。
Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
2.元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。
如:
1)这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
2)这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
这部分的内容,在IDEA编辑源码或是编译阶段就能看到报错、排除问题,所以一般情况下,开发人员一般不用关注这部分。
3.字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
但是,这部分内容,同第二部分,在IDEA编辑源码或是编译阶段就能看到报错、排除问题,所以开发人员一般不用关注这部分。
4.【重点】符号引用验证
(敲黑板!敲黑板!敲黑板!
) 这块是日常最容易碰到问题的地方。
这个动作会发生类加载过程中解析
阶段的虚拟机将符号引用转化为直接引用(符号引用转为直接引用的内容将在下面的解析
一节中讲解)的步骤。
该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:
(1) 符号引用中通过字符串描述的全限定名是否能找到对应的类。
(2) 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
(3) 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。
……
符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
导致这些异常的原因众多,例如: 类被不同ClassLoader导致;不同文件系统的文件顺序不固定间接导致;依赖的Jar包冲突导致;类在ClassPath顺序导致等等。这些原因有的很离奇,往往让人苦不堪言!后面专门编写一篇文章讲解如何排除这些问题,解决问题。
当然,如果我们的应用是经过大量验证的,也可以关闭类校验。方法就是运行java应用时,加上参数-Xverify:none
或者-noverify
关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
三、准备
这个阶段的工作比较简单,就是为类中的静态变量(被static修饰的变量)分配内存并设置初始值。
示例:
Class ClassA {
// static类型
public final static int num = 123;
}
准备阶段就是为num设置初始值0,而123这个值会在初始化阶段执行。
当然特殊情况如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,假设上面类变量value的定义修改为:
Class ClassA {
// final static类型
public final static int num = 123;
}
编译时Javac将会为value生成ConstantValue属性,在准备阶段JVM就会根据ConstantValue的设置将value赋值为123。既然编译期间就已经设置了常量值,所以当访问ClassA.num时,如 int a = ClassA.num
这种方式进行访问,并不会触发ClassA类的加载和初始化。
静态变量存放于方法区。
在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,虽然元数据空间代替了成永久代,但是类变量其实是随着Class对象一起存放在Java堆中,所以此时"静态变量存放于方法区"就完全是一种对逻辑概念的表述。
四、解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
通俗地讲:符号引用就是字符串,这个字符串用以描述类名,方法名,方法参数等信息。在编译时,Java 类肯定不知道所引用的类的实际的内存地址,只能使用符号引用来代替。
符号引用存储在 class 文件的常量池中,比如类和接口的全限定名、类引用、方法引用以及成员变量引用等,如果要使用这些类和方法,就需要把它们转化为 JVM 可以直接获取的内存地址或指针,即直接引用。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
解析的动作主要是针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符这 7 类符号引用进行的。
示例:
public static void main(String[] args) {
com.skyme.jvm.Cat.say();
}
示例中com.skyme.jvm.Cat.say()
在Class文件中的符号引用为com/skyme/jvm/Cat.say:()V
。当main()方法执行到com.skyme.jvm.Cat.say()
这段,执行步骤大致是:
(1) 要根据com/skyme/jvm/Cat.say:()V
找对应的类、对应的方法,先在内存中找到类com/skyme/jvm/Cat
,没找到,则触发类加载,如果类加载无法找到类对应的文件,则抛出NoClassDefFoundError
和ClassNotFoundException
异常(这个检查动作在验证
阶段完成的,所以说: 类加载的阶段是交叉进行的)。找到类后,将类的符号引用替换为对类地址的直接引用。
(2) 然后再找到类中对应方法say:()
,如果找不到对应方法,则抛出NoSuchMethodError
异常(这个检查动作在验证
阶段完成的)。如果可以找到,将方法的符号引用替换为对方法地址的直接引用。
上面的描述比较粗浅,如果要深入理解,可以看一下JVM里的符号引用如何存储? - 知乎。
五、初始化
这里的初始化是指 JVM 运行类的静态变量的赋值动作和静态代码块(static{}块}),此时 JVM 才真正开始执行类中编写的 Java 代码。
Java 编译器在编译过程中,会自动收集类中所有静态变量的赋值动作以及静态代码块,将其合并到类构造器<clinit>()
方法,编译器收集的顺序是由语句在源文件中出现的顺序决定的。额外说一下,除了类构造器<clinit>()
方法,我们也关注一下对象构造器<init>()
方法,它是对象构造时用以运行对象的构造函数、初始化属性以及执行非静态代码块。
对于初始化,我们有一些注意事项:
1.类的<clinit>()
方法执行前,会先执行父类的<clinit>()
方法。
举例一个常见面试题:
public class StaticTest {
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}
}
运行结果为 2,因为父类Parent会先执行 A = 1
、A = 2
,然后子类执行B = A
。
2.同一个类加载器下,一个类型只会被初始化一次。
这个规则会导致一些隐蔽的坑!!!
示例:
public class D {
static {
int i = 1/0;
}
public static void hello () {
System.out.println("hello");
}
}
public class staticErrorTest {
public static void main(String[] args) {
try {
// 第一次使用D类
D.hello();
} catch (Throwable e) {
e.printStackTrace();
}
try {
// 第二次使用D类
D.hello();
} catch (Throwable e) {
e.printStackTrace();
}
}
}
下面是执行结果:
java.lang.ExceptionInInitializerError
at com.skyme.jvm.staticdemo.staticErrorTest.main(staticErrorTest.java:7)
Caused by: java.lang.ArithmeticException: / by zero
at com.skyme.jvm.staticdemo.D.<clinit>(D.java:5)
... 1 more
java.lang.NoClassDefFoundError: Could not initialize class com.skyme.jvm.staticdemo.D
at com.skyme.jvm.staticdemo.staticErrorTest.main(staticErrorTest.java:12)
上面的例子中。staticErrorTest的main方法第一次执行D.hello()静态方法,触发了D类的初始化,D类的初始化过程中执行int i = 1/0
必定会报错,抛出了java.lang.ExceptionInInitializerError
异常,在jvm里会被标记D类为initialization_error
,第二次执行D.hello()静态方法,不会执行D类的初始化了,而是抛出了java.lang.NoClassDefFoundError
异常,其中异常message中Could not initialize class xxxx
这个信息告诉我们,无法初始化skyme.jvm.staticdemo.D
类。
在实际工作中,也遇到了类的初始化时出现异常最终导致了java.lang.NoClassDefFoundError
问题,排查良久,通过这篇文章深入JVM分析spring-boot应用hibernate-validator NoClassDefFoundError最终解决了问题,如感兴趣可以看一下这篇文章,本文就不赘述。
六、总结
本文讲解了类是如何一步步加载到 JVM 中的,其中重点关注加载
、验证
、初始化
阶段。
下一篇我们将讲解加载阶段相关的ClassLoader机制。