虚拟机类加载机制

tech2022-08-19  134

JVM系列文章索引: 一、HotSpot简要概述 二、JVM体系结构概述 三、虚拟机类加载机制 四、虚拟机字节码执行引擎 五、JVM运行时数据区域 六、内存溢出故障分析 七、垃圾收集器与内存分配策略 八、JVM调优


虚拟机类加载机制

1 概述2 类加载的时机3 类加载的过程3.1 加载3.2 验证3.3 准备3.4 解析3.5 初始化 4 类加载器4.1 三层类加载器4.2 模块化下的类加载器 5 双亲委派模型5.1 双亲委派模型的工作原理5.2 双亲委派模型的优点


1 概述

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。

Java虚拟机把描述类的数据从Class文件加载到内存中,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

与那些在编译时需要进行连接的语言不同,在Java语言中,类的加载、连接和初始化过程都是在程序运行期间完成的,这一特性让Java语言进行提前编译面临额外的困难,同时也增加了类加载的性能开销。

但这也为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分。这种动态组装应用的方式目前已广泛应用于Java程序之中,从最基础的Applet、JSP到相对复杂的OSGi技术,都依赖着Java语言运行期类加载才得以诞生。


2 类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如图所示。 图中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。请注意,这里是按部就班地“开始”,而不是按部就班地“进行”或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这点由虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有: 使用new关键字实例化对象的时候。读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。调用一个类型的静态方法的时候。 使用java.lang.reflect包的方法对类型进行反射调用时,如果类型没有进行过初始化,则需要先触发其初始化。当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种: 当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。


3 类加载的过程

接下来我们会详细了解Java虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段所执行的具体动作。

3.1 加载

“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,在这个阶段,Java虚拟机需要完成以下三件事:

通过类的全限定类名来获取定义该类的二进制字节流;将该字节流所代表的静态存储结构转换为方法区的运行时数据结构;在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问接口。

《Java虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与Java应用的灵活度都是相当大的。例如“通过一个类的全限定名来获取定义此类的二进制字节流”这条规则,它并没有指明二进制字节流要从哪里获取、如何获取。例如: ·从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。 ·从网络中获取,这种场景最典型的应用就是Web Applet。 ·运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。 ·由其他文件生成,典型场景是JSP应用,由JSP文件生成对应的Class文件。 ·从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。 ·可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文件来保障程序运行逻辑不被窥探。

非数组类型的加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成(重写一个类加载器的findClass()或loadClass()方法)。 而对于数组类而言,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类中的元素的类型还是要靠类加载器来完成加载。

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中,并会在Java堆内存中实例化一个java.lang.Class类的对象,作为程序访问方法区中的类型数据的外部接口。

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

3.2 验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

Java语言本身是相对安全的编程语言(起码对于C/C++来说是相对安全的),使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果尝试这样去做了,编译器会毫不留情地抛出异常、拒绝编译。但由于Class文件并不一定只能由Java源码编译而来,它可以使用靠键盘0和1直接在二进制编辑器中敲出Class文件,等等途径产生,这样一来上述Java代码无法做到的事情在字节码层面上都是可以实现的。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟机保护自身的一项必要措施。

从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。 受篇幅所限,以下仅作简要介绍:

文件格式验证 第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。元数据验证 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,保证不存在与《Java语言规范》定义相悖的元数据信息。字节码验证 第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。符号引用验证 最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是判断该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。

3.3 准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置初始值的阶段。

从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,物理实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

准备阶段,有两个容易产生混淆的概念需要着重强调:

该阶段进行内存分配的仅包括类变量,而不包括实例变量。实例变量将会在对象实例化时随着对象一起分配在Java堆中。这里所说的初始值“通常情况”下是数据类型的零值。基本数据类型的零值如下表:

3.4 解析

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

符号引用(Symbolic References):以一组符号来描述所引用的目标; 直接引用(Direct References):直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池中的8种常量类型。具体解析方法参见《深入理解Java虚拟机》中7.3.4章节。

3.5 初始化

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。 准备阶段时,变量已被初始化为零值,而在初始化阶段,则会被初始化为我们代码中指定的值。


4 类加载器

类加载过程中的“加载”阶段中的 “通过一个类的全限定名来获取描述该类的二进制字节流” 这个动作在Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器” (Class Loader)。类加载器可以说是Java语言的一项创新,它是早期Java语言能够快速流行的重要原因之一。

类加载器除了“通过一个类的全限定名来获取描述该类的二进制字节流”这个作用以外,还确保了一个类的唯一性。每一个类加载器,都拥有一个独立的类名称空间,要比较两个类是否“相等”,除了比较类本身以外,还需要保证这两个类是由同一个类加载器加载的。否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是其他的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

站在Java开发人员的角度来看,类加载器就应当划分得更细致一些。自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构,尽管这套架构在Java模块化系统出现后有了一些调整变动,但依然未改变其主体结构。

4.1 三层类加载器

启动类加载器(Bootstrap Class Loader): 负责加载Java核心类库,存放在 <JAVA_HOME>\lib 目录下的Jar包的库,例如Object、String。它无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要将加载请求委派给引导类加载器去处理,那直接使用null代替即可。

扩展类加载器(Extension Class Loader): 这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载Java的扩展库,javax包下的库。这是一种Java系统类库的扩展机制,在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。

应用程序类加载器(Application Class Loader): 这个类加载器由sun.misc.Launcher$AppClassLoader来实现。它负责加载应用类路径(ClassPath)上所有的类,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

_用户自定义加载器: 用户可以通过继承Java.lang.ClassLoader类,自定义类加载器,定制类的加载方式

4.2 模块化下的类加载器

模块化下的类加载器的变动主要包括以下几个方面:

扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader,启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader,启动类加载器(Bootstrap Class Loader)由Java虚拟机内部和Java类库共同协作实现

5 双亲委派模型

各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。

5.1 双亲委派模型的工作原理

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。如果依旧无法加载则会抛出“ClassNotFoundException”。

5.2 双亲委派模型的优点

使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类,不会污染代码,保证“沙箱安全”。

反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

代码示例:

//测试加载器的加载顺序 package java.lang; public class String { public static void main(String[] args) { System.out.println("hello world!"); } }

output: 错误: 在类 java.lang.String 中找不到 main 方法

首先加载的是Bootstrap加载器,由于JVM中有java.lang.String这个类,所以会首先加载这个类,而不是自己写的类,而这个类中并无main方法,所以会报“在类 java.lang.String 中找不到 main 方法”。(可以正常编译,无法被加载运行)

如果自定义了自己的类加载器,强行用defineClass()方法去加载一个以“java.lang”开头的类,将会收到一个由Java虚拟机内部抛出的“java.lang.SecurityException:Prohibited package name:java.lang”异常。

最新回复(0)