概述

类从被加载到虚拟机开始,到卸载出内存,整个生命周期分为七个阶段,分别是加载、验证、准备、解析、初始化、使用和卸载。其中验证、准备和解析这三个阶段统称为连接。整个过程如下图所示:

类加载的生命周期.png

加载、验证、准备、初始化和卸载这五个阶段顺序是确定的,类的加载过程这些阶段必须按这个顺序开始(注意这里强调的开始的顺序,进行和完成可能是交叉混合着的)。由于 Java 支持动态绑定,在动态绑定时解析阶段会在初始化之后执行。

类加载时机

上面讲到类的分为七个阶段,那么什么情况下会开始类的加载呢?

思考这个问题我们可以从两个维度出发,一个是 JVM 规范维度,一个是从虚拟机运行的维度;

JVM 规范维度

JVM 规范没有强制约束类的加载时机,但 Java 虚拟机严格规定了有且只有5种情况必须立即对类进行”初始化”,执行初始化自然必须先执行前面的步骤。

  • 遇到 new、getstatic、putstatic、或 invokestatic 这4条字节码指令时,如果类没有初始化,则需要先触发其初始化。其对应的场景分别为:使用 new 关键字初始化实例对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候和调用一个类的静态方法的时候;
  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化;
  • 当初始化一个类的时候,如果发现其父类没有初始化,则需要先触发其父类的初始化;
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法那个类),虚拟机会先初始化这个主类
  • 当使用 JDK1.7 开始的动态语言支持时,如果 􏱏􏰑􏰣􏲝java.lang.invoke.MethodHandle􏲟􏲡􏰿􏱆 实例最后的解析结果为 􏰏􏰲􏱨􏰐􏰑REF_getStatic、􏱦REF_putStatic、􏱦REF_invokeStatic 的方法句柄􏰏􏲶􏵁􏸁􏸂􏰛􏱢􏴪􏱑􏲝􏲶􏵁􏸁􏸂 􏳨􏱣􏲏􏰏􏰇􏴣􏳝􏱔􏱊􏱾􏱩􏱪􏱚􏰛􏴋􏱂􏱃􏳔􏴰􏰡,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

虚拟机运行维度

从虚拟机运行的维度来说,有两种时机会触发类加载:

  • 预加载
  • 运行时加载

预加载

虚拟机启动时加载,加载的是JAVA_HOME/lib/下的rt.jar下的.class文件,这个jar包里面的内容是程序运行时非常常 常用到的,像java.lang.*java.util. java.io. 等等,因此随着虚拟机一起加载。

要证明这一点很简单,写一个空的main函数,设置虚拟机参数为-XX:+TraceClassLoading来获取类加载信息,运行一下:

