Java 是目前用户最多、使用范围最广的软件开发技术,Java 的技术体系主要由支撑 Java 程序运行的虚拟机、提供各开发领域接口支持的 Java 类库、Java 编程语言及许许多多的第三方 Java 框架(如 Spring、 MyBatis 等) 构成。在国内,有关 Java 类库 API、Java 语言语法及第三方框架的技术资料和书籍非常丰富,相比而言,有关 Java 虚拟机的资料却显得异常贫乏。
这种状况很大程度上是由 Java 开发技术本身的一一个重要优点导致的:在虚拟机层面隐藏了底层技术的复杂性以及机器与操作系统的差异性。运行程序的物理机千差万别,而 Java 虚拟机则在千差万别的物理机上面建立了统一的运行平台,实现了在任意一台 Java 虚拟机上编译的程序,都能在任何其他 Java 虚拟机上正常运行。这一极大的优势使得 Java 应用的开发比传统 C/C+ +应用的开发更高效快捷,程序员可以把主要精力放在具体业务逻辑,而不是放在保障物理硬件的兼容性上。通常情况下,一个程序员只要了解了必要的 Java 类库 API、Java 语法, 学习适当的第三方开发框架,就已经基本满足日常开发的需要了。虚拟机会在用户不知不觉中完成对硬件平台的兼容及对内存等资源的管理工作。因此,了解虚拟机的运作并不是普通开发人员必备的,或者说首要学习的知识。
其实,目前商用的高性能 Java 虚拟机都提供了相当多的优化参数和调节手段,用于满足应用程序在实际生产环境中对性能和稳定性的要求。如果只是为了入门学习,让程序在自己的机器上正常工作,那么这些特性可以说是可有可无的;但是,如果用于生产开发,尤其是大规模的、企业级的生产开发,就迫切需要开发人员中至少有一部分 人对虚拟机的特性及调节方法具有很清晰的认识。所以在 Java 开发体系中,对架构师、系统调优师、高级程序员等角色的需求一直都非常大。
学习虚拟机中各种自动运作特性的原理也成为 Java 程序员成长路上最终必然会接触到的一课。通过本文,读者可以以一个相对轻松的方式学到虚拟机的运作原理。
参考:JVM 规范,Memories of a Java Runtime
「堆」:JVM 启动时按-Xmx, -Xms大小创建的内存区域,用于分配对象、数组所需内存,由 GC 管理和回收
「方法区」:存储被 JVM 加载的类信息(字段、成员方法的字节码指令等)、运行时常量池(字面量、符号引用等)、JIT 编译后的 Code Cache 等信息;JDK8 前 Hotspot 将方法区存储于永久代堆内存,之后参考 JRockit 废弃了永久代,存储于本地内存的 Metaspace 区
「直接内存」:JDK1.4 引入 NIO 使用 Native/Unsafe 库直接分配系统内存,使用 Buffer,Channel 与其交互,避免在系统内存与 JVM 堆内存之间拷贝的开销
分配堆内存:类加载完毕后,其对象所需内存大小是确定的;堆内存由多线程共享,若并发创建对象都通过 CAS 乐观锁争夺内存,则效率低。故线程创建时在堆内存为其分配私有的分配缓冲区(TLAB:Thread Local Allocation Buffer)
注:当 TLAB 剩余空间不足以分配新对象,但又小于最大浪费空间阈值时,才会加锁创建新的 TLAB
零值初始化对象的堆内存、设置对象头信息、执行构造函数 ()V
「对象数据」:各种字段的值,按宽度分类紧邻存储
「对齐填充」:内存对齐为 1 个字长整数倍,减少 CPU 总线周期
验证:openjdk/jol 检查对象内存布局
public class User {
private int age = -1;
private String name = "unknown";
}
// java -jar ~/Downloads/jol-cli-latest.jar internals -cp . com.jol.User
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf8021e85 // User.class 引用地址
12 4 int User.age -1 // 基本类型则直接存储值
16 4 java.lang.String User.name (object) // 引用类型,指向运行时常量池中的 String 对象
20 4 (object alignment gap) // 有 4 字节的内存填充
Instance size: 24 bytes
「堆内存」: -Xms 指定堆初始大小,当大量无法被回收的对象所占内存超出 -Xmx 上限时,将发生内存溢出 OutOfMemoryError
// -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public class HeapOOM {
static class OOMObject {}
public static void main(String[] args) {
List vs = new ArrayList<>();
while (true)
vs.add(new OOMObject());
}
}
分析 GC Root 发现com.ch02.HeapOOM对象间接引用了大量的OOMObject对象,共占用 15.4MB 堆内存,无法回收最终导致 OOM
「栈内存」: -Xss 指定栈大小,当栈深度超阈值(比如未触发终止条件的递归调用)、本地方法变量表过大等,都可能导致内存溢出 StackOverflowError
「方法区」: -XX:MetaspaceSize 指定元空间初始大小, -XX:MaxMetaspaceSize 指定最大大小,默认 -1 无限制,若在运行时动态生成大量的类,则可能触发 OOM
「运行时常量池」: strObj.intern() 动态地将首次出现的字符串对象放入字符串常量池并返回,JDK7 前会拷贝到永久代,之后则直接引用堆对象
String s1 = "java"; // 类加载时,从字节码常量池中拷贝符号到了运行时常量池,在解析阶段初始化的字符串对象
String s2 = "j";
String s3 = s2 + "ava"; // 堆上动态分配的字符串对象
println(s3 == s1); // false
println(s3.intern() == s1); // true // 已在字符串常量池中存在
「直接内存」: -XX:MaxDirectMemorySize 指定大小,默认与 -Xmx 一样大,不被 GC 管理,申请内存超阈值时 OOM
GC 可分解为 3 个子问题:which(哪些内存可被回收)、when(什么时候回收)、how(如何回收)
原理:每个对象都维护一个引用计数器rc,当通过赋值、传参等方式引用它时rc++,当引用变量修改指向、离开函数作用域等方式解除引用时rc--,递减到 0 时说明对象无法再被使用,可回收。伪代码:
assign(var, obj):
incr_ref(obj) # self = self # 先增再减,避免引用自身导致内存提前释放
decr_ref(var)
var = obj
incr(obj):
obj.rc++
decr(obj):
obj.rc--
if obj.rc == 0:
remove_ref(obj) # 断开 obj 与其他对象的引用关系
gc(obj) # 回收 obj 内存
优点:思路简单,对象无用即回收,延迟低,适合内存少的场景
缺点:此算法中对象是孤立的,无法在全局视角检查对象的真实有效性,循环引用的双方对象需引入外部机制来检测和回收,如下图红色圈(图源:what-is-garbage-collection)
原理:从肯定不会被回收的对象(GC Roots)出发,向外搜索全局对象图,不可达的对象即无法再被使用,可回收;常见可作为 GC Root 的对象有:
优点:无需对象维护 GC 元信息,开销小;单次扫描即可批量识别、回收对象,吞吐高
缺点:多线程环境下对象间的引用关系随时在变化,为保证 GC Root 标记的准确性,需在不变化的 snapshot 中进行,会产生 Stop The World(以下简称 STW) 卡顿现象
示例:限制堆內存 50MB,其中新生代 30MB,老年代 20MB;依次分配 5 次 10MB 的byte[]对象,仅使用软引用来引用,观察 GC 过程
public static void main(String[] args) {
// softRefList --> SoftReference --> 10MB byte[]
List> softRefList = new ArrayList<>();
ReferenceQueue softRefQueue = new ReferenceQueue<>(); // 无效引用队列
for (int i = 0; i < 5; i++) {
SoftReference softRef = new SoftReference<>(new byte[10*1024*1024], softRefQueue);
softRefList.add(softRef);
for (SoftReference ref : softRefList) // dump 所有软引用指向的对象,检查是否已被回收
System.out.print(ref.get() == null ? "gced " : "ok ");
System.out.println();
}
Reference<? extends byte[]> ref = softRefQueue.poll();
while (ref != null) {
softRefList.remove(ref); // 解除对软引用对象本身的引用
ref = softRefQueue.poll();
}
System.out.println("effective soft ref: " + softRefList.size()); // 2
}
// java -verbose:gc -XX:NewSize=30m -Xms50m -Xmx50m -XX:+PrintGCDetails com.ch02.DemoRef
ok
ok ok
// 分配第三个 []byte 时,Eden GC 无效,触发 Full GC 将一个 []byte 晋升到老年区
// 此时三个 byte[] 都只被软引用所引用,被标记为待二次回收(若为弱引用,此时 Eden 已被回收)
[GC (Allocation Failure) --[PSYoungGen: 21893K->21893K(27136K)] 21893K->32141K(47616K), 0.0046324 secs]
[Full GC (Ergonomics) [PSYoungGen: 21893K->10527K(27136K)] [ParOldGen: 10248K->10240K(20480K)] 32141K->20767K(47616K), [Metaspace: 2784K->2784K(1056768K)], 0.004 secs]
ok ok ok
// 再次 GC,前三个 byte[] 全部被回收
[GC (Allocation Failure) --[PSYoungGen: 20767K->20767K(27136K)] 31007K->31007K(47616K), 0.0007963 secs]
[Full GC (Ergonomics) [PSYoungGen: 20767K->20759K(27136K)] [ParOldGen: 10240K->10240K(20480K)] 31007K->30999K(47616K), [Metaspace: 2784K->2784K(1056768K)], 0.003 secs]
[GC (Allocation Failure) --[PSYoungGen: 20759K->20759K(27136K)] 30999K->30999K(47616K), 0.0007111 secs]
[Full GC (Allocation Failure) [PSYoungGen: 20759K->0K(27136K)] [ParOldGen: 10240K->267K(20480K)] 30999K->267K(47616K), [Metaspace: 2784K->2784K(1056768K)], 0.003 secs]
gced gced gced ok
gced gced gced ok ok
原理:若对象不可达,被标记为可回收后,会进行 finalize() 是否被重写、是否已执行过等条件筛选,若通过则对象会被放入 F-Queue 队列,等待低优先级的后台 Finalizer 线程触发其 finallize() 的执行(不保证执行结束),对象可在 finalize 中建立与 GC Root 对象图上任一节点的引用关系,来逃脱 GC
使用:finalize 机制与 C++ 中的析构函数并不等价,其执行结果并不确定,不推荐使用,可用try-finally替代
两个分代假说:符合大多数程序运行的实际情况
对应地,JVM 堆被划分为 2 个不同区域,将对象按年龄分类,兼顾了 GC 耗时与内存利用率
跨代引用
示意图
「2. 加速 GC:CardTable」问题:非收集区域(老年代)会存在到收集区域(新生代)的跨代引用,如何避免对前者的全量扫描?
卡表:记忆集的字节数组实现;将老年代内存划分为 Card Page(512KB)大小的子内存块,若新建跨代引用,则将对应的 Card 标记为 dirty,GC 时只需扫描老年代中被标记为 dirty 的子内存块
写屏障:有别于volatile禁用指令重排的内存屏障,GC 中的写屏障是在对象引用更新时执行额外 hook 动作的机制。简单实现:
void oop_field_store(oop* field, oop new_val) { // oop: ordinary object pointer
// pre_write_barrier(field, new_val); // 写前屏障:更新前先执行,使用 oop 旧状态
*field = new_val;
post_write_barrier(field, new_val); // 写后屏障:更新完才执行
}
使用写屏障保证 CardTable 的实时更新(图源:The JVM Write Barrier - Card Marking)
「3. 正确 GC:并发可达性分析」参考演讲:Shenandoah: The Garbage Collector That Could by Aleksey Shipilev
问题:GC Roots 的对象源固定,故枚举时 STW 时间短暂且可控。但后续可达性分析的时间复杂度与堆中对象数量成正相关,即堆中对象越多,对象图越复杂,堆变大后 STW 时间不可接受
解决:并发标记。引出新问题:用户线程动态建立、解除引用,标记过程中图结构发生变化,结果不可靠;证明:用三色法描述对象状态
STW 无并发的正确标记:顶部 3 个对象将被回收
用户线程并发修改引用,会导致标记结果无效,分 2 种情况:
论文《Uniprocessor Garbage Collection Techniques - Paul R. Wilson》§3.2 证明了「实际存活的对象被标记为可回收」必须同时满足两个条件(有时间序)
为正确实现标记,打破其中一个条件即可(类比打破死锁四个条件之一的思想),分别对应两种方案:
搭配使用示意图:
原理:内存不足触发 GC 后会暂停所有用户线程,单线程地在新生代中标记复制,在老年代中标记整理,收集完毕后恢复用户线程
优点:全程 STW 简单高效
缺点:STW 时长与堆对象数量成正相关,且 GC 线程只能用到 1 core 无法加速
场景:单核 CPU 且可用内存少(如百兆级),JDK1.3 之前的唯一选择
原理:多线程并行版的 Serial 实现,能有效减少 STW 时长;线程数默认与核数相同,可配置
场景:JDK7 之前搭配老年代的 CMS 回收器使用
垃圾回收有两个通常不可兼得的目标
原理:与 ParNew 类似都是并行回收,主要增加了 3 个选项(倾向于提高吞吐量)
CMS:Concurrent Mark Sweep,即并发标记清除,主要有 4 个阶段
优点:两次 STW 时间相比并发标记耗时要短得多,相比前三种收集器,延迟大幅降低
缺点
特点:基于 region 内存布局实现局部回收;GC 延迟目标可配置;无内存碎片问题
跨代引用:各 region 除了用卡表标记各卡页是否为 dirty 之外,还用哈希表记录了各卡页正在被哪些 region 引用,通过这种“双向指针”机制,能直接找到 Old 区,避免了全量扫描(G1 自身内存开销大头)
G1 GC 有 3 个阶段(参考其 GC 日志)
参数控制(文档:HotSpot GC Tuning Guide)
使用 Serial 收集器 -XX:+UseG1GC 演示
新对象在 Eden 区分配,空间不足则触发 Minor GC,存活对象拷贝到 To Survivor,若还是内存不足则通过分配担保机制转移到老年区,依旧不足才 OOM
byte[] buf1 = new byte[6 * MB];
byte[] buf2 = new byte[6 * MB]; // 10MB 的 eden 区剩余 4MB,空间不足,触发 minor GC
// java -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:+UseSerialGC com.ch03.Allocation
// minor gc 后新生代内存从 6M 降到 0.2M,存活对象移到了老年区,总的堆内存用量依旧是 6MB
[GC (Allocation Failure) [DefNew: 6823K->286K(9216K), 0.002 secs] 6823K->6430K(19456K), 0.002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 6513K
eden space 8192K, 76% used // buf2
from space 1024K, 28% used
to space 1024K, 0% used
tenured generation total 10240K, used 6144K
the space 10240K, 60% used // buf1
对于 Serial, ParNew,可配置超过阈值 -XX:PretenureSizeThreshold 的大对象(连续内存),直接在老年代中分配,避免触发 minor gc,导致 Eden 和 Survivor 产生大量的内存复制操作
byte[] buf1 = new byte[4 * MB];
// java -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:+UseSerialGC
// -XX:PretenureSizeThreshold=3145728 com.ch03.Allocation // 3145728 即 3MB
Heap
def new generation total 9216K, used 843K
eden space 8192K, 10% used
from space 1024K, 0% used
to space 1024K, 0% used
tenured generation total 10240K, used 4096K
the space 10240K, 40% used // buf1
对象头中 4bit 的 age 字段存储了对象当前 GC 分代年龄,当超过阈值 -XX:MaxTenuringThreshold (默认 15,也即 age 字段最大值)后,将晋升到老年代,可搭配 -XX:+PrintTenuringDistribution 观察分代分布
byte[] buf1 = new byte[MB / 16];
byte[] buf2 = new byte[4 * MB];
byte[] buf3 = new byte[4 * MB]; // 触发 minor gc
buf3 = null;
buf3 = new byte[4 * MB];
// java -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:+UseSerialGC
// -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution com.ch03.Allocation
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 359280 bytes, 359280 total
: 4839K->350K(9216K)] 4839K->4446K(19456K), 0.0017247 secs]
// 至此,buf1 熬过了第一次收集,age=1
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1): 4446K->0K(9216K)] 8542K->4438K(19456K)]
Heap
def new generation total 9216K, used 4178K
eden space 8192K, 51% used
from space 1024K, 0% used // buf1 在第二轮收集中被提前晋升
to space 1024K, 0% used
tenured generation total 10240K, used 4438K
the space 10240K, 43% used
-XX:MaxTenuringThreshold 并非晋升的最低硬性门槛,当 Survivor 中同龄对象超 50% 后,大于等于该年龄的对象会被自动晋升,哪怕还没到阈值
老年代作为 To Survivor 区的担保区域,当 Eden + From Survivor 中存活对象的总大小超出 To Survivor 时,将尝试存入老年代。JDK6 之后,只要老年代的连续空间大于新生代对象的总大小,或之前晋升的平均大小,则只会进行 Minor GC,否则进行 Full GC
Class 文件实现语言无关性,JVM 实现平台无关性,参考《Java 虚拟机规范》
一个 Class 文件描述了一个类或接口的明确定义,文件内容是一组以 8 字节为单位的二进制流,各数据项间没有分隔符,超过 8 字节的数据项按 Big-Endian 切分后存储。数据项分两种:
ClassFile {
u4 magic; // 魔数
u2 minor_version; // 版本号
u2 major_version;
u2 constant_pool_count; // 常量池
cp_info constant_pool[constant_pool_count-1];
u2 access_flags; // 类访问标记
u2 this_class; // 本类全限定名
u2 super_class; // 单一父类
u2 interfaces_count; // 多个接口
u2 interfaces[interfaces_count];
u2 fields_count; // 字段表
field_info fields[fields_count];
u2 methods_count; // 方法表
method_info methods[methods_count];
u2 attributes_count; // 类属性
attribute_info attributes[attributes_count];
}
CONSTANT_Utf8_info {
u1 tag; // 值为 1
u2 length; // bytes 数组长度,u2 最大值 65535,即单个字符串字面量不超过 64KB
u1 bytes[length]; // 长度不定的字节数组
}
CONSTANT_Class_info {
u1 tag; // 值为 7
u2 name_index; // 指向全限定类名的 Utf8_info // 常量间存在层级组合关系
}
CONSTANT_Fieldref_info {
u1 tag; // 值为 9
u2 class_index; // 所属类
u2 name_and_type_index; // 字段的名称、类型描述符
}
CONSTANT_Methodref_info {
u1 tag; // 值为 10
u2 class_index; // 所属类
u2 name_and_type_index; // 方法的名称、签名描述符
}
CONSTANT_NameAndType_info {
u1 tag; // 值为 12
u2 name_index; // 字段或方法的名称
u2 descriptor_index; // 类型描述符
}
如上只列举了其中 5 种常量的结构,可见常量间通过组合的方式,来描述层级关系
field_info {
u2 access_flags; // 作用域、static,final,volatile 等访问标记
u2 name_index; // 字段名
u2 descriptor_index; // 类型描述符
u2 attributes_count; // 字段的属性表
attribute_info attributes[attributes_count];
}
类型描述符简化描述了字段的数据类型、方法的参数列表及返回值,与 Java 中的类型对于关系如下:
method_info {
u2 access_flags; // 访问标记
u2 name_index; // 方法名
u2 descriptor_index; // 方法描述符
u2 attributes_count; // 方法属性表
attribute_info attributes[attributes_count];
}
字段表、方法表都可以带多个属性表,如常量字段表、方法字节码指令表、方法异常表等。属性模板:
attribute_info {
u2 attribute_name_index; // 属性名
u4 attribute_length; // 属性数据长度
u1 info[attribute_length]; // 其他字段,各属性的结构不同
}
属性有 20+ 种,此处只记录常见的三种
Code_attribute {
u2 attribute_name_index; // 属性名,指向的 Utf8_info 值固定为 "Code"
u4 attribute_length; // 剩下字节长度
u2 max_stack; // 操作数栈最大深度,对于此方法的栈帧中操作数栈的深度
u2 max_locals; // 以 slot 变量槽为单位的局部变量表大小,存储隐藏参数 this,实参列表,catch 参数,局部变量等
u4 code_length; // 字节码指令总长度
u1 code[code_length]; // JVM 指令集大小 200+,单个指令的编号用 u1 描述
u2 exception_table_length; // 异常表,描述方法内各指令区间产生的异常及其 handler 地址
{ u2 start_pc; // catch_type 类型的异常,会在 [start_pc, end_pc) 指令范围内抛出
u2 end_pc;
u2 handler_pc; // 若抛出此异常,则 goto 到 handler_pc 处执行
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count; // Code 属性自己的属性
attribute_info attributes[attributes_count];
}
LineNumberTable_attribute {
u2 attribute_name_index; u4 attribute_length;
u2 line_number_table_length;
{ u2 start_pc; // 字节码指令区间开始位置
u2 line_number; // 对应的源码行号
} line_number_table[line_number_table_length];
}
LocalVariableTable_attribute {
u2 attribute_name_index; u4 attribute_length;
u2 local_variable_table_length;
{ u2 start_pc; // 局部变量生命周期开始的字节码偏移量
u2 length; // 向后生命周期覆盖的字节码长度
u2 name_index; // 变量名
u2 descriptor_index; // 类型描述符
u2 index; // 对应的局部变量表中的 slot 索引
} local_variable_table[local_variable_table_length];
}
其他属性直接参考 JVM 文档
package com.cls;
public class Structure {
public static void main(String[] args) {
System.out.println("hello world");
}
}
javac -g:lines com/cls/Structure.java 编译后,参考 javap 反编译得到的正确结果, od -x --endian=big Structure.class 得出 class 文件内容的十六进制表示,解读如下:
cafe babe # 1. u4 魔数,标识 class 文件类型
0000 0034 # 2. u2,u2 版本号,52 JDK8
# 3. 常量池
---1---
001f # u2 constant_pool_count,31 项(从 1 开始计数,0 预留)
0a # u1 tag,10,Methoddef_info,成员方法结构
0006 # u2 index,6,所属类的 Class_info 在常量池中的编号 ## java/lang/Object
0011 # u2 index,17,此方法 NameAndType 编号 ## :()V
---2---
09 # 9,Fileddef_info,成员变量结构
0012 # u2 index,18,所属类 Class_info 编号 ## java/lang/System
0013 # u2 index,19,此字段 NameAndType 编号 ## out:Ljava/io/PrintStream
---3---
08 # 8,String_info,字符串
0014 # u2 index,20,字面量编号 ## hello world
---4---
0a
0015 # 21 ## java/io/PrintStream
0016 # 22 ## println:(Ljava/lang/String;)V
---5---
07 # Class_info,全限定类名
0017 # u2 index,23,字面量编号 ## com/cls/Structure
---6---
07 # 7,Class_info,类引用
0018 # 24 ## java/lang/Object
---7---
01 # Utf8_info,UTF8 编码的字符串
0006 # u2 length,6,字符串长度
3c 69 6e 69 74 3e # 字面量值 ## ""
---8-16---
01 0003 282956 ## "()V"
01 0004 436f6465 ## "Code"
01 000f 4c696e654e756d6265725461626c65 ## "LineNumberTable"
01 0004 6d61696e ## "main"
01 0016 285b4c6a6176612f6c616e672f537472696e673b2956 ## "([Ljava/lang/String;)V"
01 0010 4d6574686f64506172616d6574657273 ## "MethodParameters"
01 0004 61726773 ## "args"
01 000a 536f7572636546696c65 ## "SourceFile"
01 000e 5374727563747572652e6a617661 ## "Structure.java"
---17---
0c # 12,NameAndType,名字及类型描述符
0007 # u2 index,7,字段或方法名字面量编号 ##
0008 # u2 index,8,字段或方法结构编号 ## ()V
---18---
07 0019 # 25 ## java/lang/System
---19---
0c
001a 001b # 26:27 ## out:Ljava/io/PrintStream;
---20---
01 000b 68656c6c6f20776f726c64 ## "hello world"
---21--
07 001c # 28 ## java/io/PrintStream
---22--
0c
001d 001e # 29:30 ## println:(Ljava/lang/String;)V
---23-31---
01 0011 636f6d2f636c732f537472756374757265 ## "com/cls/Structure"
01 0010 6a6176612f6c616e672f4f626a656374 ## "java/lang/Object "
01 0010 6a6176 612f 6c61 6e67 2f53 7973 7465 6d ## "java/lang/System"
01 0003 6f7574 ## "out"
01 0015 4c6a6176612f696f2f5072696e7453747265616d3b ## "Ljava/io/PrintStream;"
01 0013 6a6176612f696f2f5072696e7453747265616d ## "java/io/PrintStream"
01 0007 7072696e746c6e ## "println"
01 0015 284c6a6176612f6c616e672f537472696e673b2956 ## "(Ljava/lang/String;)V"
0021 # 4. u2,access_flags ## ACC_PUBLIC | ACC_SUPER
0005 # 5. u2, this_class,5 ## --5.Class_info--> com/cls/Structure
0006 # 6. u2, super_class, 6 ## --6.Class_info--> java/lang/Object
0000 # 7. u2, interface_count, 0
0000 # 8. u2, fields_count, 0
0002 # 9. methods count, 2
# 方法一
0001 # u2, access_flags, ACC_PUBLIC
0007 # u2, name_index, 7 ##
0008 # u2, descriptor_index, 8 ## ()V
0001 # u2, attribute_count, 1
0009 # u2, attribute_name_index, 9 ## Code 属性
0000 001d # u4, attribute_length, 30
0001 # u2, max_stack, 1
0001 # u2, max_locals, 1
0000 0005 # u4, code_array_length, 5
2a # u1, aload_0 ## 将第 0 个 slot 中的变量 this 入栈
b7 0001 # u1, invokespecial ## 执行从 Object 继承的
b1 # u1, return ## 返回 void
0000 # u2, exception_table_length, 0 ## exception table 为空,无异常
0001 # u2, attributes_count, 1 ## Code 属性本身的子属性
000a # 10 ## LineNumberTable 属性
0000 0006 # 6
0001 # u2, line_number_table_length, 1
0000 # u2, start_pc, 0
0003 # u2, line_number, 3
# 方法二
0009 # access_flags ## ACC_PIBLIC | ACC_STATIC
000b # name_index, 11 ## main
000c # descriptor_index, 12 ## ([Ljava/lang/String;)V
0002 # attribute_count, 2
0009 # attribute_name_index, 9 ## Code
0000 0025 # attribute_length, 37
0002 # max_stack, 2
0001 # max_locals, 1
0000 0009 # code_array_length, 9
b2 0002 # getstatic, 2 ## Field: java/lang/System.out:Ljava/io/PrintStream; // 加载静态对象变量
12 03 # ldc, 3 ## String: "hello world" // 将常量参数入栈
b6 0004 # invokevirtual, 4 ## Method: java/io/PrintStream.println:(Ljava/lang/String;)V // 执行方法
b1 # return
0000 # exception_table_length, 0
0001 # attributes_count, 1
000a # 10 ## LineNumberTable
0000 000a # 10
0002 # line_number_table_length, 2
0000 0005 # 0 -> 5
0008 0006 # 8 -> 6
JVM 面向操作数栈(operand stack)设计了指令集,每个指令由 1 字节的操作码(opcode)表示,其后跟随 0 个或多个操作数(operand),指令集列表参考 Java bytecode instruction listings
字节码可大致分为六类:
// 将 slot 0,1,2,3,N 加载到栈顶,T 表示类型简记前缀,可取 i,l,f,d,a
Tload_0, Tload_1, Tload_2, Tload_3, Tload n
// 将栈顶数据写回指定的 slot
Tstore_0, Tstore_1, Tstore_2, Tstore_3, Tstore n
// 将不同范围的常量值加载到栈顶,由于 0~5 常量过于常用,有单独对应的指令,ldc 则加载普通常量
bipush, sipush, Tconst_[0,1,2,3,4,5], aconst_null, ldc
Tadd, Tsub, Tmul, Tdiv, Trem // 算术运算:加减乘除,取余
Tneg, Tor, Tand, Txor // 位运算:取反、或、与、异或
dcmpg, dcmpl, fcmpg, fcmpl, lcmp // 比较运算:后缀 g 即 greater, l 即 less than
iinc // 局部自增运算,与 iload 搭配使用
i2b // int -> byte
i2c, i2s; l2i, f2i, d2i; d2l, f2l; d2f
new // 创建类实例
newarray, annewarray, multianewarry // 创建基本类型数组、引用类型数组、多维引用类型数组
getfield, putfield; getstatic, putstatic // 读写类实例字段;读写类静态字段
Taload, Tastore; arraylength // 读写数组元素;计算数组长度
instanceof; checkcast // 校验对象是否为类实例;执行强制转换
pop, pop2 // 弹出栈顶 1,2 元素
dup, dup2; swap // 复制栈顶 1,2 个元素并重新入栈;交换栈顶两个元素
if_ // 整型比较,引用相等性判断
if // 搭配其他类型的比较运算指令使用
invokevirtual // 根据对象的实际类型进行分派,调用对应的方法(比如继承后方法重写)
invokespecial // 调用特殊方法,如 ()V, ()V 等初始化方法、私有方法、父类方法
invokestatic // 调用类的静态方法
invokeinterface // 调用接口方法(实现接口的类对象,但被声明为接口类型,调用方法)
invokedynamic // TODO
Treturn, return // 返回指定类型,返回 void
原理:委托 ClassLoader 读取 Class 二进制字节流,载入到方法区内存,并在堆内存中生成对应的java.lang.Class对象相互引用
校验字节流确保符合 Class 文件格式,执行语义分析确保符合 Java 语法,校验字节码指令合法性
在堆中分配类变量(static)内存并初始化为零值,主义还没到执行 putstatic 指令赋值的初始化阶段,但静态常量属性除外:
public class ClassX {
final static int n = 2; // 常量的值在编译期就已知,准备阶段完成赋值,值存储在 ConstantValue
final static String str = "str"; // 字符串静态常量同理
}
static final java.lang.String str;
descriptor: Ljava/lang/String;
flags: ACC_STATIC, ACC_FINAL
ConstantValue: String str
将常量池中的符号引用(Class_info, Fieldref_info, Methodref_info)替换为直接引用(内存地址)
javac 会从上到下合并类中 static 变量赋值、static 语句块,生成类构造器 ()V,在初始化阶段执行,此方法的执行由 JVM 保证线程安全;注意 JVM 规定有且仅有的,会立即触发对类初始化的六种 case
public class ClassX {
static {
println("main class ClassX init"); // 1. main() 所在的主类,总是先被初始化
}
public static void main(String[] args) throws Exception {
// 首次会触发类的初始化
// SubX b = new SubX(); // new 对象 // 2. new, getsatic, putstatic, invokestatic 指令
// println(SuperX.a); // 读写类的 static 变量,或调用 static 方法
// println(SubX.c); // 3. 子类初始化,会触发父类初始化
// println(SubX.a); // 子类访问父类的静态变量,只会触发父类初始化
// 不会触发类的初始化
// println(SubX.b); // 1. 访问类的静态常量(基本类型、字符串字面量)
// println(SubX.class); // 2. 访问类对象
// println(new SubX[2]); // 3. 创建类的数组
}
}
class SuperX {
static int a = 0;
static {
println("class SuperX initiated");
}
}
class SubX extends SuperX {
final static double b = 0.1;
static boolean c = false;
static {
println("class SubX initiated");
}
}
层级关系
双亲委派机制
// java/lang/ClassLoader
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 先检查自己的加载器是否已加载此类
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2. 还有上层则委派给上层去加载
c = parent.loadClass(name, false);
} else {
// 3. 如果没有上级,则委派给 Bootstrap 加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 类不存在
}
if (c == null) {
// 4. 到自己的 classpath 中查找类,用户自定义 ClassLoader 自定义了查找规则
long t1 = System.nanoTime();
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
public static void main(String[] args) {
int a = 1008611;
int b = ++a;
}
对应运行时栈帧结构:
public static void main(String[] args) {
{
byte[] buf = new byte[10 * 1024 * 1024];
}
System.gc(); // buf 还在局部变量表的 slot 0 中,作为 GC Root 无法被回收
// int v = 0; // 变量 v 重用 slot 0,gc 生效
// System.gc();
「1. 虚方法、非虚方法」非虚方法:编译期可知(程序运行前就唯一确定)、且运行期不可变的方法,在类加载阶段就会将方法的符号引用解析为直接引用。有 5 种:
public class StaticResolution {
public static void doFunc() {
System.out.println("do func...");
}
public static void main(String[] args) {
StaticResolution.doFunc();
}
}
stack=0, locals=1, args_size=1 // 静态方法的调用版本,在编译时就以常量的形式,存入字节码的参数
0: invokestatic #5 // Method doFunc:()V
3: return
虚方法:需在运行时动态确定直接引用的方法,由invokevirtual, invokeinterface调用
「2. 静态分派、动态分派」背景:方法可被重载(参数类型不同,或数量不同)、可被重写(子类继承后覆盖)
分派:对象可声明为类、父类、实现的接口等类型,当对象作为实参或调用方法时,需根据其静态类型或实际类型,才能确定要调用的方法的版本,进而确定其直接引用。此过程即方法的分派
reference 变量的 2 种类型
public class StaticDispatch {
static abstract class Human {}
static class Man extends Human {}
static class Woman extends Human { }
public void f(Human human) {System.out.println("f(Human)");}
public void f(Man man) {System.out.println("f(Man)");}
public void f(Woman woman) {System.out.println("f(Woman)");}
public static void main(String[] args) {
Human man = new Man(); // 静态类型都是 Human
Human woman = new Woman(); // 实际类型分别为 Man, Woman
StaticDispatch sd = new StaticDispatch();
sd.f(man); // f(Human) // invokevirtual #13 // Method f:(Lcom/ch08/StaticDispatch$Human;)V
sd.f(woman); // f(Human) // 编译期就已确定重载版本,写入字节码中
}
}
注:类的方法查找是高频操作,JVM 会在方法区中为类建一张虚方法表 vtable,以实现方法的快速查找
public class DynamicDispatch {
static abstract class Human {
protected abstract void f();
}
static class Man extends Human {
@Override
protected void f() {
System.out.println("Man f()");
}
}
static class Woman extends Human {
@Override
protected void f() {
System.out.println("Woman f()");
}
}
public static void main(String[] args) {
Human man = new Man(); // 虽然静态类型都是 Human
Human woman = new Woman();
man.f(); // Man f() // invokevirtual #6 // Method com/ch08/DynamicDispatch$Human.f:()V
woman.f(); // Woman f() // 虽然字节码指令的参数,都是静态类型方法的符号引用
man = new Woman();
man.f(); // Woman f() // 但 invokevirtual 会根据 Receiver 实际类型,在运行时解析到实际类的直接引用
}
}
注意,类的字段读写指令getfield, putfield没有invokevirtual的动态分派机制,即子类的同名字段会直接覆盖父类的字段。示例:
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMoney();
}
public void showMoney() { System.out.println("Father, money = " + money); }
}
static class Son extends Father {
public int money = 3; // 子类字段在类加载的准备阶段被赋零值
public Son() { // 子类构造器第一行默认隐藏调用 super()
money = 4;
showMoney();
}
public void showMoney() { System.out.println("Son, money = " + money); }
}
public static void main(String[] args) {
Father guy = new Son();
System.out.println("guy, money = " + guy.money);
}
}
// Son, money = 0 // Father 类构造器执行,动态分派执行了 Son::showMoney()
// Son, money = 4 // Son 类构造器中访问最新的、自己的 money 字段
// guy, money = 2 // 字段的读写没有动态分派,静态类型是谁,就访问谁的字段
「3. 单分派、多分派」方法的 Receiver,与方法的参数,都是方法的宗量,根据一个宗量来选择目标方法称为单分派,需要多个宗量才能确定方法的叫多分派
综上两点,Java 是静态多分派、动态单分派的语言
注明:第 10,11 章讲 Java 的前后端编译,学习了自动装箱等常见语法糖的字节码实现,其余部分待有空搭配龙书一起学;第 12,13 章内容与《Java Concurrency In Practice》等书重合度较高,此处不再赘述
记得点赞,关注+转发!!!
留言与评论(共有 0 条评论) “” |