深入理解 JVM 类加载机制
Java代码在编译过后,想要被运行和使用,经过的第一个步骤就是将编译后的字节码文件加载的虚拟机,那虚拟机是如何把字节码文件加载到虚拟机的呢,接下来以一系列实例对这一流程作简要分析与介绍。
一、为什么需要类加载机制
Java源码经过编译后成为字节码(Byte Code)存储在Class文件中,在Class文件中包含的各类信息都需要加载到虚拟机后才能被运行和使用。
二、何为类加载机制
JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制。一个类型(类或者接口)从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期将会经历如下7个阶段。
其中,验证、准备、解析是哪个阶段统称为连接(Linking)。需要注意的是,加载、验证、准备、初始化和卸载这几个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化完成后再开始,这是为了支持Java语言的动态绑定特性。
动态绑定是指程序在运行期间判断所引用对象的实际类型,然后再确定具体是哪个实例对象的方法。来看一个最简单的例子,下面的代码中,只有在运行时才知道bird对象所引用实际类型是Pigeon。
还有一点需要强调:上述的顺序或者说生命周期是相对于一个类来说的,而对于整个应用,这些阶段通常是相互交叉地混合进行的,就比如上面的示例中,在Demo的运行过程中,才会激活Pigeon类的初始化阶段。
三、虚拟机在何时加载类
关于在什么情况下需要开始类加载的第一个阶段,《Java虚拟机规范》中并没有进行强制约束,留给虚拟机自由发挥。但对于初始化阶段,虚拟机规范则严格规定:当且仅当出现以下六种情况时,必须立即对类进行初始化,而加载、验证、准备自然需要在此之前进行。虚拟机规范中对这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。接下来我会通过多个实例来介绍这六种情况。
3.1 遇到指定指令时
在程序执行过程中,遇到 new、getstatic、putstatic、invokestatic这4条字节码执行时,如果类型没有初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型场景有:
3.1.1 new
这没什么好说的,使用new关键字创建对象,肯定会触发该类的初始化。
3.1.2 getstatic与putstatic
当访问某个类或接口的静态变量,或对该静态变量进行赋值时,会触发类的初始化。关于这一点,我会使用一系列的例子来说明。首先来看第一个例子:
执行后会输出:
同样地,如果直接给Bird.a进行赋值,也会触发Bird类的初始化:
执行后会输出:
接着再看下面的例子:
执行后会输出:
本例中,a不在是一个静态变量,而变成了一个常量,运行代码后发现,并没有触发Bird类的初始化流程。常量在编译阶段会存入到调用这个常量的方法所在类的常量池中。本质上,调用类并没有直接引用定义常量的类,因此并不会触发定义常量的类的初始化。即这里已经将常量a=2存入到Demo类的常量池中,这之后,Demo类与Bird类已经没有任何关系,甚至可以直接把Bird类生成的class文件删除,Demo仍然可以正常运行。使用javap
命令反编译一下字节码:
从反编译后的代码中可以看到:Bird.a
已经变成了助记符iconst_2(将int类型2推送至栈顶)
,和Bird类已经没有任何联系,这也从侧面证明,只有访问类的静态变量才会触发该类的初始化流程,而不是其他类型的变量。
关于Java助记符,如果将上面一个示例中的常量修改为不同的值,会生成不同的助记符,比如:
其中:
iconst_n:将int类型数字n推送至栈顶,n取值0~5
lconstn:将long类型数字n推送至栈顶,n取值0,1,类似的还有fconstn、dconst_n
bipush:将单字节的常量值(-128~127)推送至栈顶
sipush:将一个短整类型常量值(-32768~32767) 推送至栈顶
ldc:将int、float或String类型常量值从常量池中推送至栈顶
再看下一个实例:
执行后会输出:
在本例中,常量a
的值在编译时不能确定,需要进行方法调用,这种情况下,编译后会产生getstatic
指令,同样会触发类的初始化,所以才会输出bird init
。看下反编译字节码后的代码:
3.1.3 invokestatic
调用类的静态方法时,也会触发该类的初始化。比如:
执行后会输出:
通过本例可以证明,调用类的静态方法,确实会触发类的初始化。
3.2 反射调用时
使用java.lang.reflect
包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。来看下面的例子:
执行后输出结果:
本例中,调用ClassLoader方法load一个类,并不会触发该类的初始化,而使用反射包中的forName方法,则触发了类的初始化。
3.3 初始化子类时
当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。比如:
执行后输出:
本例中,在main方法调用Pigeon类的静态方法,最先初始化的是父类Bird,然后才是子类Pigeon。因此,在类初始化时,如果发现其父类并未初始化,则会先触发父类的初始化。
再看下一个例子,可以先猜猜运行结果:
执行后输出:
本例中,由于fly方法是定义在父类中,那么方法的拥有者就是父类,因而,使用Pigeno.fly()
并不是表示对子类的主动引用,而是表示对父类的主动引用,所以,只会触发父类的初始化。
3.4 遇到启动类时
当虚拟机启动时,如果一个类被标记为启动类(即:包含mian方法),虚拟机会先初始化这个主类。比如:
执行后输出:
3.5 实现带有默认方法的接口的类被初始化时
当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
由于接口中没有static{}
代码块,怎么判断一个接口是否初始化?来看下面这个例子:
执行后输出:
可知,接口确实已被初始化,如果把接口中的default
方法去掉,那么不会输出interface init
,即接口未被初始化,这里就不列代码了,大家可以自己去试试看。
3.6 使用JDK7新加入的动态语言支持时
当使用JDK7新加入的动态类型语言支持时,如果一个java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
简单点来说,即使当初次调用MethodHandle
实例时,如果其指向的方法所在类没有进行过初始化,则需要先触发其初始化。
在举例之前,有几个问题需要大家先弄清楚:
什么是动态类型语言,与Java等静态类型语言有什么不同?
MethodHandle使用方法和原理
如果直接使用MethodHandle编码,其功能与反射类似,那么其与反射有何区别?
首先,动态类型语言的关键特性是它的类型检查的主体过程是在运行期进行的,常见的语言比如:JavaScript、PHP、Python等,相对地,在编译器进行类型检查过程的语言,就是静态类型语言,比如Java和C#等。简单来说,对于动态类型语言,变量是没有类型的,变量的值才具有类型,在编译时,编译器最多只能确定方法的名称、参数、返回值这些,而不会去确认方法返回的具体类型以及参数类型。而Java等静态类型语言则不同,你定义了一个整型的变量x,那么x的值也只能是整型,而不能是其他的,编译器在编译过程中就会检查定义变量的类型与值的类型是否一致,不一致编译就不能通过。因此,「变量无类型而变量值才有类型」是动态类型语言的一个核心特征。
其次,关于MethodHandle
的使用方法和原理相关涉及到很多的示例代码,这里就列出来了,相关讨论可以参考:
JDK1.8下关于MethodHandle问题 - 开源中国
最后,关于MethodHandle与反射的区别,可以参考周志明著「深入理解Java虚拟机」第8.4.3小节,这里引用部分内容,方便理解。
1.Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。
2.反射中的Method对象包含了方法签名、描述符以及方法属性列表、执行权限等各种信息,而MethodHandle仅包含执行该方法的相关信息,通俗来讲:Reflection是重量级,而MethodHandle是轻量级。
总的来说,反射是为Java语言服务的,而MethodHandle则可为所有Java虚拟机上的语言提供服务。
最后,还是来看一个简单的示例:
在Pigeon类中,使用MethodHandle来调用Bird类中的静态方法fly,按照前面所述,初次调用MethodHandle
实例时,如果其指向的方法所在类没有进行过初始化,则需要先触发其初始化。所以,这里一定会执行Bird类中的静态代码块。而最终的运行结果也与我们预计的一致:
四、虚拟机如何加载类 - 类的加载过程
类的加载全过程包括:加载、验证、准备、解析和初始化5个阶段,是一个非常复杂的过程。这里仅对这五个流程做一个简要介绍,如果其中有需要注意的点,我会举例说明。
如果对这几个流程的细节很感兴趣的话,建议阅读:《Java虚拟机规范》,但个人建议,了解熟悉即可。
4.1 加载Loading
Loading是整个“类加载”过程的第一个阶段,这里插一句,我也不知道为什么要这样命名,希望你没有搞混。Loading阶段主要是找到类的class文件,并把文件中的二进制字节流读取到内存,然后在内存中创建一个java.lang.Class
对象。
在虚拟机规范中,对这一过程的规定并不明确,全靠虚拟机厂商自由发挥,比如,如果通过一个类来找到其二进制字节流都能玩出下面的花样来:
从本地文件系统中直接读取,比如使用IDE调试代码时,直接从电脑的磁盘上读取的class文件
从ZIP压缩包中读取,这也是JAR、WAR格式的基础
从网络中下载,典型的应用场景是Web Applet
运行时计算生成,最常见的场景就是动态代理
由其他文件生成,比如JSP文件生成对应的Class文件
从加密文件中获取,这是典型的防Class文件被反编译的保护措施
加载完成后,就进入连接阶段,但需要注意的是,加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序,也就是只有加载阶段开始后,才有可能进入连接阶段。
4.2 验证Verification
验证是连接阶段的首个步骤,其目的是确保被加载的类的正确性,即要确保加载的字节流信息要符合《Java虚拟机规范》的全部约束要求,确保这些信息被当做代码运行后不会危害虚拟机自身的安全。
其实,Java代码在编译过程中,已经做了很多安全检查工作,比如,不能将一个对象转型为它未实现的类型、不能使用未初始化的变量(赋值除外)、不能跳转到不存在的代码行等等。但JVM仍要对这些操作作验证,这是因为Class文件并不一定是由Java源码编译而来,甚至你都可以通过键盘自己敲出来。如果JVM不作校验的话,很可能就会因为加载了错误或有恶意的字节流而导致整个系统受到攻击或崩溃。所以,验证字节码也是JVM保护自身的一项必要措施。
整个验证阶段包含对文件格式、元数据、字节码、符号引用等信息的验证,在这里不再细说,有兴趣可以自行阅读Java虚拟机规范。在实际开发中,由于上线到生产环境的代码都经过严格测试,如果想在生产环境想要加快类加载时间,可以使用-Xverify:none
参数关闭大部分验证措施。
4.3 准备Preparation
这一阶段主要是为类的静态变量分配内存,并将其初始化为默认值。这里有两点需要注意:
仅为类的静态变量分配内存并初始化,并不包含实例变量
初始化为默认值,比如int为0,引用类型初始化为null
需要注意的是,准备阶段的主要目的并不是为了初始化,而是为了为静态变量分配内存,然后在填充一个初始值而已。就比如:
来看一个实例加深印象,可以先考虑一下运行结果。
其运行结果是
在准备阶段,counter1和counter2都被初始化为默认值,因此,在构造方法中自增后,它们的值都变为1,然后继续执行初始化,仅为counter2赋值为0,counter1的值不变。
如果你理解了这段代码,再看下面这个例子,想想会输出什么?
运行后输出:
counter2并没有任何变化,为什么counter1的值会变成2?其实是因为类在初始化的时候,是按照代码的顺序来的,就比如上面的示例中,为counter1复制以及执行构造方法都是在初始化阶段执行的,但谁先谁后呢?按照顺序来,因此,在执行构造方法时,counter1已经被赋值为1,执行自增后,自然就变为2了。
4.4 解析Resolution
解析阶段是将常量池类的符号引用替换为直接引用的过程。而解析的动作主要是针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符这7类符号引用进行的。
如果对符合引用和直接引用这两个改变有疑问的,可以参考:JVM里的符号引用如何存储? - 知乎,关于解析过程还是请大家阅读[Java虚拟机规范](《Java虚拟机规范》)。
4.5 初始化Initialization
类的初始化是类加载过程的最后一个步骤,直到这一个步骤,JVM才真正开始执行类中编写的Java代码。
前面也曾说到,在准备阶段,类变量已经根据系统要求赋值为初始零值,而在初始化阶段,则会根据代码的逻辑去初始化类变量和其它资源。
Java编译器在编译过程中,会自动收集类中所有类变量的赋值动作以及静态代码块,将其合并到类构造器<clinit>()
方法,编译器收集的顺序是由语句在源文件中出现的顺序决定的。
而初始化阶段就是执行<clinit>()
方法的过程。如果两个类存在父子关系,那么在执行子类的<client>()方法之前,会确保父类的<clinit>方法已执行完毕,因此,父类的静态代码块会优先于子类的静态代码块。我们前面举的很多例子,都可以证明这一初始化过程。
这里有一点需要特别强调,JVM会保证一个类的<clinit>()
方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()
方法,其它线程都需要等待,直到<clinit>()
方法执行完毕。如果在一个类的<clinit>()
方法中有耗时很长的操作,那么可能会造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。因此,在实际开发过程中,我们都会强调,不要在类的构造方法中加入过多的业务逻辑,甚至是一些非常耗时的操作。
结束
如果你有关注到本文的行文脉络的话,在分析类加载这一流程的时候,采用的是5W2H分析法,即:
WHY:为什么会有类加载机制?
WHAT:类加载机制指的什么?
WHEN:虚拟机在什么时候加载类?
HOW:虚拟机是如何加载类的?
5W2H分析法并不是一定要把这7个要素分析完,就比如本文,少了WHO、WHERE以及HOW MUCH。而这里的WHO,自然指的是虚拟机;而WHERE表示事件发生的地点,即JVM将class文件从磁盘加载到内存;最后的一个HOW,有时被称为HOW MUCH,有时又被称为HOW GOOD,但意义上差不太多,只是不同场景下的不同表述,比如分析解决方案时,这里的HOW MUCH可以表示需要投入的资源是多少?现在有哪些资源?还差什么?对自身有何影响?而HOW GOOD则可以表示还有没有更好的解决方案?以前的经验对此有何帮助?
在学习任何知识的时候,都可以通过5W2H法来分析理解,看你是否真的掌握。
深入理解JVM系列的第1篇,从目录阅读请移步:深入理解JVM系列文章目录
参考资料
周志明著;深入理解Java虚拟机(第三版);机械工业出版社;2019-12
版权声明: 本文为 InfoQ 作者【NORTH】的原创文章。
原文链接:【http://xie.infoq.cn/article/2143d2ccad9d9b5c5f9137145】。未经作者许可,禁止转载。
评论 (5 条评论)