0%

java类加载全过程详解

[toc]


关于类初始化的时机和误区#

书籍的第一步部分上来就先讲了类初始化的时机,整理成图片如下:
image.png
看起来非常多,很难记住,很折磨。

个人认为,书籍把这一部分放到章节的最前面不太合理,曾经一度让我把上面的这些事件,理解成了类加载的时机,也不懂这些规则的缘由(根本原因还是此时读者对类加载的理解不够深。)

先贴一下类加载和类初始化的区别:

  • 类加载概念:将class文件加载到jvm中并生成class对象,并根据情况做初始化。
  • 类初始化概念:调用类class文件中默认存在的<cinit>类初始化方法。

而我们容易产生误解的原因,是因为书中没有这句话::所谓的类初始化时机,只是针对cinit类初始化方法的调用,并不是指的类加载时机!

以上图中红色的部分为例:
image.png
这里书籍中没有解释这3个规则的原因,在没理解原理前,强行记忆这3条是没有任何意义的。我认为是作者的失误。

在这里我挑其中一个做补充:
“使用类里的static final 常量,不会触发初始化”
想要理解这个规则,需要先理解class文件原理。
对于类的static final常量字段,它的常量值是存放在字段的constanValue属性中。
image.png
正因为如此,static final常量并不需要通过cinit方法中的指令来完成赋值。
所以也就没有必要在这时候调用<cinit>方法了。

因此对于“儿子类调用父类的静态成员,不用对儿子类做类初始化”也是一个道理,儿子类的类静态成员没有被使用到,没必要做cinit。

对于上面的分析,可以浓缩为一句话:
“如果我们急需使用static成员,且这个成员的值是要通过cinit方法赋值的,那么我们才做cinit初始化”

新的疑问:那为什么仅仅是new一个对象时,也一定要做cinit类初始化呢?
假设此时我还没用到static成员,那么new一个对象时,是否可以省去cinit,等用到静态成员的时候,再去触发cinit?

这涉及到了类初始化的另一个容易被忽视的点:“cinit类初始化方法,并不仅仅是做类成员的赋值,其实还可能包含一些初始化行为调用”,这可以是资源的启动或者加载等类对象必须要用到的内容。

因此在一切可能触发类对象实际行为前,必须触发cinit避免出错。

所以刚才的长篇大论,可以再次进行优化,浓缩为:
“当需要用到static成员的初始赋值,或者对类对象进行正式使用时,才会触发cinit类初始化,目的是为了保证类对象或者类成员的正确使用”
拿着这一句话,去回看前面的类初始化时机的触发时机和不触发的时机时,相信你就会有更深的理解了,甚至也不需要强行去记忆每一条规则了。


有误导的“加载三部曲”#

有一个很经典的回答,叫做类加载三部曲:加载、连接、初始化
好像类加载过程就是这三步按照顺序串行拼装起来的。

实际上这3个过程是存在交叉的!
只能说,“最早发生”的时机,是按照这个顺序发生,但是中间加载过程是有很多的,具体后面会结合我画的图以及原理解释进行呈现。

加载:不仅仅是读取字节流#

image.png
对于加载,很容易只理解成只是“从文件里加载二进制字节到内存”。
这个过程显然是必须最先执行的,否则连类的基本信息都获取不到。
image.png
可以看到这个过程很灵活,只要你从你能想到的地方拿到字节流即可,任意形式都行。

然而,对于“加载”,除了获取字节流,实际上还包含了“把字节流转成方法区里的数据结构,进行存储defineClass”、“生成一个class对象,存储在堆中”这两步。

这2步是穿插在连接过程中的。
比如字节流转数据结构的过程,必须在确认字节流的正确性之后完成。
而生成class对象同理,符合一个class对象的条件时,才能将其在堆中生成。
image.png
image.png

加载过程是由类加载器classloader完成的,在这里对classLoader也顺便做一个详细的分析。

类加载器#

双亲委派#

类加载时的双亲委派模型,反正就记得优先去父类加载器中看类是否能加载。

这个过程和多态方法调用是相反的,多态方法是子类覆写了的话则优先子类调用,类加载则是父加载器能加载则加载。

注意:Bootsrap不是ClassLoader的子类,他是C++编写的。
而ExtClassLoader和AppClassLoader都是继承自ClassLoader的

a4c77999a2f8a0d5bc6401d05efeca9cdcecef43

双亲委派的详细执行过程和中间方法#

loaderClass(className) 双亲加载实现(这里会体现先去父亲找,再自己)注意,jdk1.2之后不提倡覆盖loadClass方法,这个方法可以理解为一个模板方法

