类文件内容
- 魔数
- 主次版本号
- 常量池
- 访问标志
- 类索引、父类索引与接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合
什么是属性表集合
字段表和方法表分别用于描述一个字段和一个方法,而它们当中都有一个属性表,属性表用于描述一些额外信息,比如对于常量字段来说,它可能包含一个指向常量池中的常量的引用,对于一个方法来说,它可能包含该方法的所有字节码指令。
所以,编译后方法中的字节码指令在类文件的属性表的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语言规范》相悖的元数据信息
- 字节码:分析程序语义是否合法
- ...
准备
为静态变量分配内存并设置初始值
解析
虚拟机将常量池内的符号引用替换为直接引用,此时相当于传统编译器的链接过程执行完毕,所有的符号引用都被替换成了实际的地址,内存布局已经建立,而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