详解Java类加载过程
概述
类从被加载到虚拟机开始,到卸载出内存,整个生命周期分为七个阶段,分别是加载、验证、准备、解析、初始化、使用和卸载。其中验证、准备和解析这三个阶段统称为连接。整个过程如下图所示:
加载、验证、准备、初始化和卸载这五个阶段顺序是确定的,类的加载过程这些阶段必须按这个顺序开始(注意这里强调的开始的顺序,进行和完成可能是交叉混合着的)。由于 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 | [Opened /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 文件的字节流中包含的信息符合当前虚拟机要求,并且不会危害虚拟机自身的安全。
正如前面所说,二进制字节流可能有很多种来源,虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节 流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
验证阶段大致会完成以下四个阶段的检验动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
验证阶段与加载阶段是交叉进行的,加载阶段还没有结束验证阶段就已经开始了。
这个阶段也是最耗费时间的,如果我们所运行的全部代码(包括自己编写的及第三方依赖包中的代码)都已经被反复使用和验证过,那么可以考虑使用 -Xverifynone
参数来关闭大部分类验证措施,以缩短虚拟机类加载时间。
准备
准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配 。
这里的类变量是指不被 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 | package com.zephyr.demo; |
使用javap -verbose SymbolClass
反编译一下这个类,我们主要看看常量池部分:
1 | Constant pool: |
上面带Utf8
的那一行就是符号引用,每行最前面的就是符号,后面就是引用的值。对于变量来说都会有两行成对出现,比如#7 是 count,#8就是 count 的类型 Integer(常量池里简写为 I )。方法如果有返回值,方法和返回值也会成对出现,比如 #17 和 #18,分别代表的方法和返回值类型。
简单理解符号引用就是对于类、变量、方法的描述。 并且符号引用和虚拟机的内存布局是没有关系的,引用的目标未必已经加载到内存中了。
直接引用
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。
解析过程
Java 本身是一个静态语言,但后面又加入了动态加载特性,因此我们理解解析阶段需要从这两方面来考虑。
如果不涉及动态加载,那么一个符号的解析结果是可以缓存的,这样可以避免多次解析同一个符号,因为第一次解析成功后面多次解析也必然成功,第一次解析异常后面重新解析也会是同样的结果。
如果使用了动态加载,前面使用动态加载解析过的符号后面重新解析结果可能会不同。使用动态加载时解析过程发生在在程序执行到这条指令的时候,这就是为什么前面讲的动态加载时解析会在初始化后执行。
整个解析阶段主要做了下面几个工作:
- 类或接口的解析
- 类方法解析
- 接口方法解析
- 字段解析
初始化(重要)
初始化是整个类加载过程的最后一个阶段。整个类加载的五个阶段只有加载和初始化是开发者可以参与的,因此初始化阶段也是需要重点关注的阶段。
初始化阶段简单来说就是执行类的构造器方法(<clinit>()
),要注意的是这里的构造器方法<clinit>()
并不是开发者写的,而是编译器自动生成的。
代码顺序的影响
编译器编译的时候会按代码顺序进行收集,声明在静态代码块(static {} 块)之后的静态变量,只能在静态代码块里赋值,不能访问。
1 | public class TestClinit { |
父类与子类<clinit>()
方法执行顺序
Java 虚拟机会保证父类的<clinit>()
方法执行完成后才执行子类的<clinit>()
方法,这也就意味着父类的静态代码块一定会先于子类执行的。
这里需要注意的是,如果父类的静态代码块有耗时操作,子类可能会被阻塞迟迟加载不了。
编译器生成<clinit>()
方法的条件
编译器生成<clinit>()
方法的前提是有变量赋值操作或者有静态代码块需要执行。接口虽然没有静态代码块但是有变量赋值操作,所以接口会生成<clinit>()
方法。
注意:接口或者接口的实现类在执行<clinit>()
方法之前不一定会去执行父类的<clinit>()
方法,仅当父类或者被实现的接口变量被使用才会调用这个方法。