但如果确实有需要破坏双亲委派的需求,则可以重写loaderClass方法,解除双亲委派机制

findClass() 如何根据名字,寻找并生成1个class(内部需要借助defineClass)
defineClass() 通过这个方法将字节码生成1个class类,基本不用改动。

例如需要根据类目,从某个远端网络加载获取这个类, 而且获取过来的时候还是加密的,需要在findClass里对byte数组做解密,解密完成,再调用defineClass生成class类。

https://blog.csdn.net/zzti_erlie/article/details/82757435

双亲委派的好处#

书中提到的原因就一个: java类随着类加载器,具备了带有优先级的层次关系。
保证了例如object类在每个环境里都是同一种,不会出现混乱。

父加载器中要加载某个类A时,A需要使用子加载器来加载,但是父加载器没有子加载器的代码,怎么办?#

可以使用线程上下文TCCL机制, 例如java的JNDI服务,JNDI是在启动类加载器里入加载的(JDK1.3的rt.jar), 但是JNDI会加载很多扩展性很强的新资源类。

因此可以在JNDI加载类的过程中,从TCCL这个context对象中,拿到set进去的用户加载器,然后进行加载即可。(JDBC、JBI等SPI机制都是如此)

OSGI网状类加载器#

OSGI中, 每一个程序模块(bundle)都有一个自己的类加载器,当需要更换一个bundle时,就把bundle连同类加载器一起换掉,实现代码的热替换

它是一个网状的类加载结构,只有java.*以及委派名单的,才会用双亲委派机制,否则都是各种网状的加载。加载过程如下所示:

OSGI加载

为什么OSGI可以实现热部署,但是双亲委派不可以?#

首先,理解双亲委派模型下,不能热部署的原因:
如果有新升级的同名类要加入,它只能在新给出的一个加载器去加载, 但是双亲模型限定了必须是先交给父加载器加载,这导致了升级困难,无法让新的加载器去做抢先加载。

OSGi类加载模型则是网络图状的,可以在bundle间互相委托
例如bundleA、B都依赖于bundleC,当他们访问bundleC中的类时,就会委托给bundleC的类加载器,由它来查找类;如果它发现还要依赖bundleE中的类,就会再委托给bundleE的类加载器。

另一篇文章的解释:

  • 由于类加载机制的原因,导致一个类一旦加载进去就再也无法释放,因此,OSGi引入了基于插件的类加载机制
  • 举例说明:plugin1里有examples.Test1类,而pulgin2里也有examples.Test1类,在载入这两个插件时,两个类是可以同时载入进入到类缓存中,这归功于OSGi实现的插件类加载器(ClassLoader)
  • 我们把“examples.Test”服务的 Service Ranking 属性更改成了 100,那么其他bundle使用这个bundle对应的example类时,就会用优先级最高的类。
  • 在不需要停止服务和其他插件都不用更新的情况下,我们只需要再安装一个更新版本的插件,其所注册的服务就可以自动更新并应用到所有调用该插件的插件中,达到了热部署的目的。
    OSGi的热部署特性及实现

数组类是如何加载的?#

