一、概述

本文主要讲述虚拟机如何把 Class文件加载到内存的过程。校验、转换解析和初始化,最终形成可被虚拟机使用的Java类型,这就是虚拟机的类加载机制。类型的加载、连接和初始化都是在程序运行期间完成,这样做的优劣势,如下:

  • 优势:提高Java程序的灵活性,Java动态扩展的语言特性就是依赖运行期动态加载和动态连接。当面向接口的应用程序,可以等到运行时指定实现类;可以通过类加载器,让程序运行时加载一个二进制流作为程序一部分。
  • 劣势:增加类加载的性能开销。

二、 类加载的生命周期

类的生命周期是指把Class字节码从文件中加载到内存,直到卸载内存整个过程,分为7个步骤。

jvm_class_loading_2

图中用红色虚线框起来的3个过程分别为验证、准备、解析,它们合称为链接(Linking)过程。另外图中紫色的5项是严格按照执行。而蓝色的解析阶段不一定要在初始化之前, 也可以在初始化之后再解析,这种情况称为动态绑定或晚期绑定。

1. 加载

虚拟机在加载阶段,主要工作如下:

  1. 通过类的全限定名获取该类的二进制字节流;
  2. 将字节流所代表的静态存储结构 转化为 方法区的运行时数据结构;
  3. 生成代表该类的Class对象并存放方法区,作为方法区该类的各种数据的访问入口。

对于上述字节流,可能来源:

  • 压缩包,例如jar/war等格式;
  • 网络,典型场景applet;
  • 运行时计算生成,例如动态代理技术,在java.lang.reflect.Proxy中,利用ProxyGenerator.generateProxyClass来为特定接口生成形如“*$Proxy”的代理类的二进制字节流;
  • 数据库,例如中间件服务器(SAP Netweaver)。

注:对于数组类,不通过类加载器创建,而是由虚拟机直接创建的。另外加载阶段尚未完成,连接阶段可能已经开始。

2. 验证

验证是连接阶段(Linking)的第一步,目的是为了确保Class文件的字节流符合虚拟机规范,不会危害虚拟机自身安全。比如:访问数组越界问题,将对象转型为未实现的类型,跳转到不存在的代码区等情绪编译器都会拒绝编译,也就是无法生成Class文件,既然如此,为什么还要验证呢?原因是Class文件不一定都是由java源码编译而成,可以是任何途径,所以验证还是很有必要的,尽可能保证系统能承受住恶意代码攻击。

验证主要工作分4阶段:

  1. 文件格式验证:验证是否符合Class文件格式规范;
  2. 元数据验证:验证是否符合Java语言规范;
  3. 字节码验证:验证数据流和控制流分析;
  4. 符号引用验证:验证符号引用转化为直接引用。

2.1 文件格式验证

验证点有比如是否魔数0xCAFEBABE开头;主、次版本号是否范围之内;常量池中常量tag标示是否正确等等,只有通过全部的验证,才能把字节流存储到内存的方法区。

2.2 元数据验证

经过文件格式验证,字节流已加载到方法区,这个阶段工作是对方法区的字节码进行语义分析,保证符合Java语言规范。 验证点比如:

  • 该类是否有父类(除Object之外,所有类都应该有父类)
  • 该类是否继承不允许继承的类(final类)
  • 非抽象类,是否都实现其父类的抽象方法或接口中的方法
  • 类的字段、方法是否与父类矛盾(例如覆盖父类的final字段,或重载不符合规则)
  • … 除上面列举外,还有很多。经过元数据验证,能确保元数据都是符合规范。

2.3 字节码验证

比如操作数栈的数据类型和指令代码序列配合,跳转指令不会跳到方法体之外等。HotSpot虚拟机提供 -XX:-UseSplitVerifier选项来关闭这项优化。

2.4 符号引用验证

校验点:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类;
  • 在指定类是否存在符合方法的字段描述符以及简单名称所描述的方法和字段;
  • 符号引用中的类、字段、方法的访问权限检查。
  • …等等

对于虚拟机的类加载机制来说,验证阶段非常重要的,但不是一定必要的。如果所运行的全部代码(包含自己编写以及第三方包的代码)都已经被反复使用和验证过,那么可以考虑使用 -Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3. 准备

主要工作:static变量分配内存,并设置类变量的初始值的阶段。

(1). 类变量:赋予零值

数据类型的零值表,如下:

类型零值
int0
long0L
float0.0f
double0.0d
short(short)0
byte(byte)0
char‘\u0000’
booleanfalse
referencenull

例如:

1
public static int value = 10;

