jvm(2)类加载过程

下图描述的是jvm加载类过程的一个逻辑顺序,具体的执行顺序不一定如下图所示,不同版本的虚拟机有不同的实现方式,本文将按照逻辑顺序逐步分析类加载过程中都发生了什么。

加载流程

1 类加载时机

jvm规范中没有明确规定类“加载”的时机,但是规定了有且只有以下五种情况下需要jvm对类立即执行“初始化”操作:

  1. 虚拟机启动时,要先初始化主类(含main方法的类)
  2. 使用new语句创建某类的实例,引用某类的静态变量、调用某类的静态方法
  3. 使用java.lang.reflect包对某类进行反射调用的时候
  4. 初始化一个类,需要先初始化其父类
  5. 使用动态语言支持时,java.lang.invoke.MethodHandle实例最后的解析结果是REF_getstatic、REF_putstatic、REF_invokestatic的方法句柄,且该句柄对应的类没有初始化

以上5种场景的行为称为对类的“主动引用”,一定会触发类加载

除了以上5种方式,其余的类引用方式都称为”被动引用“,被动引用不会触发类的初始化,至于会不会触发类加载,jvm规范未给出明确说明,取决于虚拟机的具体实现。被动引用方式有:

  • 子类继承父类的静态变量,通过子类访问该变量时将不会初始化子类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class father{
    public static final S = "test";
    }
    public class son extend father{

    }
    public class Test{
    public static void main(String[] args){
    // 执行到这里不会初始化Son类
    System.out.println(Son.S);
    }
    }
  • new一个数组,数组元素类型不会被初始化

  • 访问一个类的常量 ?

2 类加载步骤

2.1 加载

目标:通过类加载器将class二进制流加载到jvm方法区,具体步骤如下

  1. 根据全限定名找到class二进制文件
  2. 将class二进制文件的静态存储结构转换成jvm方法区的运行时数据结构
  3. 在java堆中生成一个代表这个类的java.lang.Class对象,作为这个类方法区的访问入口。

class二进制流的获取方式:

  • 可以通过zip压缩文件获取,jar包war包
  • reflect反射,Class.forname()
  • 网络获取
  • .jsp生成
  • 数据库获取

加载阶段是类加载过程中唯一能让应用程序参与的步骤,应用程序可以根据应用场景选择不同的class二进制流获取方式,或自定义类加载器

2.2 验证

包含格式验证、元数据验证、字节码验证、符号引用验证

格式验证:检查class文件魔数、版本号等信息

元数据验证:

字节码验证:

符号引用验证:

2.3 准备

给类的静态变量分配内存,并初始化为默认值:

  • 如果静态变量是String类型或者基本类型,从类的属性表中获取ConstantValue属性,进行初始化赋值;
  • 如果静态变量是其他引用类型,将在后面的初始化阶段赋值。

2.4 解析

链接阶段的关键步骤,将class文件中的符号引用转换成直接引用

符号引用包含:(这里是常量池的知识)

  • 类的全限定描述符 com.pd.xxxx
  • 字段的描述符 c.a:t 表示c类的字段a,类型是t
  • 方法的描述符 c.func:()v 表示c类的方法func,参数为空,返回类型是void

符号引用是字节码文件对类信息的描述,按照惯例,人类能看懂的,机器都是不能直接使用的,所以需要有解析这一过程将其转换成jvm运行时能直接使用的直接引用

jvm通过直接引用能够直接获取类实例的成员,字段或方法入口。

2.4.1 字段的解析

获取字段在对象中的偏移量

在《jvm-对象的内存布局》一文中,我们了解到了对象的内存结构,实例字段的“偏移量”是从对象起始位置开始算的。对于这样的字节码:

1
2
getfield cp#12  // C.b:Z
//这里用cp#12来表示常量池的第12项的意思

这个C.b:Z的符号引用,最终就会被解析为类似+40这样的偏移量(这里只是举例,不是真的+40),外加一些VM自己用的元数据(字段类型、访问权限等信息)。

这个偏移量加上额外元数据比原本的constant pool index要宽,原来是u2两字节,因此没办法直接替换原来constant pool项,所以HotSpot VM有另外一个叫做constant pool cache的东西来存放它们。
在HotSpot VM里,上面的字节码经过解析后,就会变成:

