类加载机制
1、类加载机制概述
java虚拟机的结构图如上所示,从详细图可以看出,字节码文件首先会被类加载器子系统加载到内存中,类加载器的工作分为三步:加载、链接和初始化。加载需要加载器,比如引导加载器、扩展加载器和应用加载器等。链接环节分为验证、准备、解析三个步骤,最后是初始化。文件被加载到内存中,内存中又分为不同区域。之后解释器和JIT即时编译器会把字节码翻译成成机器指令。当内存使用完毕后,GC收集器会对内存区域进行回收。
从上图可以看出,如果自己手写虚拟机,需要考虑 类加载器 和 执行引擎。类加载是虚拟机极其重要的一个过程。
class文件中描述了各种信息,这些信息需要被加载到虚拟机之后才能被运行和使用。java虚拟机吧描述类的数据从Class文件加载到内存,并对其进行检验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这个过程被称作虚拟机的类加载机制。
java语言里面,类型的加载、连接和初始化实在程序运行期间完成的,而不是像某些语言一样,在编译阶段就完成了。这种策略虽然让提前编译多些困难,也让类加载稍微增加了性能开销,但是却为java应用提高了极高的扩展性和灵活性。java的动态扩展特性就是依赖于运行期动态加载和动态链接特点实现的。比如Applet、JSP和OSGI等技术,都依赖于运行期动态加载才实现。
2、类加载的时机
一个类型(类或接口)从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。其中验证、准备和解析被统称为连接(Linking)。
其中加载、验证、准备、初始化和卸载顺序是确定的,类加载必须按照此顺序开始,而解析则不一定,某些情况下它可以在初始化之后再开始(比如动态绑定时)。
虚拟机规范明确规定了以下6种情况必须立即对类进行初始化。如果类型没有进行过初始化,则必须进行类的加载、验证、准备和初始化。
- 遇到new、getstatic、putstatic和invokestatic 4条字节码指令。即实例化对象(new),读取静态字段和调用静态方法时。
- 使用java.lang.reflect包的方法对类进行反射调用
- 当初始化类时发现父类还没初始化过,则必须先初始化父类
- 虚拟机启动时,需要执行主类(main),虚拟机会先初始化主类。
- jdk7新加的动态语言支持,如果java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_newInvokeStatic和REF_newInvokeSpecial四种类型的方法句柄,且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- jdk8新加入的默认方法(default修饰的接口方法)。如果接口的实现类发生了初始化,则该接口必须先被初始化。
1 |
|
以上代码并不会输出super,是因为以上代码并没有触发LoadingSuper类的初始化阶段,但是它出发了[Lcom.hk7.memory.LoadingSuper初始化,数组的定义是由newarray指令触发的。
1 |
|
依旧不会输出super,因为以上代码也没有触发LoadingSuper类的初始化阶段。虽然确实引用了LoadingSuper类的常量,但是实际上,在编译阶段,由于常量传播优化,此常量的值”JVM“已经被存储在NotInitialization类中了。编译之后LoadingTest直接引用的是NotInitialization类,因此LoadingSuper并不会被初始化。
对于接口的类初始化,区别在于以上第3种,接口初始化时不必初始化父类,父类只有被使用到才会初始化。
3、类加载过程
3.1 加载
加载是类加载第一个阶段,这一阶段,jvm需要完成以下三件事:
- 通过一个类的全限定(比如java.lang.Integer)名来获取定义此类的二进制字节流。
- 将获取到的字节流所代表的的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
需要注意,类的二进制字节流文件不仅仅从磁盘加载,还可以从网络、数据库、压缩包或者运行时计算生成等。
非数组类型的加载阶段,可以使用jvm内置的引导类加载器来完成,也可以使用自定义的类加载去完成。
对于数组类不通过类加载创建,而是jvm直接在内存中构造出来的。但是数组类的元素类型最终还是依靠类加载器来完成加载,它的创建规则如下:
- 如果数组组件类型为引用类型,则递归的采用本节定义的加载过程加载组件类型。数组会被标识在该类加载的类名称空间上。
- 如果是直接类型,则把数组C标记为与引导类加载器关联。
- 数组类的可访问性和它的组件类型可访问性一致,如果不是引用类型,则为public。
加载阶段结束后,二进制字节流就按照虚拟机所规定的格式存储在方法区里面了,方法区中的数据结构由虚拟机实现自行定义。类型数据妥善安置在方法区之后,会在java堆内存中实例化一个java.lang.Class类对象,这个对象是程序访问方法区中类型数据的外部接口。
3.2 验证
验证是一个很重要的阶段,必须确保class文件中的数据全都符合虚拟机规范,保证不会危害到虚拟机自身安全。验证阶段决定了虚拟机是否能承受恶意代码的攻击,在性能和代码量来说,占了类加载很大的比重。验证的东西特别多,主要分为文件格式验证、元数据验证、字节码验证和符号引用验证四种。
文件格式验证
主要是验证class文件的格式是否符合虚拟机规范。比如是否0xCAFEBABE开头,版本号是否合理、常量池类型等。具体可看类文件结构分析。验证通过后字节流才被允许存储在jvm内存的方法区中。后面的阶段均基于方法区进行,不会再读取字节流。
元数据验证
对字节码描述的信息进行语义分析。比如类是否有父类、是否继承了不被允许继承的类、类中的方法字段等是否和父类产生矛盾等。
字节码验证
最复杂的验证,主要通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。元数据校验之后,这部分是对类的方法体(Class文件中的Code属性)进行校验。保证方法被运行时不会危害虚拟机。比如
任意时刻操作数栈的数据类型和指令序列能操作的数据类型一致。比如iload — long不可。
任何跳转指令不会跳到方法体以外的字节码指令上。
方法体中的类型转换合法。比如Pig转Animal可,Pig转Food不可。
……
由于字节码验证复杂性很高,为了避免验证阶段耗费过多时间,jdk6之后的javac编译器进行了优化,把大量的辅助验证挪到javac编译器里进行。具体是:给方法体的Code属性属性表中新增了一块”StackMapTable“的新属性,该属性描述了方法的所有基本块开始时的本地变量表和操作栈应有的状态。在字节码验证阶段,只验证StackMapTable的合理性。这样就将类型推导变为了类型检查,大大节省时间。推导的过程编译时验证。
符号引用验证
解析时,虚拟机会将符号引用转化为直接引用,这时需要对符号引用进行验证。验证内容为
符号引用中通过字符的全限定名是否可以找到对应的类
指定类中是否存在符合方法的字段描述符以及简单名所描述的方法和字段
符号引用中的类、字段、方法的可访问性是否可被当前类访问
……
符号引用验证目的是确保解析能够正常执行。如果验证不通过,则会抛出java.lang.IncompatibleClassChangeError的子异常。
3.3 准备
准备阶段正式为类中定义的静态变量分配内存并设置零值。在JDK7及其之前,HotSpot使用永久代来实现方法区,内存分配在方法区上进行。JDK8及其之后,类变量会随着Class对象一起存放在Java堆中。
准备阶段只是为类变量赋值,而且是赋零值。实例变量会随着对象的实例化和对象一起被分配在java堆中。比如public static int val = 123
,准备阶段之后,val的值为0。给val赋值123的putstatic指令是程序编译后,存放在构造器clinit()方法中,因此真正赋值的动作要到类的初始化阶段才会被执行。java基本数据类型的零值如下:
1 |
|
一般而言,准备阶段会给类变量赋零值,但是也有例外。如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段类变量会被初始化为ConstantValue属性所指定的初始值。比如public static final int val=123
,编译时javac会为val生成ConstantValue属性,在准备阶段虚拟机会把val赋值为123。
3.4 解析
该阶段主要工作:java虚拟机将常量池内的符号引用替换为直接引用。
- 符号引用:用一组符号描述所引用的目标,比如Class文件中的CONSTANT_Class_info等常量。符号引用与虚拟机内存布局无关,引用的目标并不一定必须加载到内存中。
- 直接引用:可以直接执行目标的指针、相对偏移量或者是一个能简介定位到目标的句柄。直接引用和虚拟机实现的内存布局直接相关。
比如有以下代码,当加载A.class文件时,b.f()这一段代码块以符号的形式存在(比如f),标识这个地方引用的不是实际内存中存在的地址,这个叫符号引用。链接的时候,所有jvm会把所有f符号引用的代码块替换为实际内存地址,这个叫直接引用。
1 |
|
《java虚拟机规范》没有规定解析发生的具体时间,但是以下执行以下指令之前,必须对符号引用进行解析。以下指令有个共同点,都需要操作内存,因此在此之前必须完成解析。
1 |
|
https://www.jianshu.com/p/3c710f7f62ad
虚拟机的实现可以根据需求自行判断,类被加载前就对常量池中的符号引用进行解析,还是等到符号引用将要被使用前才去解析它。
同一个符号被多处引用,则会进行都次解析,除了invokedynamic指令,其他的指令第一次解析符号时,虚拟机会把解析结果缓存起来,后续再次解析符号时,会从缓存中直接读取。invokedynamic指令用于动态支持,必须程序运行到该条指令时,才会进行解析过程。
类或接口的解析
假设当前代码所处类为D,第一次解析符号引用N,将其解析为类或者接口C的直接饮用,步骤如下:
- C不是数组类型,虚拟机会N的全限定名传递给D的类加载器,D的类加载器会加载类C。过程中由于加载验证,可能会触发其他相关类的加载。
- C是数组类型,且元素类型为对象,N的描述符类似于
[Ljava/lang/Integer
的形式,则按照规则1加载数组的元素类型,比如需要加载的元素类型为java.lang.Integre,虚拟机会生成一个代表该数组维度和元素的数组对象。 - 上述1和2正常加载后,C在虚拟机中实际上已经成为一个有效的类或者接口了,但是还需要进行符号引用验证,即验证D是否具备对C的访问权限。权限不符合会报java.lang.IllegalAccessError异常。
字段解析、方法解析、接口方法解析很简单,主要是解析符号,按照顺序层层解析,具体的解析例子可以看上一篇文章,类文件结构中的例子。
3.5 初始化
初始化时类加载过程的最后一个步骤,初始化阶段,虚拟机会根据程序员主观计划去初始化变量和其他资源,即执行类构造器<clinit>()
方法的过程。<clinit>()
方法不是直接在java代码中定义的,而是javac编译器生成的。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,收集顺序是和语句在代码中的顺序一致,静态语句块中只能访问到静态语句块之前的变量,之后的变量,可以赋值,但不能访问。
1 |
|
由于父类的<clinit>()
方法先执行,因此父类中定义的静态语句块优先于子类的变量赋值操作。java虚拟机中第一个被执行的<clinit>()
方法类型肯定是java.lang.Object。
1 |
|
<clinit>()
方法对于类或接口来说非必须,如果类中无static{},没有类似static int = 1代码,也没有构造方法,则编译不会生成clinit方法。多线程环境中,只会有一个线程去执行这个类的clinit方法,其他线程会阻塞直至clinit执行结束。
4、类加载器
4.1 类和类加载器
类加载器用于实现类的加载工作。类由类本身和加载它的类加载器来确定唯一性,同一个class文件被同一个虚拟机加载,但是如果加载他们的加载器不同,则这2个类不就不相等。比如以下例子,一个是由虚拟机的应用程序类加载器加载,一个是由自定义加载器加载,虽然来自同一个class文件com.hk7.memory.ClassLoadingTest.class,但java虚拟机中同时存在2个com.hk7.memory.ClassLoadingTest类,其instanceof结果不一致。
1 |
|
4.1 双亲委派机制
类加载器除了能用来加载类,还能用来作为类的层次划分。Java自身提供了3种类加载器:
- 启动类加载器(Bootstrap ClassLoader):属于虚拟机自身的一部分,用C++实现,负责加载
<JAVA_HOME>\lib
目录中或被-Xbootclasspath指定的路径中的并且文件名是被虚拟机识别的文件。自己放进去的类无法被加载。 - 扩展类加载器(Extension ClassLoader):java实现,独立于虚拟机。主要负责加载
<JAVA_HOME>\lib\ext
目录中或被java.ext.dirs系统变量所指定的路径的类库。 - 应用程序类加载器(Application ClassLoader):java实现,独立于虚拟机。主要负责加载用户类路径(classPath)上的类库,如果我们没有实现自定义的类加载器,那它就是我们程序中的默认加载器。
上图就类加载的双亲委派模型。如果一个类加载器需要加载类,那么首先它会把这个类请求委派给父类加载器去完成,每一层都是如此。一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。这里的双亲其实就指的是父类,没有mother。双亲委派的实现及其简单:
1 |
|
双亲委派机制使得类之间具有了优先级的层次关系,可以保证java体系的基础行为的正确性。比如,类java.lang.Object,它存放于<JAVA_HOME>\lib
的rt.jar之中,无论哪一个加载器加载该类,最后都是委派给Bootstrap ClassLoader加载,因此Object类在程序的各个加载环境之中都是同一个类。如果不遵循双亲委派机制,由各个加载器自行加载,如果有不法分子自行构造java.lang.Object类,嵌入了危害代码,那么构造的Object类会被加载进虚拟机,会导致虚拟机中各个Object类不一致,甚至会导致整个应用程序崩溃,因此为了保证程序的争取运行,推荐使用双亲委派机制。双亲委派机制中,自行编写的基础类,可以被编译,但无法被加载运行。
4.1 破坏双亲委派机制
双亲委派模型不是一种强制性约束,也就是你不这么做也不会报错怎样的,它是一种JAVA设计者推荐使用类加载器的方式。有时也可能违背双亲委派机制,比如JDBC。
我们知道SPI,它不同于API,是由各个厂商实现的,java只需要定义SPI的标准,mysql有mysql的jdbc实现,oracle有oracle的jdbc实现,只需遵循标准,java就能调动数据库。JDBC的实现必须违背双亲委派机制,因为遵循的话Boostrap ClassLoader无法自行加载各个厂商的类,只能委托子类来加载厂商提供的具体实现。为了解决这个问题,java设计团队引入了线程上下文类加载器(Thread Context ClassLoader)。
这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法进行设置,如果线程创建时还未设置,会从父类线程中继承一个,如果应用程序的全局范围内都没有设置过,这个类加载默认就是应用程序类加载器。java中涉及到SPI的加载基本都是使用该方式完成,比如JDBC、JNDI、JCE、JAXB和SPA等。但这种方式不太优雅,jdk6提供了java.util.ServiceLoader类,结合META-INF/services中的配置信息,辅以责任链模式来解决。
再比如商家对动态部署的需求,比如OSGI的模块坏热部署,使得双亲委派机制被打破。OSGI热部署的关键是它自定义的类加载器实现,每一个程序模块都有自己的类加载器,当需要更坏模块时,把模块和类加载器一起换掉以实现代码的热替换。
5、Java模块化系统
为了实现可配置的封装隔离机制,JDK引入了Java模块化系统(Java Platform Module System,JPMS)。java虚拟机对类加载架构也做了响应的变动调整。Java模块包括以下内容(类似于modejs的包的概念):
- 代码的容器
- 依赖其他模块的列表
- 导出的包列表,即其他模块可以使用的列表
- 开放的包列表,即其他模块可反射访问模块的列表
- 使用的服务列表
- 提供服务的实现列表
模块化之前,类加载主要依靠类路径,如果类路径中缺失了依赖的类型,那么只能等到程序运行到该类型的加载、链接时才会报出运行时异常。JDK9之后,如果启用了模块化封装,模块可以申明对其他模块的显示依赖,这样虚拟机就能够在启动时验证程序运行需要的依赖关系在运行期是否完备,如果缺失则启动失败,这样可以避免大部分的由依赖引发的运行时异常。
模块化优点具体的JDK9的模块化系统可以读读其他文章。
5.1 模块的兼容
java里面有“类路径(ClassPath)”的概念,JDK9为了兼容,提出了“模块路径(ModulePath)”的概念。
某个类库到底是模块还是传统的jar包,取决于它存放在哪种路径上。只要是处于类路径上的,即便包含了模块信息也会被当做传统jar包对待。而放在模块路径上的包,即使不包含JMOD后缀,不包含module-info.class文件,也会被当做模块来对待。
JPMS的向下兼容规则:
- jar文件在类路径的访问规则:所有类路径下的jar文件及其他资源,被视为自动打包在一个匿名模块中(Unamed Module)里,匿名模块没有任何隔离,它可以看到和使用类路径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块导出的包。
- 模块在模块路径访问规则:具名模块(Named Module)只能访问它列明的模块和包。不可访问匿名模块,即具名模块看不见传统jar内容。
- jar文件在模块路径的访问规则:传统的jar包放到模块路径下,它会变成一个自动模块(Automatic Module)。不包含module-info.class文件,但默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,也默认导出自己所有的包。
5.2 模块下的类加载器
模块化之后,扩展类加载器被平台类加载器取代。平台类加载器之后依然是自定义类加载器(图中未画出)。模块化之后整个java类库都满足可扩展的需求,因此无需再保留<java_home>\lib/ext
目录,
平台类或者程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如可以就优先委派给负责那个模块的加载器。
在 Java 模块化系统明确规定了三个类加载器负责各自加载的模块。
启动类加载器负责加载的模块:
1 |
|
平台类加载负责加载的模块:
1 |
|
应用程序类加载器负责加载的模块:
1 |
|
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!