JVM总结——类文件相关
类文件内容
- 魔数
- 主次版本号
- 常量池
- 访问标志
- 类索引、父类索引与接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合
什么是属性表集合
字段表和方法表分别用于描述一个字段和一个方法,而它们当中都有一个属性表,属性表用于描述一些额外信息,比如对于常量字段来说,它可能包含一个指向常量池中的常量的引用,对于一个方法来说,它可能包含该方法的所有字节码指令。
所以,编译后方法中的字节码指令在类文件的属性表的Code
属性中。
常量池
常量池主要保存:
- 字面量:字符串字面量、final常量值等
- 符号引用:用到的包、全限定名、字段名称和描述符、方法名称和描述符等
为什么要有符号引用——Java和C/C++编译的区别
使用C/C++编译后得到可执行文件,所以它在编译时要将使用到的库全部织入到可执行文件中,并且将源码中对库函数调用的符号描述转换成库函数在内存中真实的地址,这个过程称为链接,经过链接之后得到的文件中已经建立了整体的内存布局信息。
Java在编译期并不执行链接操作,它只根据单一类生成Class文件,所以Class文件的常量池中保存了一些用到的其它库的符号引用,而当类加载时,JVM才会从这里拿出这些符号引用并进行解析,翻译成具体的内存地址。
Java的链接操作并不在编译期完成,而是在类加载之后,这称为动态链接,有点动态链接库的意思。
字节码指令
对于一个指令,并不是每种数据类型都有对应的版本,比如iload
用于int
类型,但并没有用于byte类型的bload
加载和存储指令:
用于将数据在栈帧中的局部变量表和操作数栈间来回传输。
- 局部变量表到操作数栈:
iload
、lload
、fload
、dload
、aload
以及对应的<T>load_<N>
版本 - 操作数栈到局部变量表:
istore
、lstore
... - 将常量加载到操作数栈:
bipush
、sipush
、ldc
、iconst_<i>
、lconst_<l>
... - 数组元素加载到操作数栈:
baload
、caload
- 操作数栈存储到数组元素:
bastore
、castore
iload_<n>
代表一组具有操作数的指令的特殊形式,可以省略掉操作数,如iload_0
代表操作数为0时的iload
指令,这样可以缩短指令长度。
运算指令:
用于将两个在操作数栈上的数据进行计算
byte
、short
、boolean
、char
在运算时都会使用int
类型的指令进行计算,这也是为什么Java中这些数据类型参与运算后就变成int
运算 | 指令 | 示例 |
---|---|---|
加法 | Tadd | iadd |
减法 | Tsub | lsub |
乘法 | Tmul | fmul |
除法 | Tdiv | ddiv |
求余 | Trem | irem |
取反 | Tneg | ineg |
位移 | Tshl/Tshr/Tushl/Tushr | lshr |
按位或 | Tor | ior |
按位与 | Tand | land |
按位异或 | Txor | ixor |
局部变量自增 | Tinc | iinc |
比较 | Tcmp/Tcmpg/Tcmpl | dcmpg |
对象创建访问指令:
- 创建对象:
new
- 创建数组:
newarray
、anewarray
、multianewarray
- 访问类字段和实例字段:
putfield
、getfield
、putstatic
、getstatic
- 取数组长度:
arraylength
- 检查实例类型:
instanceof
、checkcast
方法调用指令:
invokevirtual
:调用对象实例方法(虚方法分派)invokeinterface
:调用接口方法invokespecial
:调用特殊的实例方法,如实例初始化方法、私有方法、父类方法invokestatic
:调用静态方法invokedynamic
:动态调用
类型转换指令:
对于窄化类型转换(从大的转向小的),需要使用显式的类型转换指令
i2b、i2c、l2i....
同步指令:
moniterenter、moniterexit
控制转移指令:
ifeq、goto...
类加载
类加载过程
- 只有解析阶段可以延后到初始化后执行,剩下的顺序都是确定的
- 类何时被加载的时机在规范中没有定义,但是定义了类初始化的时机,也就是说在类初始化之前加载验证准备阶段必须完成。
加载阶段
- 通过一个名字获取类的字节码表示
- 将字节码表示的静态class结构转换为方法区的运行时数据结构
- 在内存中生成Class对象
所以,类被加载后,Class对象已经被生成了,但它还尚未初始化,所以我们对
class.getField
、class.getMethod
都不会初始化类,因为这些信息已经存在与Class对象中,而field.get()
才会实际触发类的初始化,执行<clinit>
,设置初始值。
验证
验证Class文件是否符合规范,是否具有可能破坏虚拟机安全的数据。比如:
- 文件格式:验证字节码是否符合Class文件的格式规范
- 元数据:对类的元数据进行语义分析,确保不存在与《Java语言规范》相悖的元数据信息
- 字节码:分析程序语义是否合法
- ...
准备
为静态变量分配内存并设置初始值
解析
虚拟机将常量池内的符号引用替换为直接引用的过程。
为什么说解析阶段可能发生在初始化后呢?因为有一些东西的解析确实不是类加载时就能完成的,这里要有一个概念,解析阶段并不是像类加载的其他阶段一样,针对一个类只做一次就完事儿了的,它是针对的是类中的每个符号引用,每一个符号引用都需要经历解析,而有的符号引用解析需要在运行时发生,比如虚方法的调用只有在运行时才知道具体调用哪个类的方法。
初始化
这个阶段调用类的初始化方法<clinit>
,也就是说静态变量的实际值会被赋予,static
代码块会被执行。对程序员来讲,类的创建刚刚开始,对虚拟机来讲,类的创建已经结束。
<clinit>
的调用会被虚拟机加锁,保证多线程互斥,所以它是安全的,但要注意不要在其中执行非常耗时的操作。
初始化时机
只有在下面的情况才会初始化类:
- 遇到
new
、getstatic
、putstatic
、invokestatic
时new
该类的对象- 操作类的静态变量(静态常量不会导致类加载)
- 调用静态方法
- 使用反射对类型进行调用
Class.forName
会初始化类,并且可以指定类加载器class.newInstance
会初始化类field.get()
会初始化类method.invoke()
也会初始化类- 但是获取属性、方法、类信息这些都不会初始化类
- 初始化子类之前会先初始化父类
- 虚拟机执行的主类会被虚拟机初始化
- 方法句柄,这个不太了解
- 一个接口的实现类初始化时,若接口具有默认方法,接口需要被先行初始化
注意,通过子类访问父类的静态变量时,子类并不会被初始化,初始化的是具有该变量的类
注意,new数组时数组的元素类型并不会被初始化
类加载器
在一个JVM中,两个类只有在它们是同一个类文件并且它们由同一个类加载器加载时才被JVM认为是同一个类,这也代表着一个JVM中可以有多个来自同一个类文件的类,只要它们不被同一个类加载器加载。
层级结构
类加载器是有层级结构的
- 启动类加载器:由C/C++编写,用于加载
<JAVA_HOME>/lib
目录下或被-Xbootclasspath
参数指定的路径下的,必须符合JVM要求的指定文件名(如rt.jar
)的类库 - 扩展类加载器:由Java编写,负责加载
<JAVA_HOME>/lib/ext
目录下或者java.ext.dirs
系统变量指定的路径下的类库。它用于允许官方和用户通过将SDK之外的通用类库添加到指定目录中以扩展Java功能。 - 应用类加载器:由Java编写,负责加载用户类路径(classpath)下的所有类库,由于是
ClassLoader.getSystemClassLoader
方法的返回值,所以也被称为系统类加载器。
ClassLoader通过持有父ClassLoader的实例来维护层级关系,所以上层ClassLoader感知不到下层的存在,也就造成了后面用于支持SPI的
Thread.getContextClassLoader
的出现
双亲委派模型
双亲委派模型是Java官方推荐的一种类加载的模型,它的过程如下:
- 判断类是否加载过,如果是直接返回
- 否则判断是否有父加载器,如果有,尝试让父加载器加载,否则尝试让
BootstrapClassLoader
加载 - 如果上面步骤中加载成功,则直接返回
- 否则,尝试自己加载
这个结构让JVM中的类结构变得稳定,不会模棱两可,如不会出现两个java.lang.Object
类。
为了不让每一个类加载器都自己实现双亲委派模型,Java的ClassLoader
类提供了loadClass
方法,里面就是双亲委派模型的完整实现:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
根据代码,我们可以发现:
- 如果你想实现一个符合双亲委派模型的类加载器,重写它的
findClass
方法,该方法会在父类加载器无法工作时调用 - 如果你想实现一个不符合双亲委派模型的类加载器,直接重写
loadClass
破坏双亲委派模型的几种情况:
- 构建自己的模块化系统、实现代码热替换等功能
- 使用JDBC、JNDI等SPI
字节码执行
栈帧
每一个方法调用的开始到结束都是一个栈帧入栈到出栈的过程
每一个方法都会有一些局部变量,栈要做的就是以各种方式操作它们,进行各种运算,并返回结果,所以,栈中要有:
- 局部变量表:记录栈中的局部变量
- 操作数栈:维持计算过程
- 方法返回地址:方法结束时返回到哪里
- 动态链接:指向运行时常量池中该栈帧所属方法的引用
局部变量表
Java在编译时就能确定一个方法需要多大的局部变量表,这个值存储在属性表的Code
属性中的max_locals
数据项中。
局部变量表中存放变量的最小单位是槽
,虚拟机规范中说每个槽都应该能够存放byte
、char
、short
、int
、float
、reference
或returnAddress
类型的数据,也就暗示着槽最小得有32位的大小,但也保留了设计者按照处理器、操作系统的不同实现更长的槽(比如64位)。
为了最小化栈消耗的空间,槽是可以被复用的,比如一个循环作用域中私有的变量所占用的槽稍后可能会被其它局部变量复用。
方法调用
方法调用分为两种方式——解析和分派。
解析
解析是指在类的解析过程中就能够确定方法调用的唯一版本的情况,此时,在解析阶段就可以将这部分符号引用转换为直接引用。
学过C++的朋友可能知道,不能在解析过程中确定实际调用方法是由于多态性所带来的,这种方法叫虚方法,而剩下的就是非虚方法。
使用解析的方法调用:
invokespecial
指令调用的方法:<clinit>
和<init>
,父类<init>
和私有方法invokestatic
指令调用的方法:静态方法invokevirtual
指令调用的final
方法,是一个由于历史原因遗留的意外
这些方法被统称为非虚方法,它们的特点就是在解析阶段,待调用的方法是确定的,不会存在在运行时才能确定的多个版本。
分派
静态分派
静态分派多用于方法重载,比如这种:
结果:
hello,guy!
hello,guy!
对于这种情况,选择哪个重载版本基于外观类型来决定,所以调用哪个版本的方法其实也是编译时就能确定的,这种静态分派依然是编译器做的,而不是JVM在运行时做的。正是由于这个原因,很多资料中也把静态分派归为解析的一类。
动态分派
动态分派用于方法重写,只要有多态的地方就有动态分派,如下图所示:
这种情况下,对于外观类型为Human
的两个对象的sayHello
的调用,实际调用到哪个对象上,是Human
对象还是子类对象,这是运行时才能确定的,想象你在编写一个类库,而实现类需要运行时指定,你的类库编译时都不知道具体由哪个实现类提供实现,那编译器怎么能在编译时就确定方法版本呢?
Java是静态单分派动态多分派的