1
2
fast_bgetfield cpc#5  // (offset: +40, type: boolean, ...)
//这里用cpc#5来表示constant pool cache的第5项的意思

于是解析后偏移量信息就记录在了constant pool cache里,getfield根据解析出来的constant pool cache entry里记录的类型信息被改写为对应类型的版本的字节码fast_bgetfield来避免以后每次都去解析一次,然后fast_bgetfield就可以根据偏移量信息以正确的类型来访问字段了。

2.4.2 静态变量的解析

从JDK 1.3到JDK 6的HotSpot VM,静态变量保存在类的元数据(InstanceKlass)的末尾。而从JDK 7开始的HotSpot VM,静态变量则是保存在类的Java镜像(java.lang.Class实例)的末尾。

在HotSpot VM中,对象、类的元数据(InstanceKlass)、类的Java镜像,三者之间的关系是这样的:

引用知乎大神RednaxelaFX文章内容,链接在文后。

1
2
3
4
5
6
7
Java object      InstanceKlass       Java mirror
[ _mark ] (java.lang.Class instance)
[ _klass ] --> [ ... ] <-\
[ fields ] [ _java_mirror ] --+> [ _mark ]
[ ... ] | [ _klass ]
| [ fields ]
\ [ klass ]

每个Java对象的对象头里,_klass字段会指向一个VM内部用来记录类的元数据用的InstanceKlass对象;InsanceKlass里有个_java_mirror字段,指向该类所对应的Java镜像——java.lang.Class实例。HotSpot VM会给Class对象注入一个隐藏字段“klass”,用于指回到其对应的InstanceKlass对象。这样,klass与mirror之间就有双向引用,可以来回导航。
这个模型里,java.lang.Class实例并不负责记录真正的类元数据,而只是对VM内部的InstanceKlass对象的一个包装供Java的反射访问用。

在JDK 6及之前的HotSpot VM里,静态字段依附在InstanceKlass对象的末尾;而在JDK 7开始的HotSpot VM里,静态字段依附在java.lang.Class对象的末尾。

假如有这样的A类:

1
2
3
class A {
static int value = 1;
}

那么在JDK 6或之前的HotSpot VM里:

1
2
3
4
5
6
7
Java object      InstanceKlass       Java mirror
[ _mark ] (java.lang.Class instance)
[ _klass ] --> [ ... ] <-\
[ fields ] [ _java_mirror ] --+> [ _mark ]
[ ... ] | [ _klass ]
[ A.value ] | [ fields ]
\ [ klass ]

可以看到这个A.value静态字段就在InstanceKlass对象的末尾存着了。

而在JDK 7或之后的HotSpot VM里:

1
2
3
4
5
6
7
8
Java object      InstanceKlass       Java mirror
[ _mark ] (java.lang.Class instance)
[ _klass ] --> [ ... ] <-\
[ fields ] [ _java_mirror ] --+> [ _mark ]
[ ... ] | [ _klass ]
| [ fields ]
\ [ klass ]
[ A.value ]

可以看到这个A.value静态字段就在java.lang.Class对象的末尾存着了。

方法的解析:

要弄清楚方法是怎解析的,首选需要了解jvm中的虚方法表vtable :

  1. vtable 是 Java 实现多态的基石
  2. Java 子类会继承父类的 vtable。Java 所有的类都会继承 java.lang.Object 类,Object 类有五个虚方法可以被继承和重写。当一个类不包含任何方法时,vtable 的长度也最小为五,表示 Object 类的五个虚方法
  3. final 和 static 修饰的方法不会被放到 vtable 方法表里 ,private方法也不会放到vtable中
  4. 当子类重写了父类方法,子类 vtable 原本指向父类的方法指针会被替换为子类的方法指针—-多态
  5. 子类的 vtable 保持了父类的 vtable 的顺序

vtable的位置:vtable属于类的元数据,存放在元空间,具体位置是在instanceKlass对象实例的尾部,而instanceKlass大小在64 位系统的大小为 0x1B8,因此 vtable 的起始地址等于 instanceKlass 的内存首地址加上 0x1B8。