1
2
3
4
5
6
[Opened /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Object from /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.io.Serializable from /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Comparable from /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.CharSequence from /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
...

运行时加载

虚拟机在用到一个.class文件的时候,会先去内存中查看一下这个.class文件有没有被加载,如果没有就会按照类的全限定名来加载这个类。

详解类加载过程

加载(重要)

加载阶段主要做了三件事:

  • 获取 .class 文件的二进制流;
  • 将类信息、静态变量、字节码、常量这些 .class 文件中的内容放入方法区中;
  • 在内存中生成一个代表这个.class文件的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。一般这个Class是在堆里的,不过HotSpot虚拟机比较特殊,这个Class对象是放在方法区中的。

虚拟机规范对这三点的要求并不具体,因此虚拟机实现与具体应用的灵活度都是相当大的。

这种灵活度对于开发者来说主要体现在第一步,由于虚拟机规范并没有规定二进制字节流的来源,开发者可以从以下几个渠道获取:

  • 从zip包中获取,这就是以后jar、ear、war格式的基础
  • 从网络中获取,典型应用就是Applet
  • 运行时计算生成,典型应用就是动态代理技术
  • 由其他文件生成,典型应用就是JSP,即由JSP生成对应的.class文件
  • 从数据库中读取,这种场景比较少见

链接(理解)

链接分为三个步骤:验证、准备和解析

验证

验证阶段主要是为了确保 .class 文件的字节流中包含的信息符合当前虚拟机要求,并且不会危害虚拟机自身的安全。

正如前面所说,二进制字节流可能有很多种来源,虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节 流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作

验证阶段大致会完成以下四个阶段的检验动作:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

验证阶段与加载阶段是交叉进行的,加载阶段还没有结束验证阶段就已经开始了。

这个阶段也是最耗费时间的,如果我们所运行的全部代码(包括自己编写的及第三方依赖包中的代码)都已经被反复使用和验证过,那么可以考虑使用 􏱏􏰑􏳨􏱉􏱊􏰏􏶶􏲹􏰋􏰌􏳹􏳟􏳠􏲰􏷠􏰍􏲢􏰏􏵀􏰁􏸗􏲶􏳟􏰹􏰏􏰋 􏰌􏳺􏱁􏲿􏷼􏱯􏵂􏳆􏱌􏱍􏱋􏱥􏳻􏱾􏰛􏱵􏱙􏰸􏲟􏹵􏳾􏳿􏱳􏱭􏱮􏶖􏶗􏱌􏱍-Xverify􏳙none􏵑􏱠􏴦􏴲 􏹯􏰪􏲹􏲺􏰏􏰇􏱥􏳻􏹶􏹵􏰛􏱮􏹷􏶅􏰄􏰅􏰆􏰇􏰈􏰉􏰏􏱶参数来关闭大部分类验证措施,以缩短虚拟机类加载时间。

准备

准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配 。

这里的类变量是指不被 final 修饰的 static 变量,这里设置的初始值指的是赋零值。

各个数据类型对应的零值如下:

数据类型 零值
int 0
long 0L
short (short)0
chart ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

这里需要注意一下,类变量由于在这个阶段会有一个初始值,所有代码里可以不指定初始值直接使用,但其他变量不行,使用前必须有初值,否则会编译出错。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

这里就需要了解符号引用和直接引用的概念:

符号引用

符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。

下面以简单的代码来理解符号引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.zephyr.demo;

public class SymbolClass {

public static String serial;
private int count;

public static void calculate() {
}

public int getCount() {
return count;
}
}

使用javap -verbose SymbolClass反编译一下这个类,我们主要看看常量池部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Constant pool:
#1 = Methodref #4.#25 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#26 // com/zephyr/demo/SymbolClass.count:I
#3 = Class #27 // com/zephyr/demo/SymbolClass
#4 = Class #28 // java/lang/Object
#5 = Utf8 serial
#6 = Utf8 Ljava/lang/String;
#7 = Utf8 count
#8 = Utf8 I
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 LocalVariableTable
#14 = Utf8 this
#15 = Utf8 Lcom/zephyr/demo/SymbolClass;
#16 = Utf8 calculate
#17 = Utf8 getCount
#18 = Utf8 ()I
#19 = Utf8 main
#20 = Utf8 ([Ljava/lang/String;)V
#21 = Utf8 args
#22 = Utf8 [Ljava/lang/String;
#23 = Utf8 SourceFile
#24 = Utf8 SymbolClass.java
#25 = NameAndType #9:#10 // "<init>":()V
#26 = NameAndType #7:#8 // count:I
#27 = Utf8 com/zephyr/demo/SymbolClass
#28 = Utf8 java/lang/Object

上面带Utf8的那一行就是符号引用,每行最前面的就是符号,后面就是引用的值。对于变量来说都会有两行成对出现,比如#7 是 count,#8就是 count 的类型 Integer(常量池里简写为 I )。方法如果有返回值,方法和返回值也会成对出现,比如 #17 和 #18,分别代表的方法和返回值类型。

简单理解符号引用就是对于类、变量、方法的描述。􏵱􏵲􏷞􏱍􏱮􏰣􏲻􏵱􏵲􏴦􏰺􏰭􏳨􏷞􏱍􏰏􏲽􏷯􏰛􏵱􏵲􏱭 并且􏱮􏰜􏸛􏱐􏱫􏰠􏰏􏰙􏱽􏴽􏰛􏴫􏱃􏱌􏱍􏱶􏱈􏳰􏺂􏲯􏰔􏲨􏵰􏱄􏲽􏷯􏴬􏱭符号引用和虚拟机的内存布局是没有关系的,引用的目标未必已经加载到内存中了。

直接引用

直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。

解析过程

Java 本身是一个静态语言,但后面又加入了动态加载特性,因此我们理解解析阶段需要从这两方面来考虑。

如果不涉及动态加载,那么一个符号的解析结果是可以缓存的,这样可以避免多次解析同一个符号,因为第一次解析成功后面多次解析也必然成功,第一次解析异常后面重新解析也会是同样的结果。

如果使用了动态加载,前面使用动态加载解析过的符号后面重新解析结果可能会不同。使用动态加载时解析过程发生在在程序执行到这条指令的时候,这就是为什么前面讲的动态加载时解析会在初始化后执行。

整个解析阶段主要做了下面几个工作:

  • 类或接口的解析
  • 类方法解析
  • 接口方法解析
  • 字段解析

初始化(重要)

初始化是整个类加载过程的最后一个阶段。整个类加载的五个阶段只有加载和初始化是开发者可以参与的,因此初始化阶段也是需要重点关注的阶段。

初始化阶段简单来说就是执行类的构造器方法(<clinit>() ),要注意的是这里的构造器方法<clinit>()并不是开发者写的,而是编译器自动生成的

代码顺序的影响

编译器编译的时候会按代码顺序进行收集,声明在静态代码块(static {} 块)之后的静态变量,只能在静态代码块里赋值,不能访问。

1
2
3
4
5
6
7
public class TestClinit {
static {
i = 0; // 可以给变量赋值
System.out.print(i); // 在这里访问编译器会提示“非法向前引用”
}
static int i = 1;
}

父类与子类<clinit>()方法执行顺序

Java 虚拟机会保证父类的<clinit>()方法执行完成后才执行子类的<clinit>()方法,这也就意味着父类的静态代码块一定会先于子类执行的。

这里需要注意的是,如果父类的静态代码块有耗时操作,子类可能会被阻塞迟迟加载不了。

编译器生成<clinit>()方法的条件

编译器生成<clinit>()方法的前提是有变量赋值操作或者有静态代码块需要执行。接口虽然没有静态代码块但是有变量赋值操作,所以接口会生成<clinit>()方法。

注意:接口或者接口的实现类在执行<clinit>()方法之前不一定会去执行父类的<clinit>()方法,仅当父类或者被实现的接口变量被使用才会调用这个方法。