原本是想写一篇关于Java类加载机制的博文,后来发现这个主题有点大,其中涉及的细节点太多,一篇博文,三言两语恐怕无法讲明白,于是乎决定从整体到局部,先来谈谈类的生命周期,从整体把握一个类从“出生”到“凋亡”的过程,其中涉及了类加载、使用、卸载等各个阶段,有了整体的认知后,再深入细节并结合具体实例,探讨加载原理、类加载器等相关知识。今天就让博主带领你开启第一段旅程:类的生命周期详解。
类的生命周期
类的生命周期是指一个class从加载到内存直至卸载出内存的过程,共包含加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段,如下图所示:
Java类的生命周期其中验证、准备、解析三个阶段统称为连接(Linking),而加载、连接、初始化又可以统称为类加载的过程,所以我们有时又可以称类的生命周期包含加载、连接、初始化、使用和卸载这5个阶段,或者是类加载、使用、卸载这3个阶段。
回到上图,加载、验证、准备、初始化和卸载这5个阶段的开始顺序是确定的,如图中箭头所示。之所以强调“开始顺序”,是因为这里的先后顺序仅仅是各阶段开始时间的顺序,而不是进行或完成的顺序,这些阶段通常是相互交叉地混合式进行的。比如加载和验证,并不是说非要等到加载完成之后,才开始验证阶段,在加载的阶段中,会穿插各种检验动作,否则对于连格式都不符合的字节流,又怎能正确解析出其中的静态数据结构从而转化为方法区中的数据结构呢?对于解析阶段,其开始时间则比较特殊,既可能在加载阶段就开始(对常量池中的符号引用的解析),也可能在初始化阶段之后才开始(支持Java语言的动态绑定)。
下面我们就来看看各个阶段都大致做哪些事情。
一、类加载的过程
类加载的过程包含加载、连接和初始化三个阶段。
1.1 加载
加载是类加载过程的第一阶段,此时虚拟机将查找并加载类的二进制数据,具体分为三个步骤:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表此类的java.lang.class对象,作为对方法区中此类的各种数据的访问入口。
这三条属于虚拟机规范的内容,只指明了做什么,具体实现交由虚拟机实现自行安排,这就给了虚拟机实现和具体应用足够的灵活度。对于第一条,并未指明定义类的二进制字节流的存储形式(class文件、ZIP包)、来源(本地文件系统、内存或网络)以及获取方式(既可以从已有静态资源读取也可动态生成),因而就有了如下的多样可能性:
- 从ZIP包中读取,这是后来支持类加载器可从JAR、EAR、WAR等格式文件中加载class的基础。
- 从网络中获取字节流,我们熟知的Applet是这种场景的典型应用。
- 程序动态生成字节流,这种场景应用最多的就是动态代理,通过字节码技术动态生成代理类的二进制字节流。
- 由除了Java源文件之外的其他文件编译而成,如JSP文件、Scala源文件等。
对于第三条中所说的“内存”,虚拟机规范并没有明确规定是在Java堆还是方法区中,对于我们最为熟悉的HotSpot虚拟机,是存放在Java堆的永久代中。实际上永久代是HotSpot虚拟机特有的,是它对虚拟机规范中方法区概念的具体实现(JDK1.7及以下),对于其他虚拟机(如IBM J9)是不存在永久代一说的,关于方法区和永久代的关系超出本博文的谈论范畴了,点到为止。
加载阶段完成后,原本定义类的二进制字节流就按照虚拟机所需的格式存储在方法区中,这里的存储格式依具体的虚拟机实现而定,各有差异,虚拟机规范并未规定此区域的具体数据结构。
关于加载阶段的注意点:
- 数组类的加载比较特殊,它本身并不通过类加载器创建,而是由Java虚拟机直接创建,但数组类的元素类型(去掉所有维度后的类型,比如A[][]的元素类型,就是A)是由类加载器加载的。举例,对于类型
org.sherlockyb.test.HelloWorld
,定义一维数组类HelloWorld[] hws = new HelloWorld[8]
,虚拟机会直接创建名为“[Lorg.sherlockyb.test.HelloWorld”的数组类,并对其进行初始化。
类加载器
上一节中加载阶段的第一步骤——“通过一个类的全限定名来获取定义此类的二进制字节流”,就是类加载器所做的唯一工作,类加载器是Java技术体系中的重要基石,它在类层次划分、OSGi、热部署、代码加密等领域扮演着重要角色,关于它我们暂且不做细致介绍,后面会有单独博文深入探讨之。
加载时机
虚拟机规范并未强制规定加载阶段具体什么时候开始,由虚拟机实现自由把握。就我们所熟知的HotSpot虚拟机来说,有两种情况:
- 预加载。虚拟机在启动时会预先加载rt.jar中的class文件,其中包括java.lang.*、java.util.*、java.io.*等运行时常用的类。
- 运行时加载。当虚拟机在运行过程中需要某个类时,如果该类的class未被加载则加载之。
1.2 连接
连接可细分为三个阶段:验证、准备和解析。
验证
连接的第一个阶段,确保从class文件中所加载的字节流符合当前虚拟机的要求,且不会危害虚拟机自身的安全。该阶段会依次进行如下校验:
- 文件格式校验:判断当前字节流是否符合class文件格式的规范。如是否以class文件的魔数oxCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中常量的类型是否合法等等。校验的目的是保证字节流能正确地解析并存储于方法区内,通过验证后,会在方法区中存储,后面的校验动作都是基于方法区的存储结构进行,不再直接操作字节流。
- 元数据校验:语义分析,判断其描述的信息是否符合Java语言的规范要求。如该类除了java.lang.Object之外,是否有其他父类;该类的父类是否继承了不允许被继承的final类等
- 字节码验证:通过数据流和控制流分析,判断程序语义是否合法、符合逻辑。如保证跳转指令不会跳转到方法体以外的字节码指令上、方法体中的类型转换是有效的等。
- 符号引用校验:发生在解析阶段将符号引用转为直接引用的时候,确保解析动作能正确执行。如符号引用中通过字符串描述的全限定名是否能找到对应类。
从上面可以看出,验证阶段非常重要,关乎虚拟机的安全,但它并不是必须的,它对程序运行期没有影响,如果所引用的类已被反复使用和验证过,那么可以考虑采用-Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。通常来讲,应用所加载的class文件都是由我们本地或服务器的JDK编译通过的,我们都确定它是符合虚拟机要求的,对于这类class文件其实并不需要验证,主要是像从网络加载的class字节流或是通过动态字节码技术生成的字节流,出于安全的考虑,是必须要经过严格验证的。
准备
准备阶段做的唯一一件事就是为类的静态变量分配内存,并将其初始化为默认值。注意这里的初始化和后面要讲的“初始化阶段”是不同的,容易混淆。这些内存都在方法区中分配。几点注意项:
- 对于初始化为默认值这一点,有两个角度的理解:从Java应用层面讲,会为不同的类型设置对应的零值,如对于int、long、byte等整数对应就是0,对于float、double等浮点数则是0.0,而对于引用类型则是null,有个零值映射表,具体就不在这一一列举了;从JVM层面,实际上就是分配了一块全0值的内存,只是不同的数据类型对于0值有不同的解释含义,这是Java编译器自动为我们做的。
- 如果类的静态变量是final的,即它的字段属性表中存在ConstantValue属性,那么在准备阶段就会被初始化为程序指定的值,比如对于
public static final int len = 5
,在准备阶段len
的值已经被设置为5了。实际上对于final的类变量,在编译时就已经将其结果放入了调用它的类的常量池中,这种类变量的访问并不会触发其所属类的初始化阶段。
解析
该阶段把类在常量池中的符号引用转为直接引用。符号引用就是一组用来描述目标的字面量,说白了就是静态的占位符,与内存布局无关,而直接引用则是运行时的,是指内存中直接指向目标的指针、相对偏移量或间接定位到目标的句柄。解析工作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符这7类符号引用,将其替换为直接引用。
虚拟机规范规定,在执行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic这16个用于操作符号引用的字节码指令之前,必须先对符号引用进行解析。至于具体时间并未要求,交由虚拟机实现自行决定:在类被加载时就对常量池中的符号引用进行解析(静态指令,除invokedynamic之外的),或是等到一个符号引用将要被使用前才去解析(动态指令:invokedynamic,为了支持动态绑定)。
1.3 初始化
为类的静态变量赋予程序设定的初始值。在Java中对类变量设定初始值有两种方式:声明类变量时指定初始值和静态代码块为静态变量赋值。我们来看下类的初始化步骤:
- 若该类还没有被加载和连接,则先加载并连接该类
- 若该类的直接父类没有被初始化,则先初始化其父类(接口没有此规则)
- 若该类有初始化语句(赋值语句和静态代码块),则按照代码中申明的顺序依次执行初始化语句
我们可以从字节码层面获知上述初始化步骤的原理,
编译器在编译Java源文件时,自动收集类中所有类变量的赋值操作和静态语句块中的语句(按照源码中声明先后顺序),将其合并产生<clinit>方法,即类构造器(注意与实例构造器<init>相区分)。该方法的执行过程遵循以下规则:
- 虚拟机保证在子类的<clinit>方法执行之前,会先执行父类的<clinit>方法(若父类是接口,则忽略不执行),依次递归。
- <clinit>方法并不是必须的。若一个类或接口中既没有类变量的赋值操作也没有静态语句块(接口没有此项),编译器可以不为它生成<clinit>方法。
- 虚拟机会保证<clinit>方法在多线程环境中被正确地加锁、同步,确保同一时刻只会有一个线程去执行该方法。这也是单例模式其中一种实现方式(定义静态实例)的依据。
初始化时机
虚拟机规范严格规定,当发生对一个类的主动引用时,会立即触发类的初始化阶段。主动引用有且仅有以下5种情况:
- 遇到new、getstatic、putstatic或invokestatic这4条字节码时,如果类没有被初始化,则先触发其初始化。从Java代码层面来讲,就是使用new关键字实例化对象、读取或设置类的静态字段(final修饰的常量字段除外)、调用静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用时,如Class.forName(...)。
- 当初始化一个类时,若其父类还未初始化,则先触发其父类的初始化(接口无此规则)。
- 当虚拟机启动时,用户需指定一个主类(包含main方法),虚拟机会先初始化该类。
- 对于REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄(使用JDK1.7的动态特性),若其对应的类还未初始化,则先触发其初始化。
除此之外,其他所有引用类的方式都属于被动引用,不会触发初始化。
二、类的使用
包括主动引用和被动引用,前者在上节已有说明,我们来列举几个被动引用的实例:
- 通过子类调用父类的静态字段,不会触发子类初始化。
- 通过数组定义来引用类,不会触发该类的初始化。例如
A[] arr = new A[8]
,并不会触发A的初始化。 - 在类A中调用B的常量字段,不会触发B的初始化。因为此常量字段在编译阶段会存入调用类A的常量池中,本质上并没有直接引用到定义类B。
三、类的卸载
当一个类被判定为无用类时,才可以被卸载。条件苛刻,需要同时满足如下条件:
- 类的所有实例都已被回收。
- 加载该类的ClassLoader已被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用。
对于满足上述3个条件的无用类,虚拟机可以对其回收,但并不是必然的,是否回收可通过-Xnoclassgc
参数控制。注意:在大量使用反射、动态代理等字节码框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代(特指HotSpot虚拟机)不会溢出。
总结
终于算是“走马观花”般地把Java类的生命周期过了一遍,相信当再提起类的生命周期时,大家脑海里就会立马浮现出类生命周期的大纲,都有哪些阶段,每个阶段都大致做些什么事情,都有些什么注意点,这样,本博文的目的就达到了!掌握了全局之后,接下来就是细节的探讨,比如像验证阶段中的字节码验证,实际是非常复杂的,虚拟机专门为此做了诸多优化;再比如解析阶段,7类符号引用各自不同的解析细节又是什么,等等之类。之后,笔者将会单独另起博文,针对类加载器、解析阶段等进行详细分析,敬请期待。
同步更新到原文
网友评论