使用HSDB查看虚方法表内容,最终得出的结果是

1
2
3
4
5
6
7
0x00000007c0060dd8: 0x000000001a120c10 //Object.finallize
0x00000007c0060de0: 0x000000001a1206e8 //Object.equal
0x00000007c0060de8: 0x000000001a120840 //Object.toString
0x00000007c0060df0: 0x000000001a120640 //Object.hashCode
0x00000007c0060df8: 0x000000001a120778 //Object.clone
0x00000007c0060e00: 0x000000001a5232f8
0x00000007c0060e08: 0x000000001a522f88

第一列是堆内存地址(无需关心),第二列则是对应方法的入口地址(8个字节)。

2.4.3 方法的解析

就是根据方法的描述符,在方法所在类的虚方法表中,获取到方法的入口指针。

考虑这样一个Java类:

1
2
3
4
5
6
7
public class X {
public void foo() {
bar();
}
public void bar() {
}
}

以上源码编译出来的结果如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
作者:RednaxelaFX
链接:https://www.zhihu.com/question/30300585/answer/51335493
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Classfile /private/tmp/X.class
Last modified Jun 13, 2015; size 372 bytes
MD5 checksum 8abb9cbb66266e8bc3f5eeb35c3cc4dd
Compiled from "X.java"
public class X
SourceFile: "X.java"
minor version: 0
major version: 51
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#16 // java/lang/Object."<init>":()V
#2 = Methodref #3.#17 // X.bar:()V
#3 = Class #18 // X
#4 = Class #19 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 LocalVariableTable
#10 = Utf8 this
#11 = Utf8 LX;
#12 = Utf8 foo
#13 = Utf8 bar
#14 = Utf8 SourceFile
#15 = Utf8 X.java
#16 = NameAndType #5:#6 // "<init>":()V
#17 = NameAndType #13:#6 // bar:()V
#18 = Utf8 X
#19 = Utf8 java/lang/Object
{
public X();
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LX;

public void foo();
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokevirtual #2 // Method bar:()V
4: return
LineNumberTable:
line 3: 0
line 4: 4
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LX;

public void bar();
flags: ACC_PUBLIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this LX;
}

HotSpot VM的实现略复杂,我们看个更简单的实现,Sun的元祖JVM——Sun JDK 1.0.2的32位x86上的做法。

Sun Classic VM:(以32位Sun JDK 1.0.2在x86上为例)

1
2
3
4
5
6
7
8
9
HObject             ClassObject
-4 [ hdr ]
--> +0 [ obj ] --> +0 [ ... fields ... ]
+4 [ methods ] \
\ methodtable ClassClass
> +0 [ classdescriptor ] --> +0 [ ... ]
+4 [ vtable[0] ] methodblock
+8 [ vtable[1] ] --> +0 [ ... ]
... [ vtable... ]

元祖JVM在做类加载的时候会把Class文件的各个部分分别解析(parse)为JVM的内部数据结构。例如说类的元数据记录在ClassClass结构体里,每个方法的元数据记录在各自的methodblock结构体里,等等。
在刚加载好一个类的时候,Class文件里的常量池和每个方法的字节码(Code属性)会被基本原样的拷贝到内存里先放着,也就是说仍然处于使用“符号引用”的状态;直到真的要被使用到的时候才会被解析(resolve)为直接引用。

假定我们要第一次执行到foo()方法里调用bar()方法的那条invokevirtual指令了。
此时JVM会发现该指令尚未被解析(resolve),所以会先去解析一下。
通过其操作数所记录的常量池下标0x0002,找到常量池项#2,发现该常量池项也尚未被解析(resolve),于是进一步去解析一下。
通过Methodref所记录的class_index找到类名,进一步找到被调用方法的类的ClassClass结构体;然后通过name_and_type_index找到方法名和方法描述符,再到ClassClass结构体上记录的方法列表里找到匹配的那个methodblock;最终把找到的methodblock的指针写回到常量池项#2里。

也就是说,原本常量池项#2在类加载后的运行时常量池里的内容跟Class文件里的一致,是:

1
[00 03] [00 11]

