文章原文:https://gaoyubo.cn/blogs/4b481fd7.html
在JVM学习-Class文件结构中,讲了Class文件存储格式的具体细节。虽然Class文件中描述了各种类信息,但要让这些信息在虚拟机中运行和使用,就需要加载到内存中。本章将重点介绍虚拟机的类加载机制,包括Class文件如何加载到内存、加载后的信息发生何种变化等方面的内容。
Java虚拟机通过将描述类的数据从Class文件加载到内存中,进行校验、转换解析和初始化,最终生成可以被虚拟机直接使用的Java类型。这一过程即为虚拟机的类加载机制。与那些在编译时需要进行连接的语言不同,Java语言中类型的加载、连接和初始化过程都在程序运行期间完成。尽管这种策略可能导致编译时的一些困难和类加载时的性能开销略微增加,但它为Java应用程序提供了极高的扩展性和灵活性。Java天生支持动态扩展的语言特性依赖于运行时的动态加载和动态连接。
例如,编写一个面向接口的应用程序,可以在运行时指定其实际的实现类。用户可以通过Java预置的或自定义的类加载器,在运行时从网络或其他位置加载一个二进制流作为程序代码的一部分。这种动态组装应用的方式已广泛应用于Java程序,涵盖了从基础的Applet、JSP到相对复杂的OSGi技术。这一创新的方法使得Java语言能够适应多样化的应用需求。
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如下:
类加载过程包括加载、验证、准备、初始化和卸载这五个阶段。这些阶段的顺序是确定的,必须按部就班地开始,而解析阶段则不一定。解析阶段在某些情况下可以在初始化阶段之后再开始,以支持Java语言的运行时绑定特性(动态绑定或晚期绑定)。值得注意的是,这些阶段通常是互相交叉地混合进行的,在一个阶段执行的过程中可能调用、激活另一个阶段。
关于何时需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有强制约束,这点可以由虚拟机的具体实现*把握。然而,在初始化阶段,《Java虚拟机规范》明确规定了六种情况必须立即对类进行“初始化”(加载、验证、准备自然需要在此之前开始):
new
、getstatic
、putstatic
或invokestatic
这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。典型的Java代码场景包括:
new
关键字实例化对象。final
修饰、已在编译期把结果放入常量池的静态字段除外)。java.lang.reflect
包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。main()
方法的那个类),虚拟机会先初始化这个主类。java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。default
关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。这六种会触发类型进行初始化的场景被称为主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
package algorithmAnalysis; /** * 被动使用类字段演示一: * 通过子类引用父类的静态字段,不会导致子类初始化 **/ public class SuperClass { static { System.out.println("SuperClass init!"); } public static int value = 123; } public class SubClass extends SuperClass { static { System.out.println("SubClass init!"); } } /** * 非主动使用类字段演示 **/ public class NotInitialization { public static void main(String[] args) { System.out.println(SubClass.value); } }
上述代码中,运行后只会输出“SuperClass init!”而不会输出“SubClass init!”。
这是因为对于静态字段,只有直接定义这个字段的类才会被初始化。通过子类引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证阶段,在《Java虚拟机规范》中并未明确规定,因此这一点取决于虚拟机的具体实现。在HotSpot虚拟机中,可以通过添加参数-XX:+TraceClassLoading
观察到这个操作会导致子类加载,输出结果如下。
package algorithmAnalysis; /** * 通过数组定义来引用类,不会触发此类的初始化 **/ public class Test { public static void main(String[] args) { SuperClass[] superClasses = new SuperClass[10]; } }
这段代码复用了示例一的SuperClass,运行之后发现没有输出“SuperClass init!”,说明并没有触发类algorithmAnalysis.SuperClass的初始化阶段。但是这段代码里面触发了另一个名为“[LalgorithmAnalysis.SuperClass”的类的初始化阶段。
对于用户代码来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由
字节码指令newarray触发。
这个类代表了一个元素类型为algorithmAnalysis.SuperClass的一维数组,数组中应有的属性 和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。
Java语 言中对数组的访问要比C/C++相对安全,很大程度上就是因为这个类包装了数组元素的访问(准确地说,越界检查不是封装在数组元素访问的类中,而是封装在数组访问的xaload、xastore字节 码指令中),而 C/C++中则是直接翻译为对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出
java.lang.ArrayIndexOutOfBoundsException异常
,避免了直接造成非法内存访问。
在加载阶段,Java虚拟机执行以下三个主要任务:
java.lang.Class
对象,用于在方法区中访问该类的各种数据。这个Class
对象是对类的抽象,通过它可以获取类的各种信息。《Java虚拟机规范》确实在对类加载的过程中给予了相当大的灵活性,没有强制指定二进制字节流必须从Class文件中获取,这为Java虚拟机的实现和应用带来了广泛的适用性和可扩展性。开发人员在这个灵活的舞台上发挥了巨大的创造力,导致了许多重要的Java技术的诞生。以下是一些典型的应用场景:
- 从ZIP压缩包中读取: 这为日后JAR、EAR、WAR等格式的应用打下了基础,这些格式在Java应用中广泛使用,提供了一种方便的打包和分发方式。
- 从网络中获取: Web Applet是一个典型的应用场景,它允许在Web浏览器中加载并执行Java小程序,通过网络获取字节流。
- 运行时计算生成: 动态代理技术是一个重要的应用,它允许在运行时生成代理类的字节流,用于实现动态代理。
- 由其他文件生成: JSP应用是一个例子,其中JSP文件会在运行时被编译成对应的Class文件,实现了动态生成和加载。
- 从数据库中读取: 在一些中间件服务器中,程序代码可以安装到数据库中,通过加载时从数据库获取相应的字节流,实现了在集群间的分发。
- 从加密文件中获取: 采用加载时解密Class文件的方式,可以作为一种保护措施,防止Class文件被反编译。
加载阶段相对于类加载过程的其他阶段具有更高的可控性,尤其是在非数组类型的加载阶段。在这个阶段,开发人员可以通过以下方式灵活控制:
findClass()
或loadClass()
方法。对于数组类的加载,虽然数组类本身是由Java虚拟机直接在内存中动态构造的,但与类加载器仍然存在密切关系。数组类的创建遵循以下规则:
此外,数组类的可访问性与其组件类型的可访问性一致。如果组件类型不是引用类型,数组类的可访问性默认为public,可被所有的类和接口访问。
加载阶段结束后,二进制字节流按虚拟机设定的格式存储在方法区中。在方法区中,类型数据会被实例化为一个java.lang.Class
对象,这个对象作为程序访问方法区中类型数据的外部接口。
需要注意的是,加载阶段与连接阶段的一些动作是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。这两个阶段的开始时间保持着固定的先后顺序。
Java语言本身具有相对较高的安全性,相比于C/C++等语言来说更为安全。使用纯粹的Java代码通常无法执行一些危险操作,比如访问数组边界以外的数据、将对象转型为其未实现的类型、跳转到不存在的代码行等。在这些情况下,编译器会严格抛出异常并拒绝编译。
然而,需要注意的是,Class文件并不一定只能由Java源码编译而来。任何途径产生的Class文件,包括直接在二进制编辑器中编写0和1的方式,都是有效的。因此,验证字节码是Java虚拟机保护自身安全的必要措施。
验证阶段在整个类加载过程中具有重要意义,其严谨程度直接影响Java虚拟机是否能够抵御恶意代码攻击。验证阶段涵盖了文件格式验证、元数据验证、字节码验证和符号引用验证等四个主要方面。
验证阶段的工作量相当大,涉及到整个类加载过程的安全性和性能。因此,它是保障Java应用程序安全执行的关键环节。
在验证阶段的第一阶段,主要任务是验证字节流是否符合Class文件格式的规范,并且能够被当前版本的虚拟机正确处理。以下是包含在这一阶段的验证点:
在验证阶段的第二阶段,主要任务是对字节码描述的信息进行语义分析,以确保其描述的信息符合《Java语言规范》的要求。以下是包含在这一阶段的验证点:
java.lang.Object
之外都应该有父类。final
修饰的类。final
字段,或者出现不符合规则的方法重载(方法参数一致但返回值类型不同等)。这些验证点旨在对类的元数据信息进行语义校验,以确保它们符合Java语言规范的定义。
在验证阶段的第三阶段,通过数据流分析和控制流分析,目标是确定程序语义是合法的、符合逻辑的。在进行方法体的校验分析时,主要考虑以下验证点:
为了降低在字节码验证阶段中的执行时间开销,Java虚拟机设计团队采用了联合优化策略。该策略在JDK 6之后实施,主要包括在Javac编译器中增加了校验辅助措施,并通过引入名为
StackMapTable
的新属性来描述方法体的基本块状态。这一策略的核心思想是通过在编译期执行尽可能多的校验辅助措施,从而减轻字节码验证期间的负担。具体而言:
- Javac编译器中的校验辅助措施: Javac编译器在编译期执行一系列校验辅助措施,以便在方法体的Code属性中引入StackMapTable属性。这样,编译器在校验阶段就能够提供关于基本块状态的信息,减轻虚拟机在字节码验证期间的工作。
- StackMapTable属性的引入: StackMapTable属性是一项新的属性,用于描述方法体的基本块状态。这个属性记录了基本块开始时本地变量表和操作栈应有的状态。虚拟机在字节码验证期间只需检查StackMapTable属性中的记录是否合法,而无需推导这些状态的合法性,从而减少了验证的时间开销。
在类加载的最后一个阶段,校验行为发生在虚拟机将符号引用转化为直接引用的时候,即发生在连接的第三阶段——解析阶段中。符号引用验证旨在匹配类自身以外的各类信息,确保类能够正常访问其依赖的外部类、方法、字段等资源。此阶段通常需要校验以下内容:
符号引用验证的主要目的是确保解析行为能够正常执行。如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError
的子类异常,如java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
验证阶段对于虚拟机的类加载机制是重要但非强制执行的阶段,因为验证阶段只有通过或不通过的差别。一旦通过了验证,其后对程序运行期没有任何影响。
如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都经过了反复使用和验证,那么在生产环境的实施阶段可以考虑使用
-Xverify:none
参数关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段
这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
初始值通常情况
下是数据类型的零值:
public static int value = 123;
<clinit>()
方法中,<clinit>()
方法会在初始化时执行,也就是说,value 变量只有在初始化后才等于 123。如果类字段的字段属性表中存在ConstantValue属性
,那在准备阶段变量值就会被初始化为ConstantValue属性所指定
的初始值:
public static final int value = 123;
()
static final
赋值之后 value 就不能再修改了,所以在这里进行了赋值之后,之后不可能再出现赋值操作,所以可以直接在准备阶段就把 value 的值初始化好。解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,JVM学习-Class文件结构-符号引用
对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。
invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符 (Dynamically-Computed Call Site Specifier)”,这里“动态”的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。
相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。
假设当前代码所处的类为D,解析一个从未解析过的符号引用N为一个类或接口C的直接引用通常涉及以下三个步骤:
java.lang.IllegalAccessError
异常。在JDK 9及之后的版本中,需要考虑模块化的因素,即访问权限验证还需检查模块之间的访问权限。具体来说,一个D要访问C,至少满足以下三条规则之一:
public
的,并且与访问类D处于同一个模块。public
的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的模块进行访问。public
的,但是它与访问类D处于同一个包中。要解析一个未被解析过的字段符号引用,通常需要按照以下步骤进行:
class_index
项(class_index)中索引的CONSTANT_Class_info
符号引用进行解析,即解析字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现异常,导致字段符号引用解析失败。java.lang.Object
,按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,返回这个字段的直接引用,搜索结束。java.lang.NoSuchFieldError
异常。java.lang.IllegalAccessError
异常。解析规则确保Java虚拟机能够获得字段的唯一解析结果。在实际情况中,Javac编译器可能会采取比规范更加严格的约束,例如,当一个同名字段同时出现在某个类的接口和父类中,或者同时在自己或父类的多个接口中出现时,Javac编译器可能会拒绝编译为Class文件。
方法解析的步骤与字段解析相似,通常包括以下步骤:
class_index
项中索引的方法所属的类或接口的符号引用。使用C表示这个类。如果解析成功,继续后续的方法搜索。class_index
中索引的C是个接口,直接抛出java.lang.IncompatibleClassChangeError
异常。java.lang.AbstractMethodError
异常。java.lang.NoSuchMethodError
。java.lang.IllegalAccessError
异常。解析接口方法和解析类的方法在主要逻辑上是相似的,但由于接口和类在Java中有一些不同的特性,导致在解析过程中存在一些细微的差异:
class_index
中索引的C)是个类而不是接口,会直接抛出java.lang.IncompatibleClassChangeError
异常。这是因为接口方法必须属于接口,而不能是类的方法。在JDK 9之前,Java接口中的所有方法默认都是
public
的,且不存在模块化的访问约束,因此接口方法的符号解析不会抛出java.lang.IllegalAccessError
异常。然而,从JDK 9开始,引入了接口的静态私有方法以及模块化的访问约束,因此在JDK 9及以后的版本中,接口方法的访问可能会因为访问权限控制而抛出java.lang.IllegalAccessError
异常。
在Java虚拟机的类加载过程中,初始化阶段是加载过程的最后一个步骤。在初始化阶段,Java虚拟机执行类构造器<clinit>()
方法,该方法是由编译器自动生成的,用于执行类中的所有类变量的赋值动作和静态语句块中的语句。
以下是关于初始化阶段和<clinit>()
方法的一些重要信息:
<clinit>()
方法由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句生成。编译器根据源文件中语句的顺序来确定收集的顺序。<clinit>()
方法与类的构造函数(实例构造器<init>()
方法)不同。它不需要显式调用父类构造器,并且Java虚拟机保证在子类的<clinit>()
方法执行前,父类的<clinit>()
方法已经执行完毕。第一个被执行的<clinit>()
方法的类型是java.lang.Object
。<clinit>()
方法。与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法。<clinit>()
方法在多线程环境中正确地加锁同步。如果多个线程同时初始化一个类,只会有其中一个线程执行该类的<clinit>()
方法,其他线程需要阻塞等待。<clinit>()
方法,其他线程需要等待。Java虚拟机设计团队采用创新的方式将类加载阶段中获取类的二进制字节流的动作放到Java虚拟机外部实现,这个实现被称为"类加载器"(Class Loader)。这设计的初衷是为了让应用程序自己决定如何获取所需的类,为Java语言带来了灵活性和可扩展性。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
instanceof
关键字equals()
isInstance()
isAssignableFrom()
Java虚拟机的角度来看,只存在两种不同的类加载器:
java.lang.ClassLoader
Java开发人员的角度来看,类加载器就应当划分得更细致一些。三层类加载器、双亲委派的类加载架构:
图7中展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。
当前类加载器收到类加载的请求后,先不自己尝试加载类,而是先将请求委派给父类加载器
因此,所有的类加载请求,都会先被传送到启动类加载器
只有当父类加载器加载失败时,当前类加载器才会尝试自己去自己负责的区域加载
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 首先,检查请求的类是否已经被加载过了 Class<?> c = findLoadedClass(name); if (c == null) { try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 如果父类加载器抛出ClassNotFoundException // 说明父类加载器无法完成加载请求 } if (c == null) { // 在父类加载器无法加载时 // 再调用本身的findClass方法来进行类加载 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }
parent.loadClass(name, false)
extends ClassLoader
,然后重写 findClass()
方法而不是 loadClass()
方法,这样就不用重写 loadClass()
中的双亲委派机制了java.lang
、java.util
等)由启动类加载器加载,防止用户自定义类替代核心类库,从而保护了Java运行环境的稳定性和一致性。在某些情况下,开发者可能会有意或无意地破坏双亲委派机制。以下是一些可能导致双亲委派机制破坏的情况:
loadClass
方法时,可以选择不调用父类加载器的 loadClass
方法,从而实现自定义的加载逻辑。Thread.setContextClassLoader
方法进行设置。在一些框架和应用场景中,开发者可能会为线程设置上下文类加载器,以便在特定的情况下改变类加载器的委派行为。premain
方法中使用 java.lang.instrument.ClassFileTransformer
接口,开发者可以修改类的字节码,从而破坏双亲委派机制。在Java 9之前,Java应用程序是以JAR文件的形式组织的,其中包含了一堆类和资源。这种方式存在一些问题:
Java模块化解决了这些问题。模块是一种新的编程单元,它可以包含类、资源和其他模块的依赖关系。模块化的代码更容易维护,更容易重用,同时也提供了更好的安全性。
为了保证兼容性,JDK 9并没有从根本上动摇从JDK 1.2以来运行了二十年之久的三层类加载器架构以及双亲委派模型。但是为了模块化系统的顺利施行,模块化下的类加载器仍然发生了一些变化。
<JAVA_HOME>\lib\ext
目录。之前通过这个目录来加载扩展类库的扩展类加载器也就不再需要,完成了它的历史使命。<JAVA_HOME>\jre
目录: 在新版的JDK中取消了<JAVA_HOME>\jre
目录。这是因为现在可以根据需要组合构建出程序运行所需的JRE。例如,如果只需要使用java.base
模块中的类型,可以通过jlink
命令轻松地打包出一个只包含所需模块的“JRE”。最后,JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了 变动。
当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能 够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的破坏。
启动类加载器负责加载的模块
平台类加载器负责加载的模块
应用程序类加载器负责加载的模块