前言
参考资料:
《深入理解 Java 虚拟机 - JVM 高级特性与最佳实践》
第1部分主题为自动内存管理,以此延伸出 Java 内存区域与内存溢出、垃圾收集器与内存分配策略、参数配置与性能调优等相关内容;
第2部分主题为虚拟机执行子系统,以此延伸出 class 类文件结构、虚拟机类加载机制、虚拟机字节码执行引擎等相关内容;
第3部分主题为程序编译与代码优化,以此延伸出程序前后端编译优化、前端易用性优化、后端性能优化等相关内容;
第4部分主题为高效并发,以此延伸出 Java 内存模型、线程与协程、线程安全与锁优化等相关内容;
本系列学习笔记可看做《深入理解 Java 虚拟机 - JVM 高级特性与最佳实践》书籍的缩减版与总结版,想要了解细节请见纸质版书籍;
5. 类文件结构
5.1 无关性概述
- 实现语言无关性的基础是虚拟机和字节码存储格式;
- Java 虚拟机不和包括 Java 在内的任何语言绑定,它只与 class 文件这种特定的二进制文件格式所关联;
- Java 虚拟机不关心 class 的来源是何种语言。比如 Groovy、Scala 等语言都能产出符合规范的class文件;
- Java 虚拟机规范要求在 class 文件中使用许多强制性的语法和结构化约束;
5.2 Class 类文件结构
- class 文件是一组以
8位bit(1字节)为基础单位
的二进制流,各个数据项目严格按照顺序紧凑的排列在 class 文件之中,中间没有任何分隔符。当遇到需要占用 1 字节以上空间的数据项时,则会按照高位在前的方式分割成若干个 1 字节进行存储; - 包含两种数据类型:
- 无符号数:基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数。无符号数可以用来描述数字、索引引用、数量值或者字符串值;
- 表:由多个无符号数或者其他表作为数据项构成的复合数据类型。表用于描述有层次关系的复合结构的数据,整个 class 文件本质上就是一张表;
-
class 文件的数据项如下表:
5.3 class 文件的数据项
- u4 魔数(Magic Number):唯一的作用是确定这个文件是否为一个能被虚拟机接受的 class 文件,固定为 0xCAFEBABE;
- u2+u2 版本:虚拟机也必须拒绝执行超过其版本号的 class 文件;
-
u2+ 常量池:常量池容量计数器用来记录常量个数。常量池中主要存放两大类常量:
- 字面量:近于 Java 语言层面的常量概念,如文本字符串、final 修饰的常量值等;
- 符号引用:编译原理方面的概念,包括了:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。常量池中的每一项常量都是一个表。可以用 javap 分析 class 文件;
- u2 访问标记:用于标识一些类或者接口层次的访问信息;
- 4u2 类与接口索引集合*:由这 4 项数据确定类的继承关系;
- u2+ 字段表集合:用于描述接口或者类中声明的变量。包括类级变量和实例级变量,不包括在方法内部声明的局部变量;(public、static、final、volatile、transient 等)
- u2+ 方法表集合:类似上面字段表。方法里的 Java 代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为"Code"的属性里。方法调用指令以常量池中指向方法的符号引用作为参数;
- u2+ 属性表集合:不是单独的一部分,而是由 class 文件、字段表、方法表等携带,以描述某些场景专有的信息;
5.4 字节码指令
- 由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的0至多个所需参数(称为操作数,Operands)构成;
- 由于 Java 虚拟机采用面向操作数栈的架构,而不是寄存器,所以多大数的指令都不包含操作数,只有一个操作码(追求小数量、高传输效率),对操作数栈进行出栈、入栈操作;
- 指令集的操作码总数不超过 256 条(操作码只有1字节)。因此 Java 虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持(例如有 int 类型的 iload,没有 byte 类型的同类指令);
- 对于没有定义的数据类型的相关指令,大多数会在编译期或运行期转换成 int 类型作为运算类型;
5.5 字节码用途分类
- 加载和存储指令:用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。比如 iload、istore、bipush等;
- 运算指令:用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作数栈顶。比如加法指令:iadd,减法指令:isub 等等;
- 类型转换指令:将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显示类型转换操作,或者处理前面提到的指令集中数据类型相关指令无法与数据类型一一对应的问题(byte、short等扩展为int);
- 对象创建与访问指令:要注意 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。创建类实例:new,创建数组:nwarray、anewarray 等;
- 操作数栈管理指令:类似于操作普通数据结构中的栈,Java虚拟机提供了一些用于直接操作操作数栈的指令。比如pop、dup、swap等;
- 控制转移指令:可以让 Java 虚拟机有条件或无条件的修改程序计数器的值。包括条件分支(比如ifeq)、复合条件分支(比如tableswitch)、无条件分支(比如goto)等等;
- 方法调用和返回指令:方法调用指令包括,像 invokevirtual 指令:用于调用对象的实例方法,invokespecial指令:调用一些需要特殊处理的方法,包括实例初始化方法、私有方法和父类方法;方法调用指令与数据类型无关,但方法返回指令是根据返回值类型区分的,包括ireturn(返回boolean、byte、char、short、int),lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口类初始化方法使用;
- 异常处理指令:Java 程序中显示抛出异常的操作(throw)都是用 athrow 指令来实现。除此之外,Java 虚拟机规范还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。比如在整数运算中,当除数为 0 时,虚拟机会在 idiv 或 ldiv 指令中抛出 ArithmeticException 异常。现在在 Java 虚拟机中处理异常是采用异常表完成的,以前则使用的是 jsr 和 ret 指令实现;
- 同步指令:synchronized 语句块对应的指令就是 monitorenter 和 monitorexit。编译器必须确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必须执行其对应的 monitorexit 指令。所以为了保证在方法异常完成时,monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可以处理所有的异常;
6. 类加载机制
6.1 必须要对类进行初始化的五种时机(对类的主动引用)
- 遇到
new
、getstatic
、putstatic
或invokestatic
这 4 条字节码指令时没初始化触发初始化;(即:new 关键字实例化对象、读取一个类的 finel 静态字段、调用一个类的静态方法); - 使用
java.lang.reflect
包的方法对类进行反射调用; - 发现某类的父类还没有进行初始化,先触发其父类的初始化;
- 当虚拟机启动时,用户需指定一个要加载的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;
- 当使用 JDK 1.7 的动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果REF_getStatic
、REF_putStatic
、REF_invokeStatic
的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化;
6.2 类加载过程(生命周期)
- 程序主动使用某个类时,如果该类还未被加载到内存中,则 JVM 会通过加载、连接、初始化 3 个步骤来对该类进行初始化;
- 在程序运行期间完成;
-
1. 加载:将类的 class 文件读入到内存。通过一个类的全限定名来获取定义次类的二进制流。将这个字节流所代表的静态存储结构转换成方法区中的运行时数据结构。在堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区类数据的访问入口(反射接口)。这个过程需要类加载器参与;
- 数组类的特殊性:数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的:
- 如果数组的组件类型是引用类型,那就递归采用类加载加载;
- 如果数组的组件类型不是引用类型,Java 虚拟机会把数组标记为引导类加载器关联;
- 数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public;
- 数组类的特殊性:数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的:
-
连接:负责把类的二进制数据合并到 JRE 中(将 Java 类的二进制代码合并到 JVM 的运行状态之中);
-
2. 验证:确保加载的类信息符合 JVM 规范,没有安全方面的问题。验证是否符合 Class 文件格式规范,并且是否能被当前的虚拟机加载处理;
- (验证即其之前都是操作字节流的,之后操作基于方法区的存储结构);
- 验证过程包括文件格式验证、元数据验证、字节码验证(最复杂)、符号引用验证
- 3. 准备:为类变量(static 变量)分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配;(static 修饰的变量赋默认值,final 和 static 修饰的变量直接赋值(编译时生成 ConstantValue 属性));
-
4. 解析:(这里是静态解析)虚拟机常量池的符号引用替换为直接引用过程;
- 符号引用:以一组符号来描述所引用的目标,符号可以使任何形式的字面量。与虚拟机的内存布局无关,引用的目标并不一定加载到内存中;
- 直接引用:可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄(与内存布局有关)。与虚拟机布局相关;
- (解析及其之前都是虚拟机主导,之后是 Java 代码主导);
-
2. 验证:确保加载的类信息符合 JVM 规范,没有安全方面的问题。验证是否符合 Class 文件格式规范,并且是否能被当前的虚拟机加载处理;
-
5. 初始化:执行类构造器
<clinit>()
方法的过程。为类的变量赋予正确的初始值。类构造器<clinit>()
方法是由编译器自动收藏类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生,代码从上往下执行。如果发现父类还没有进行过初始化,则需要先触发其父类的初始化。虚拟机保证一个类的<clinit>()
方法在多线程环境中被正确加锁和同步; - 6. 使用;
- 7. 卸载;
6.3 类加载器
-
概述:
- 由 JVM 提供,是所有程序运行的基础;
- 开发者可以通过继承 ClassLoader 基类来创建自己的类加载器;
- 类加载器的任务就是根据一个类的全限定名来读取此类的二进制字节流到 JVM 中,然后转换为一个与目标类对应的 java.lang.Class 对象实例;
- 最终产物就是位于堆中的 Class 对象,该对象封装了类在方法区中的数据结构,并且向用户提供了访问方法区数据结构的接口,即 Java 反射的接口;
-
几种类加载器:
-
启动类加载器(Bootstrap Class Loader):用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader。加载
lib
下或被-Xbootclasspath
路径下的类。C++ 实现。不允许直接通过引用启动类加载器进行操作。 -
扩展类加载器(Extensions Class Loader):Sun 公司(已被 Oracle 收购)实现的 sun.misc.Launcher$ExtClassLoader 类,由 Java 语言实现的,是 Launcher 的静态内部类,它负责加载
<JAVA_HOME>/lib/ext
目录下或者由系统变量-Djava.ext.dir
指定位路径中的类库。开发者可以直接使用标准扩展类加载器; -
系统类加载器(System Class Loader)、应用程序类加载器(Application Class Loade):负责在 JVM 启动时加载来自 Java 命令的
-classpath
选项、java.class.path
系统属性,或者CLASSPATH
将变量所指定的 JAR 包和类路径。程序可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由 Java 语言实现,父类加载器为 ExtClassLoader;
-
启动类加载器(Bootstrap Class Loader):用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader。加载
-
类加载器间的关系:
- 启动类加载器:C++ 实现,没有父类;
- 拓展类加载器(ExtClassLoader):Java 实现,父类加载器为 Null;
- 系统类加载器(AppClassLoader):Java 实现,父类加载器为 ExtClassLoader;
- 自定义类加载器,父类加载器为 AppClassLoader;
-
类加载器的执行步骤:
- 1. 判断缓冲区中是否有此 Class,如果有直接进入第 8 步。否则进入第 2 步;
- 2. 判断父类加载器是否存在,存在则进入第 3 步。否则说明 Parent / 本身是启动类加载器,则跳到第 4 步;
- 3. 请求使用父类加载器去载入目标类,如果载入成功则跳至第 8 步。否则接着执行第 5 步;
- 4. 请求使用启动类加载器去载入目标类,如果载入成功则跳至第 8 步。否则跳至第 7 步;
- 5. 当前类加载器尝试寻找 Class 文件,如果找到则执行第 6 步。如果找不到则执行第 7 步;
- 6. 从文件中载入 Class,成功后跳至第 8 步;
- 7. 抛出 ClassNotFountException 异常;
- 8. 返回对应的 java.lang.Class 对象;
6.3 双亲委派模式
-
工作原理:如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载;
- 优势:Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载。即:当父亲已经加载了该类时,就没有必要子 ClassLoader 再加载一次。安全因素,Java 核心 API 中定义类型不会被随意替换(父类已经加载过,从父类中查找返回);
6.4 破坏双亲委派模式
- 到目前为止,双亲委派模型主要出现过3次较大规模的“被破坏的”情况:
- 第一次:主要是历史问题。双亲委派模型在 JDK1.2 之后才被引入,在这之前用户都是通过重写 loadClass() 方法实现自定义加载器。为了向前兼容,JDK1.2 之后的 java.Lang.ClassLoader 添加了一个新的 protected 方法 findClass()。以此保证双亲委派模型;
- 第二次:由模型本身的缺陷导致的,缺陷在于:当某个类的接口使用父类加载器,而其实现类使用子类加载器时,父类加载器无法委托子类加载器工作。Java 服务接口 SPI 由 Java 核心库提供,靠启动类加载器来加载的。而 SPI 的实现类需要由应用程序类加载器来加载。在加载 SPI 的实现类时,启动类加载器无法找到应用程序类加载器。因为依照双亲委派模型,BootstrapClassloader 无法委派 AppClassLoader 来加载类。JDK 设置线程上下文类加载器(Thread Context ClassLoader),当父类加载器需要使用子类加载器(子类加载器未创建)时,会从父线程中继承一个线程上下文类加载器,以此请求子类加载器去完成类加载的动作。这种行为实际上已经打破了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则;
- 第三次:由开发者对程序动态性的追求而导致。动态性指:代码热替换、模块热部署等。OSGi(面向Java的动态模块化系统)实现模块化热部署的关键就是它自定义的类加载器机制的实现,当需要更换一个 Bundlle(程序模块)时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在替换时需要在平级间调用类加载器,在原则上破坏了双亲委派模型;
7. 虚拟机字节码执行引擎
“栈帧”的概念在《JVM | 第1部分:自动内存管理与性能调优》提到,这里不再赘述;
7.1 确定被调用的方法
-
解析:所有方法调用的目标方法在 Class 文件里都是一个常量池中的符号引用。有两种解析:
-
静态解析:其中的一部分符号引用在
类加载的解析阶段
会被转化为直接引用(即:静态方法、final 修饰的方法、私有方法、父类方法、<init>方法,统称非虚方法
); -
动态链接:其他的符号引用会在
运行期
被解析为直接引用; - Java 虚拟机提供了 5 条方法调用字节码指令:invokestatic(静态方法)、invokespecial(实例构造器 <init> 方法、私有方法和父类方法)、invokevirtual(虚方法)、invokeinterface(接口方法)、invokedynamic(动态解析);
-
静态解析:其中的一部分符号引用在
-
分派:用来确定虚方法的目标方法。体现 Java 面向对象的继承、封装和多态 3 大特性。有如下 4 种:
-
静态分派:典型应用是处理
方法重载
。重载的方法在经过编译期
编译后得到相同的方法调用字节码指令和指令参数。虚拟机在处理重载时是通过参数的静态类型。方法参数的允许发送类型转变,但方法接收者本身静态类型不变;- 如果对象 A 继承 B,那么对于语句:
B b = new A();
其中 B 称为 b 变量的静态类型
(Static Type,编译器可知),A 称为 b 变量的实际类型
(Actual Type,运行期可知); - 选择静态分派目标的过程(重载的本质)。例如:尝试调用方法
say('a')
:- 'a' 首先是一个 char 类型:对应
say(char arg)
; - 其次还可以代表数字 97(参照 ASCII 码):对应
say(int arg)
; - 而转化为 97 之后,还可以转型为 long 类型的 97L:对应
say(long arg)
; - 另外还能被自动装箱包装为 Character:对应
say(Character arg)
; - 装箱类 Character 还实现了 Serializable 接口(若直接或间接实现了多个接口,优先级都是一样的,如果出现能适配多个接口的多个重载方法,会提示类型模糊,拒绝编译):对应
say(Serializable)
; - 而且 Character 还继承自 Object(如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低),对应
say(Object arg)
; - 最终还能匹配到变长类型:对应
say(char... arg)
;
- 'a' 首先是一个 char 类型:对应
- 如果对象 A 继承 B,那么对于语句:
-
动态分派:典型应用是
方法重写
。Java 虚拟机在运行期
会依据invokevirtual
指令的多态查找过程,通过实际类型来分派方法执行版本的。过程如下:- 1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做 M;
- 2. 如果在类型 M 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,若通过则返回这个方法的直接引用,查找过程结束;否则则返回 IllegalAccessError 异常;
- 3. 否则,按照继承关系从下往上依次对 M 的各个父类进行第 2 步的搜索和验证过程;
- 4. 如果始终没有找到合适的方法,则抛出 AbstractMethodError 异常;
-
单分派和多分派:方法的
接收者
和方法的参数
统称为方法的宗量
。 根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种;
-
静态分派:典型应用是处理
- 解析和分派不强调二选一的关系,强调的是在不同层次上的解决方案。例如:静态方法会在类加载的
解析阶段
就进行直接引用的转化,而静态方法也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派
完成的; - Java 语言的 静态多分派、动态单分派 示例:
- 方法重载:编译期看静态分派,运行期看动态分派;
public class Main {
static class A {
}
static class B extends A {
}
static class C extends B {
}
public void say(A a) {
System.out.println("A");
}
public void say(B b) {
System.out.println("B");
}
public void say(C c) {
System.out.println("C");
}
public static void main(String[] args) throws Exception {
Main main = new Main();
Main superMain = new Super();
B os = new C();
main.say(os);
superMain.say((A) os);
//输出 B S-A
}
}
class Super extends Main {
public void say(A a) {
System.out.println("S-A");
}
public void say(B b) {
System.out.println("S-B");
}
public void say(C c) {
System.out.println("S-C");
}
}
- 编译期看静态分派 - 多分派:
- main 和 superMain 的静态类型都是 Main,方法参数的静态类型一个是 B,一个是 A,所以此次选择产生的两条 invokevitrual 指令的参数分别为常量池中指向 Main.say(B) 和 Main.say(A) 的方法的符号引用。这里根据两个宗量(方法接受者和参数)进行选择;
- 运行期看动态分派 - 单分派:
- 这阶段 Java 虚拟机此时不用关心参数的静态类型、实际类型,只有方法接收者的实际类型会影响到方法版本的选择。Main.say(B) 和 Main.say(A) 方法的实际类型分别是 Main.say(B) 和 Super.say(A)。也就是只有一个宗量作为选择依据;