(tag被放到了别的地方;小细节:刚加载进来的时候数据仍然是按高位在前字节序存储的)而在解析后,假设找到的methodblock*是0x45762300,那么常量池项#2的内容会变为:

1
[00 23 76 45]

(解析后字节序使用x86原生使用的低位在前字节序(little-endian),为了后续使用方便)
这样,以后再查询到常量池项#2时,里面就不再是一个符号引用,而是一个能直接找到Java方法元数据的methodblock了。这里的methodblock就是一个“直接引用”。指向调用的方法的机器指令集合code属性

解析好常量池项#2之后回到invokevirtual指令的解析。

1
[B6] [00 02]

而在解析后,这块代码被改写为:

1
[D6] [06] [01]

其中opcode部分从invokevirtual改写为invokevirtual_quick,以表示该指令已经解析完毕。
虚方法表的下标(vtable index)
原本存储操作数的2字节空间现在分别存了2个1字节信息,第一个是,第二个是方法的参数个数。这两项信息都由前面解析常量池项#2得到的methodblock*读取而来。也就是:

1
invokevirtual_quick vtable_index=6, args_size=1

这里例子里,类X对应在JVM里的虚方法表会是这个样子的:

1
2
3
4
5
6
7
[0]: java.lang.Object.hashCode:()I
[1]: java.lang.Object.equals:(Ljava/lang/Object;)Z
[2]: java.lang.Object.clone:()Ljava/lang/Object;
[3]: java.lang.Object.toString:()Ljava/lang/String;
[4]: java.lang.Object.finalize:()V
[5]: X.foo:()V
[6]: X.bar:()V

所以JVM在执行invokevirtual_quick要调用X.bar()时,只要顺着对象引用查找到虚方法表,然后从中取出第6项的methodblock*,就可以找到实际应该调用的目标然后调用过去了。

假如类X还有子类Y,并且Y覆写了bar()方法,那么类Y的虚方法表就会像这样:

1
2
3
4
5
6
7
[0]: java.lang.Object.hashCode:()I
[1]: java.lang.Object.equals:(Ljava/lang/Object;)Z
[2]: java.lang.Object.clone:()Ljava/lang/Object;
[3]: java.lang.Object.toString:()Ljava/lang/String;
[4]: java.lang.Object.finalize:()V
[5]: X.foo:()V
[6]: Y.bar:()V

于是通过vtable_index=6就可以找到类Y所实现的bar()方法。

所以说在解析/改写后的invokevirtual_quick指令里,虚方法表下标(vtable index)也是一个“直接引用”的表现。

在现在的HotSpot VM里,围绕常量池、invokevirtual的解析(再次强调是resolve)的具体实现方式跟元祖JVM不一样,但是大体的思路还是相通的。

HotSpot VM的运行时常量池有ConstantPool和ConstantPoolCache两部分,有些类型的常量池项会直接在ConstantPool里解析,另一些会把解析的结果放到ConstantPoolCache里。

2.5 初始化

根据类中的静态代码块静态变量赋值语句生成()方法对类进行初始化

  • 不需要显示调用父类的()方法,在初始化子类之前或自动初始化父类
  • 接口没有静态代码块,但是也会根据静态变量赋值语句生成()方法进行初始化,注意这里指的是引用类型对象
  • 被类实现的接口或者接口的父接口,不会在子类的初始化之前初始化,接口的初始化时机是使用到该接口时

3. jvm启动运行一个main函数

  1. 根据JVM内存配置要求,为JVM申请特定大小的内存空间;
  2. 创建一个引导类加载器实例,初步加载系统类到内存方法区区域中;

  3. 创建JVM 启动器实例 Launcher,并取得类加载器ClassLoader;

  4. 使用上述获取的ClassLoader实例加载我们定义的 org.luanlouis.jvm.load.Main类;

  5. 加载完成时候JVM会执行Main类的main方法入口,执行Main类的main方法;

  6. 结束,java程序运行结束,JVM销毁。

参考文章:

《Java虚拟机原理图解》5. JVM类加载器机制与类加载过程

Hotspot 类加载、链接和初始化 C++源码解析

RednaxelaFX–字段解析

RednaxelaFX–方法解析