数组类是由AppClassLoader加载的。
数组类打印className时,前面会有个[Lxx类
二维数组就是[[Lxxx类
数组类的父类型是Object

注意此时加载的是数组类,而数组类里面的对象是不会做自动加载的
因此xx类的静态代码并不会被直接调用

连接#

连接过程可以说是最难记住的一个过程, 里面包含了各种校验啊之类的,让人摸不清头脑。这里会通过更细致的解释和图解,让你明白连接过程究竟做了什么。
首先连接过程分为 验证、准备和解析,“解析”并不是连接的最后一步,而是在验证过程中实时发生的!。 下文会为你详细解释为什么。

验证#

文件格式校验(class文件对不对)#

image.png
注意这里的校验,都是一些最简单的校验,相当于无需做太多的语法分析操作等操作, 都是基于class文件格式定义进行的基础校验。

然而如果对加载的文件有充分的自信,来源可靠,那么确实可以省去这个步骤,提升连接效率,因此会有一个-Xverify:none的选项供使用。

元数据验证(我的父亲对不对)#

这里验证了class文件里面继承特性相关的重要信息,例如继承关系是否合理、是否实现了抽象类或接口的方法
image.png

注意,这个元数据验证的过程,会触发父类或者接口的解析(加载)操作!
书上提到了4个解析情况以及流程:

  • 类解析
  • 字段解析
  • 类方法解析
  • 接口方法解析
    却没有解释这4个解析过程是在哪里发生的。后面我会逐一提到,来真正理解这4个解析过程。

元数据验证中的类解析#

还记得class文件中,父类是指向一个constant_class_info吗?这个东西当时看就是一个utf字符串,没什么意义。你没法知道父类究竟有什么方法,是不是抽象类。
因此必须拿到父类的类信息,要么是已经在方法区中,要么需要重新加载。
而类解析的过程如下:
image.png
可以看到这个过程中也会发生加载,甚至好多次加载。


字节码验证(我的指令对不对)#

image.png
这个验证不要和前面的“文件格式验证”搞混了。
前面的“元数据验证”都只是针对类、方法、字段等和父类进行确认、校验。
但是还没有涉及到每个方法里的code属性。

code属性虽然在编译出来时是正确的,但是无法保证传输过程中被人篡改。
如果发生操作操作数栈时,栈里没东西,或者试图在局部变量表边界外写入局部变量,就可能导致不可估量的后果。

因此此刻会进行最基本的指令分析,确认对操作数栈、局部变量表的操作是安全、正确的。

但是,逐个指令分析,会不会太慢了?如果代码很长的话。

还记得class文件的code属性中,还包含了一个stackMapTable属性么,估计很多人都跳过了这个属性。
image.png
这个属性就是用在字节码验证这个过程,可以立即让编译器编译出class时,提前把各位置的情况写入stackMap中,jvm加载时只对这个stackMap做校验确认是对的即可。
但代价就是可能不安全了,因为这个stackMap是可以被篡改的。

符号引用验证(我的指令调用的目标对不对)#

注意前面的“字节码验证”是简单的确认,但不会持有过多的其他类的信息。
但是方法肯定会涉及对其他类的调用。

image.png

此时就会涉及到符号引用验证,确认自己是否拥有对方方法的访问权限。
那么你就需要找到目标类的类信息存放地址,确认方法权限,或者字段权限。
于是会在这里触发字段解析、类方法解析或者接口解析!
image.png
书上只提到了这3个解析过程的流程,却没有详细解释其中的一些缘由,我会做更详细的补充。

符号引用验证中的字段解析#

class中的constant_filed_info终于露出了它的真面目,原来是用在这个地方,即和字段相关的指令会用到它,并通过字段符号引用, 解析到这个字段真正的定义位置。
image.png
像经常遇到的NoSuchFieldError报错,就是在这个过程中爆出来的。
而且接口字段的优先级是大于父类的字段的。

符号引用验证中的类方法解析#

当调用方法前,需要先确认对象方法是否有权限访问。那么就必须这个类的信息进行确认。
注意:这个过程并不是动态分派的那个过程,此刻并没有触发任何的方法调用!仅仅是确认代码中静态类型的访问权限是否正确之类的!
image.png

  • 对类方法做解析的时候,会判断此时是类还是接口。如果是接口,竟然会报“IncompatibleClassChangeError”。
  • 还有如果是抽象类,也会报“AbstractMethodError”,因为正常情况下,你的jvm指令调用的方法,必须是实例化的对象所对应的方法,不可能直接调用抽象类方法的。

符号引用验证中的接口方法解析#

看起来像是将类方法解析中的接口和方法互换了位置。
image.png

疑问1:为什么接口方法还要解析?接口里不是没有代码吗?#

因为接口类里每个interface方法,本身也是一个方法,只不过没有详细的code属性。但方法的访问修饰符之类的都存在,因此验证阶段还是需要进行校验。

疑问2:为什么要区分类的方法和接口方法?不能用同一种思路去解析么?#

我理解的几个原因:

  1. 向上搜索时的逻辑不同,对于类方法,直接找父类即可, 而接口则需要遍历所有父接口。而且类方法还要考虑抽象类的问题,接口不需要。
  2. 类方法和接口方法本身就是两个不同的符号引用, 一个是constant_method_ref,另一个是constant_interface_ref,用2套逻辑没什么毛病
  3. 如果硬要问为什么要区分这2个符号引用,明明内容都是类索引+描述符索引?
    这是因为后面在实际调用方法时,二者有显著区别,具体见下文的“方法表的准备”。

准备#

image.png

类静态成员默认值的准备#

对于准备阶段,大家一般只记得需要对一些非final的类静态成员做默认初始值操作。

方法表的准备#

除了这个默认值赋值,还有一个动作,是准备方法表。
方法表就是为了多态而生,简化动态分派时频繁的迭代循环带来的不必要消耗:
image.png
通过前面的验证过程,我们已经获知了父类信息。
因此可以准备一个方法表,把父类方法堆到最前面,自己的方法堆到后面,后面直接根据索引获取方法调用地址即可!

重要问题:interface的接口方法,会有方法表吗?#

intefacer接口是不具有方法表的!
因此这可能也是jvm特地区分了class_inteface_info和class_method_info这2个常量,以及特地用invoke_inteface和invoke_virtual指令来区分2类方法的调用。因为他们的调用逻辑可能大相径庭。

为什么接口不能有方法表?#

这是由于Java可以实现多个接口,不同的类可能会实现了多个或者不同的接口,在虚表里该接口所实现方法的索引会不一致。

假设有A、B、C三个接口类

  • 类X实现了A、B两个接口,假设A和B接口放在虚表里,那么调用A接口方法我们假设它是在t位置。
  • 类T实现了B、C、A接口,按照实现顺序,先放B的方法,再放A的方法,最后放C的方法。这样调用接口A时,就不一定是t位置了,我们无法直接确定A里面方法的位置,因为一个类可以实现多个接口,而且顺序可以随意更改!

这样每次解析的虚表索引都可能会不同,因此不能进行缓存,需要每次都进行重新的解析。
因此,接口的方法调用会比普通的子类继承的虚函数调用要慢。

java的虚表和C++的虚表有什么区别?#

C++:
当编译器遇到调用虚方法的代码时,是通过vtable指针以及对应方法在虚表里的offset,然后获取对应的函数指针实现的,由于offset在编译过程就已经固定了,这样在执行过程中几乎没有产生任何额外的计算就实现了多态调用,效率相当高。
缺点就是当你修改了一个dll链接文件,另一个dll链接文件可能还是用的老的偏移,这导致你即使重启程序了,仍然还是错误的调用。 你必须将两个链接文件都重新编译才可以。

但对于java而言,只需要替换一个jar包即可, 类之间的方法调用关系,方法偏移,都是可以类加载过程中去生成的。

换言之,最大的区别就是修改部分方法带来的影响,java是最小的

java虚表的生成过程#

  1. 在加载该类的时候,常量池的所有虚函数的签名(包括调用的以及自身定义的)都会添加到全局的符号表(事实上是一个HashTable)。
  2. 首先对字符值进行Hash值计算,然后在全局HashTable进行查找,如果发现已经存在对应的Hash值,则返回对应的符号指针Symbol *,否则创建新的Symbol并添加到HashTable中,然后返回新创建的Symbol *。这样常量池就把字符串的引用转换成符号的引用。另外这个过程可以确保所有字符串在jvm只存有一个引用。、
  3. 当在某个类对象调用虚方法的时候,通过调用函数的符号和自身定义的符号进行比较(由于这里都是引用全局符号表的唯一符号,因此可以通过内存地址进行快速比较),就会解析出调用虚函数的信息,通过信息就可以获取虚表的索引,然后调用对应的虚函数字节码
  4. 为了提高调用时的性能,Java采用的是Lazy解析,第一次解析出虚表的索引后,则会保留到cache里面,这样下次调用就可以从缓存直接获取索引

解析#

解析的误区:并不是一个单纯的阶段#

解析其实分为“静态解析”和“动态解析”。
因此将解析说成是“连接”中的一部分是不严谨的, 只有静态解析,才是“连接”的一部分。

静态解析在初始化前发生,但动态解析则可能在初始化或者初始化之后才去使用。

静态解析#

静态解析用于解析私有方法、父类构造器、final方法等不存在多态可能的方法。
image.png

解析和分派的区别#

静态分派、动态分派, 指的是2个方法的不同阶段,他们不存在冲突的关系,即方法会先触发静态分派,再触发动态分派。

静态分派可以理解为编译器在编写class文件时,通过方法名+描述符+优先级,确定了这个位置调用的是哪个方法。

但是由于多态的特性,具体执行者可能不同,因此后面还会触发动态分派。

而静态解析和动态解析是2个不同的解析,前者是默认定死了方法引用位置,后者则必须依赖动态分派,对一个方法而言不可能同时存在动态和静态解析的情况。

初始化#

cinit方法细节解析#

image.png
关于初始化时机的解释,在开头就已经阐述过了,这里不再重复解释。

疑问1:cinit方法中的代码是如何生成的?#

cinit方法 是编译器收集所有类静态变量的赋值动作和静态语句块static{}中的语句合并产生,按照顺序收集。
因此类加载赋值的顺序和类定义顺序有关,原理就取决于cinit生成的原理。

疑问2:cinit类初始化是线程安全的吗?#

是线程安全的,虚拟机会保证一个类的加载和cinit方法会被正确的加锁、同步。
因此多线程场景下,同时使用一个之前没初始化过的类,且类初始化过程耗时非常久的话, 且可能会造成线程阻塞。
而这也是可以利用类初始化+内部类的方式,来做单例模式的实现的原理:

初始化中的动态解析#

而初始化过程中,可能会涉及其他对象实例方法的调用,因此是可能发生动态解析过程的!
类方法和接口方法的解析过程如下
类方法的解析可以借助虚方法表简化解析过程。
image.png

扩展:invoke_dynamic是什么#

动态语言和静态语言的区别#

动态类型语言: 类型检查的主体是运行期而不是编译器。 例如PHP、Lua\python
而静态类型语言就是编译器将类型都检查完,比如C++、java

静态语言的好处:在编译器就能确定类型,可以进行严谨的类型检查, 代价就是代码会很臃肿。
动态语言编写时更为随意,可以快速开发和运行。

java的MethodHandle用法#

jdk1.7之后提供的 MethodHandle, 类似于C/C++里的函数指针, 或者C#里的delegate。
C里面可以
sort(list, size, int (* compare)(int, int))
即传入一个函数指针,这个函数是哪个类调用的?不知道

java提供了methodHandle用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public static class ClassA {
public void println(String s) {
System.out.println("this is a print:" + s);
}
}

public static MethodHandle getPrintlnMethodHandle(Object object) throws NoSuchMethodException, IllegalAccessException {
// 返回值,入参
MethodType methodType = MethodType.methodType(void.class, String.class);
return MethodHandles.lookup()
// 找到对象所对应的类,确认是否存在方法
.findVirtual(object.getClass(), "println", methodType)
// 这个对对象绑定上去调用. java的反射不是也能做到吗》?
.bindTo(object);
}

public static void main(String[] args) throws Throwable {
Solution solution = new Solution();
ClassA classA = new ClassA();
// invokeExact:执行并传入参数,classA和System.out不是同一个父类或者接口,但是可以执行相同的方法
getPrintlnMethodHandle(classA).invokeExact("afdsafds");
getPrintlnMethodHandle(System.out).invokeExact("afdsafds");
// 以后排序就可以这样做了: sort(list, MethodHandle mt)
}
}

其中methodHandle背后就是由invoke_dynamic指令触发的。

methodHandle典型应用场景:如何调用爷爷类的虚方法#

如何在儿子类中,分派调用祖父类的虚方法? 且儿子类和父类都已经实现了这个虚方法。父类不能提供新的方法

通过loopup().findSpecial(祖父类, “方法名”, MethodHandle.methodType(返回值,入参), 当前类)
拿到一个方法句柄mh
然后方法句柄mh.invoke(this),即可调用祖父类的方法。

java的methodHandle机制和reflection的反射有什么区别吗?#

  1. 调用指令层面:
  • reflection是模拟java代码调用,不关心底层指令
  • 而methodHandle是模拟了字节码的执行,上面的Loopup().findVirtual,等同于invokevirtual指令(同理还有invokestatic\invokeinteface\invokespecial)
  1. method对象大小问题
  • reflection返回的method对象,包含的信息更多,例如签名、描述、属性等,返回的method比较重量级
  • 而methodHandle仅包含执行方法相关的信息,是轻量级、
  1. 可以基于methodHandle手动做虚拟机的相关调用优化(例如内联),而反射无法实现。
  2. 从最终设计目的而言,反射只针对java, 而methodHandle的核心目的在于可以将其他无类型的语言运行在java迅即之上!

invoke_dynamic指令原理。是否涉及动态分派、类加载和解析?#

我们首先看下invoke_dynamic指令调用的dynamic_info常量长什么样的:
image.png
可以看到它只包含了一个方法索引和描述,但似乎没包含方法属于哪个类。

它的作用是用java实现一些类似于脚本语言的逻辑,脚本语言不关心静态类型,不做编译检查,只关心运行期的内容。所以invoke_dynamic以及constant_dynamic_info应运而生。但书本和工作中对这块的接触都不是太深,因此我的理解也只能局限于此了。

书上还有句话可以记一下:除了invokeddynamic动态调用指令, 其他的invokevirual之类的,都会缓存解析结果。


最后的完整大图#

在线地址
https://www.processon.com/view/link/5e7eed6ce4b0ffc4ad43fda8

完整大图