在准备阶段,会为变量value在方法区分配内存并初始化零值,即value=0,而非10。 因为对于value的赋值10,是由putstatic指令完成。该指令是在java程序被编译后,存放在类构造器<clinit>方法之中。所以 value=10的操作是在类初始化的时候才发生,故类变量在准备期value=0

(2). 常量:赋予真实值

例如:

1
public static final int value = 10;

对于常量,准备阶段会把类字段的字段属性表中的ConstantValue属性所指定的值(此处是10),赋给常量(value),故常量在准备期间value =10;

(3). 实例变量:不赋任何值

该阶段仅对类变量进行内存分配,而对于实例变量(或者称呼为成员变量)并不会分配内存,也就更不用提赋值的事。实例变量的初始化,是随着对象实例化时在Java堆上分配内存而进行的。

4. 解析

主要工作:虚拟机将常量池内的符号引用替换为直接引用的阶段。

先解释一下符号引用和直接引用的概念

  • 符号引用(Symbolic Reference):以一组符号来描述引用目标,符号可以是任意形式的字面量。只能要准确定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标也不一定存在内存。这样兼容性强,各种虚拟机只需要能接受符号引用即可。
  • 直接引用(Direct Reference):直接引用就是指向目标的指针、相对偏移量或者能间接定位到目标的句柄,直接引用和虚拟机内存布局息息相关。直接引用的目标必然存在与内存中。

同一个符号引用 在不同的虚拟机中解析出来的直接引用地址一般都是不相同的;同一个符号引用,在同一个虚拟机下,多次解析时,会对第一次解析结果进行缓存(常量池记录直接引用,并标记已解析状态),从而避免多次解析。

只有静态方法、私有方法、实例构造器以及final方法,这些都可以统称为非虚方法。不可能通过继承或其他方法覆写,在类加载阶段就可以确定只有一个版本。与之相反的就是虚方法,可能有多个版本只有真正运行的时候才可确定的。

特殊情形,对于invokedynamic指令,不会进行缓存过程,每次使用前都会进行解析。

5. 初始化

主工作:主要是执行类构造器方法clinit。(class init的简称)

类初始化阶段是类加载的最后一个阶段。在初始化之前的过程中,用户可控的地方只有通过自定义类加载器参与,其余都是虚拟机主导和控制。

到了初始化,才开始真正的执行类中定义的Java程序代码。

(1). 类的构造方法

类构造方法是由编译器自动收集源文件中的类变量赋值操作静态语句块合并而成的。收集顺序是由语句在源文件的顺序所决定。故静态语句块只能访问定义之前的静态变量;对于定义之后的变量可以赋值,但不能访问。

  1. clinit方法与类的实例构造方法init不同,clinit方法不需要显式调用父类构造器,虚拟机会保证子类的clinit方法执行之前,父类的clinit方法已经执行完毕。故第一个被执行的clinit方法的类肯定是java.lang.Object;
  2. clinit方法不是必需的,对于没有静态块和类变量赋值操作,编译器不会生成clinit方法。
  3. 父类静态语句和静态变量赋值优先于子类.
  4. interface中不能有静态语句块,但仍可以有变量初始化的赋值操作,也可以生成clinit方法。但接口和类的不同是,执行接口的clinit方法不需要先执行父接口的clinit方法。只有当父接口中定义的变量使用时,父接口才会初始化。
  5. 虚拟机保证类构造方法可以多线程正确执行,会加锁、同步的操作。 一个线程执行类构造方法,其他线程阻塞等待,当类构造方法有耗时操作,会造成多进程的阻塞,往往比较隐蔽。

(2). 类初始化时机

虚拟机规范中严格规定有且只有5种情况下,当类没有初始化时必须立即对类进行初始化:

  1. 遇到newgetstaticputstaticinvokeStatic

    这4条字节码指令时。常见场景:

    1. 使用new关键字实例化对象时,触发new
  2. 读取类变量时,触发getstatic;(final常量除外) 3. 设置类变量时,触发putstatic; 4. 调用类的静态方法时,触发invokeStatic

  3. 虚拟机启动时,需指定一个要执行的主类(含有main()的类),虚拟机会先初始化该类;

  4. 初始化一个类时,当其父类没有初始化,则需要先触发其父类的初始化;

  5. 使用java.lang.reflect包中的方法对类进行反射调用时;

  6. java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,并且该句柄所对应的类没有进行过初始化;

对于final常量不能触发类初始化,是由于在编译时已把数据放入常量池的静态字段,当读取类的static final字段时,并不需要初始化类,而是从常量池中去获取相应的数据。上述的5种场景的行为都是对类的一个主动引用过程。除此之外,还有被动引用并不会触发类的初始化过程。