【DIY系列:Java虚拟机】第70篇:扩展方向——JVM 实现还可以走多远 上一篇【第69篇】系列总结系列完结 感谢阅读摘要70 篇走到最后一篇。jvmgo 跑通了HelloWorld但它是个玩具——单线程、无 GC、无 JIT、无类验证。这一篇聊两件事一、jvmgo 还能怎么改按难度分级的动手清单二、JVM 技术本身还能走多远GC / JIT / 多线程 / 语言特性 / GraalVM / 虚拟线程以及最后一份学习路线图——如果你想继续深入该读什么、按什么顺序读。一、jvmgo 扩展清单按难度分级1.1 入门级几十行代码① 补上剩下的错误检查// arraycopy 加上元素类型检查第 061 篇提到没做// aastore 加上 ArrayStoreException第 054 篇提到没做// GoString 处理 null第 056 篇提到会崩② 实现更多 println 类型System.out.println(newObject());// (Ljava/lang/Object;)VSystem.out.println(newint[]{1,2,3});// ([I)VSystem.out.println(newchar[]{a});// ([C)V这些都走PrintStream.println(Object)或println(char[])理论上 jvmgo 已经能跑因为printlnhack 删了走真正的 JDK 代码。需要验证。③ 实现更多本地方法方法难度用途System.currentTimeMillis()⭐让时间戳相关代码能跑System.nanoTime()⭐同上Object.notify()/wait()⭐⭐需要线程Math的一批方法⭐sqrt/abs/max等System.getProperty()⭐⭐系统属性Class.forName0()⭐⭐真正的反射加载④ 完善FileOutputStream书里明确说如果读者属于完美主义者很容易会发现这里的小瑕疵FileOutputStream应该可以处理任何文件而不仅仅是标准输出。没错不过本章就到此为止了感兴趣的读者可以继续完善代码让FileOutputStream可以支持任何文件。funcwriteBytes(frame*rtda.Frame){// 现在直接 os.Stdout.Write(goBytes)// 改进根据 this 里的 FileDescriptor 决定写到哪儿// fd 1 → stdout// fd 2 → stderr// 其他 → 真实文件需要在 open0 时保存 *os.File}1.2 进阶级几百行代码⑤ 把指令抛的异常改成真正的异常对象第 065 篇指出的最大局限// 现在func(self*IDIV)Execute(frame*rtda.Frame){// ...ifv20{panic(java.lang.ArithmeticException: / by zero)}}// 改进func(self*IDIV)Execute(frame*rtda.Frame){// ...ifv20{panicArithmeticException(frame)// 抛真正的异常对象}}funcpanicArithmeticException(frame*rtda.Frame){exClass:frame.Method().Class().Loader().LoadClass(java/lang/ArithmeticException)exObj:exClass.NewObject()// 调 init、填 detailMessage// 然后走 findAndGotoExceptionHandler}难点需要一个通用的创建异常对象并抛出的辅助函数涉及加载异常类创建对象调构造函数可能要传 message设置栈轨迹fillInStackTrace走findAndGotoExceptionHandler涉及约 30 处调用点但改动模式统一。⑥ 实现 vtable 优化第 049 篇讲过invokevirtual可以用 vtable 把方法查找从 O(n) 降到 O(1)。typeClassstruct{// ...vtable[]*Method// 【新增】虚方法表}func(self*Class)buildVTable(){// 1. 复制超类的 vtable// 2. 遍历自己的方法// - 重写了超类方法 → 替换对应下标// - 新方法 → 追加到表尾// 3. 记录 namedescriptor → 下标的映射}难点接口方法不能用 vtable第 049 篇讲过需要单独的 itable。⑦ 实现monitorenter/monitorexit需要线程和锁是多线程支持的第一步。⑧ 完善Class.getName()对数组的处理第 059 篇提过我们的JavaName()是简单粗暴地把所有/换成.对数组碰巧正确但不严谨。1.3 挑战级需要新子系统⑨ GC第 069 篇说过 jvmgo 靠 Go 的 GC。要自己实现需要自己管理堆不能用 Go 的对象要分配一大块[]byte当堆内存对象头设计Mark Word对象引用定位不能用 Go 的指针要用偏移量Roots 枚举从线程栈、静态变量出发标记-清除 / 复制 / 分代算法这基本等于重写 jvmgo 的内存层——工程量大但做完对理解 GC 极有帮助。⑩ 多线程Thread结构体每个线程一个 JVM 栈Thread.start0()本地方法 → 起一个 goroutinemonitorenter/monitorexit锁Object.wait()/notify()/notifyAll()Java 内存模型volatile的内存语义⑪ JIT最难的。需要热点探测方法调用计数器 回边计数器中间表示IR如 SSA 形式优化遍常量折叠、内联、逃逸分析、循环优化机器码生成或者退化成编译到 Go 代码再go build)一个务实的做法用 Go 的go/ssa包做 IR然后把热点方法编译成 Go 源码动态加载Go 不支持动态加载得用插件机制或者干脆生成.go文件重新编译。1.4 扩展清单总结难度 任务 预估工作量 ───────────────────────────────────────────────────────── 入门 ① 补错误检查 ~50 行 ② 更多 println 类型 ~20 行 ③ 更多本地方法 ~200 行 ④ FileOutputStream 支持真实文件 ~100 行 ───────────────────────────────────────────────────────── 进阶 ⑤ 指令抛异常 → 真异常对象 ~300 行30 处调用点 ⑥ vtable / itable 优化 ~200 行 ⑦ monitorenter / monitorexit ~150 行 ⑧ Class.getName() 数组处理 ~30 行 ───────────────────────────────────────────────────────── 挑战 ⑨ 自己实现 GC ~2000 行重写内存层 ⑩ 多线程 ~1500 行 ⑪ JIT 编译器 ~5000 行 ─────────────────────────────────────────────────────────做完入门级jvmgo 会更健壮做完进阶级它会接近一个真正可用的教学 JVM做挑战级那就是另一个项目了。二、JVM 的五大高级特性方向2.1 垃圾回收GC① 对象存活判定算法思路问题引用计数每个对象记被引用的次数循环引用无法回收可达性分析从 GC Roots 出发能到达的存活需要 STWStop The WorldGC Roots 包括虚拟机栈栈帧的局部变量表中引用的对象方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中 JNI 引用的对象② 三种基础算法算法做法优点缺点标记-清除标记存活对象清除其余简单内存碎片复制存活对象复制到另一块内存无碎片、分配快指针碰撞内存利用率只有 50%标记-整理标记后把存活对象移到一端无碎片、利用率高移动对象成本高③ 分代收集基于弱分代假说绝大多数对象朝生夕死。堆 ├── 新生代 (Young Gen, 1/3) │ ├── Eden (8/10) ← 新对象在这里出生 │ ├── Survivor From (1/10) │ └── Survivor To (1/10) │ Minor GCEden From 存活的 → 复制到 To年龄 1 │ 年龄到 15默认→ 晋升老年代 │ └── 老年代 (Old Gen, 2/3) Major GC / Full GC标记-整理 或 标记-清除 JDK 8 之前还有永久代 PermGenJDK 8 改成元空间 Metaspace用本地内存④ 现代收集器演进收集器年代特点Serial1998单线程、STWParallelJDK 1.4多线程并行、吞吐量优先CMSJDK 1.4并发标记清除低延迟已废弃JDK 14 移除G1JDK 7Region 化、可预测停顿模型JDK 9 起默认ZGCJDK 11染色指针 读屏障停顿 10ms支持 TB 级堆ShenandoahJDK 12Brooks 指针 读屏障低延迟EpsilonJDK 11不做 GC的 GC用于性能测试⑤ 关键技术的直觉三色标记Tri-color Marking 白色未访问 可能是垃圾 灰色已访问但字段未扫描完 黑色已访问且字段已扫描完确定存活 并发标记的问题 用户线程在标记过程中修改引用关系 → 对象消失问题本该存活的被当成垃圾 两个条件同时满足才会出 bug ① 赋值器插入了一条从黑色到白色的新引用 ② 赋值器删除了所有从灰色到该白色的引用 解决方案破坏其中一个 增量更新CMS记录新插入的引用重新扫描 原始快照 SATBG1记录删除的引用按快照扫描推荐实践先实现一个 Serial 标记清除跑通再实现复制算法最后加分代。2.2 JIT 编译器① 为什么需要 JIT解释器每条指令都要取指 → 解码 → 分派 → 执行。这个开销可能是实际工作的几倍。JIT 的思路把热点代码频繁执行的方法/循环编译成机器码直接执行。执行次数 │ ╱ 编译后的机器码 │ ╱ │ ╱ │ ╱ │ ╱ ←── 编译阈值 │ ╱ │ ╱ 解释执行 └────────────────────────────────────▶ 时间② 分层编译Tiered CompilationHotSpot 有 5 个层次层级内容0解释执行1C1 编译简单优化不带 profiling2C1 编译带方法调用计数和回边计数3C1 编译带完整 profiling4C2 编译激进优化基于 profiling 数据C1Client Compiler编译快优化保守适合桌面应用。C2Server Compiler编译慢优化激进内联、逃逸分析、循环展开适合服务端。③ 关键优化技术优化思路方法内联把被调用方法的代码复制到调用点消除调用开销最重要的优化逃逸分析分析对象是否逃出方法作用域 → 决定是否栈上分配/标量替换/锁消除公共子表达式消除a*b a*b→t a*b; t t数组边界检查消除如果编译器能证明下标不越界去掉检查循环展开 / 循环外提减少循环控制开销分支预测按 profiling 数据把热分支放前面④ 去优化DeoptimizationJIT 的优化基于假设比如这个类只有这一个实现。当假设被打破加载了新类出现多态必须退回到解释执行——这叫 deoptimization。这是 JIT 最复杂也最容易被忽视的部分。推荐实践先实现热点方法 → 编译到 Go 源码 → 用go/plugin加载比直接生成机器码简单得多。2.3 多线程与内存模型① Java 内存模型JMM线程 A 线程 B │ │ 工作内存 A 工作内存 B (CPU 缓存/寄存器) (CPU 缓存/寄存器) │ │ └──────────▶ 主内存 ◀───────────────┘ (堆、方法区) 问题工作内存和主内存之间不是实时同步的 → 可见性问题 → 有序性问题编译器和 CPU 会重排序② happens-before 规则JSR-133如果 A happens-before B那么 A 的操作对 B 可见。八条规则程序次序规则一个线程内前面的操作 happens-before 后面的监视器锁规则unlock happens-before 后续的 lockvolatile 规则volatile 写 happens-before 后续的 volatile 读线程启动规则Thread.start()happens-before 线程内任何操作线程终止规则线程内任何操作 happens-beforeThread.join()返回线程中断规则interrupt()happens-before 被中断线程检测到中断对象终结规则对象构造完成 happens-beforefinalize()传递性A hb BB hb C → A hb C③ 锁优化HotSpot 的黑魔法无锁 → 偏向锁 → 轻量级锁 → 重量级锁 ↑ ↑ ↑ 只有一个线程 有竞争但很快 竞争激烈 记个线程ID CAS 自旋 OS 互斥量阻塞偏向锁第一个获取锁的线程直接在对象头记下线程 ID后续加锁不用 CASJDK 15 起默认关闭JDK 18 移除轻量级锁用 CAS 把对象头的 Mark Word 换成指向锁记录的指针自旋锁 / 自适应自旋竞争时先空转一会避免线程切换开销锁消除逃逸分析发现不可能共享直接去掉锁锁粗化连续的加锁解锁合并成一次④ 对 jvmgo 的意义如果要在 jvmgo 里支持多线程每个Thread一个独立的 JVM 栈现在是全局单栈monitorenter/monitorexit需要一个全局锁表volatile字段的访问需要绕过 Go 的编译器优化用sync/atomicThread.start0()→ 起一个 goroutine注意Go 的内存模型和 Java 的不同Go 用 channelJava 用锁和 volatile直接映射会有语义差异。2.4 语言特性的 JVM 支持① 泛型擦除ListStringlistnewArrayList();list.add(a);Stringslist.get(0);编译后invokeinterface List.add:(Ljava/lang/Object;)Z ← String 没了 checkcast java/lang/String ← 插入的强制转换泛型在 JVM 层面几乎不存在——类型参数被擦除成Object或上界只在需要时插入checkcast。这和第 060 篇的自动装箱是同一类现象Java 的很多语言特性在 JVM 层面被降维成了更基础的操作。保留下来的Signature属性用于反射获取泛型信息、LocalVariableTypeTable属性。② Lambda 与 invokedynamicRunnabler()-System.out.println(hi);编译后0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;invokedynamic的四步① 第一次执行到 invokedynamic → 调用 Bootstrap Method引导方法 ② Bootstrap Method 返回一个 CallSite调用点对象 → JDK 用 LambdaMetafactory.metafactory() → 它在运行时生成一个实现 Runnable 的类 ③ CallSite 持有一个 MethodHandle方法句柄 → 指向生成类的方法 ④ 后续执行直接调用 MethodHandle → 不再走 Bootstrap为什么要用这么复杂的方式因为 Lambda 的实现策略可以在运行时决定——JDK 可以选择生成一个匿名类用 MethodHandle 直接调用缓存实例无捕获的 Lambda而不需要改 JVM。这就是invokedynamic的核心价值把分派策略从 JVM 规范移到用户代码。③ 模块化JPMS, Java 9module-info.class不是类是一个特殊的 class 文件模块路径--module-path取代类路径类加载器大改Bootstrap/Platform/Application三层强封装模块外的包默认不可访问对 JVM 的影响主要是类加载器和访问控制层面字节码本身没变。2.5 其他④ 值类型Valhalla 项目Java 的未来特性inline class/primitive class让对象可以像基本类型一样按值传递、无对象头、可扁平化存储到数组。对 JVM 的影响需要新的字节码指令withfield等和新的描述符语法Qxxx;vsLxxx;。⑤ 协程Loom 项目——见下一节。三、现代 JVM两个值得关注的新方向3.1 GraalVM 与 Truffle多语言虚拟机GraalVM 的三个部分┌──────────────────────────────────────────────────┐ │ GraalVM │ ├──────────────────────────────────────────────────┤ │ ① Graal 编译器 │ │ 用 Java 写的 JIT 编译器替代 C2 │ │ JDK 10 起可插拔JDK 17 后独立发展 │ ├──────────────────────────────────────────────────┤ │ ② Truffle 语言实现框架 │ │ 用 Java 写 AST 解释器 │ │ Partial Evaluation部分求值→ 自动获得 JIT │ ├──────────────────────────────────────────────────┤ │ ③ SubstrateVMNative Image │ │ 把 Java 程序 AOT 编译成独立可执行文件 │ │ 启动快、内存小无需 JVM │ └──────────────────────────────────────────────────┘Truffle 的核心思想这个对理解现代 VM 极有价值你只需要写一个 AST 解释器Graal 会通过部分求值自动把它编译成高效的机器码。传统方式写解释器 → 手写 JIT 编译器重复劳动且容易不一致。Truffle 方式1. 用 Java 写一个 AST 解释器比如 JavaScript 的解释器 node.execute() → 递归遍历 AST 节点 2. Truffle 框架在运行时做节点重写node rewriting 比如一个加法节点如果一直操作 int就特化成 IntAddNode 3. Graal 对解释器 特定 AST做部分求值 把解释 AST这个计算过程展开、常量折叠 → 得到专门执行这段 AST 的机器码 4. 结果自动获得了 JIT 的效果“自举”self-hosting是这里的关键Graal 编译器用 Java 写 → 运行在 JVM 上 → 编译 Java 代码包括它自己 → 编译后的 Graal 更快 → 编译出更快的 Graal 这就是所谓的 meta-circular支持的语言JavaScript、Python、Ruby、R、LLVM bitcode能跑 C/C、WASM。而且多语言可以互操作——在 JS 里直接调用 Python 的对象。对 jvmgo 的启发如果你要支持一门新语言不用像我们这样手写字节码编译器——用 Truffle 写个 AST 解释器就行JIT 自动就有了。3.2 虚拟线程Virtual ThreadsJava 21 正式问题Java 的线程是平台线程1:1 映射到 OS 线程创建成本高默认栈 1MB一台机器最多几千个。虚拟线程JVM 管理的轻量线程M:N 映射到少量平台线程。传统 Java 线程 ──1:1──▶ OS 线程 1000 个连接 1000 个 OS 线程 1GB 栈内存 虚拟线程Java 21 虚拟线程 ──M:N──▶ 平台线程载体线程──1:1──▶ OS 线程 100 万个虚拟线程 少量 OS 线程核心机制连续传递continuation虚拟线程在阻塞操作I/O、Lock、sleep时会把自己的栈卸载unmount到堆上释放载体线程I/O 就绪后再装载mount回去。虚拟线程 V1 运行在载体线程 C1 上 ↓ 遇到阻塞 I/O V1 的栈帧 → 保存到堆Continuation 对象 C1 空闲 → 去跑 V2 ↓ V1 的 I/O 就绪 V1 的栈帧 ← 从堆恢复 C1 或 C2 继续跑 V1对 JVM 的影响Continuation类JDK 内部jdk.internal.vm.Continuation栈的表示变化栈帧不再必须在连续内存上可以在堆上所有阻塞点都要改造Object.wait()、Thread.sleep()、LockSupport.park()、Socket I/O 都要支持卸载/装载新的调度器默认用ForkJoinPool作为载体线程池一个重要的注意点虚拟线程不是更快的线程——同样的计算任务虚拟线程和平台线程速度一样。它的价值是更多的线程让你能用一个请求一个线程的同步写法获得异步的性能。而且虚拟线程不适合CPU 密集型任务没有收益还多一层调度开销也不适合synchronized块里做长时间阻塞JDK 21 之前虚拟线程在synchronized里会被钉住——pin 住载体线程JDK 24 通过 JEP 491 解决了。对 jvmgo 的启发如果你要给 jvmgo 加多线程可以考虑直接做成虚拟线程模型——用 goroutine 天然就是 M:N语义上更接近虚拟线程。四、学习路线继续往下走读什么4.1 路线图第1阶段理解 JVM 结构本系列已完成 《自己动手写Java虚拟机》 ← 你在这里 《深入理解Java虚拟机》第 2、3 部分 ← 系统学习内存管理和 GC 第2阶段深入 GC 《The Garbage Collection Handbook》 ← GC 圣经 《深入理解Java虚拟机》第 3 章 ← 中文、更易读 实践读 G1 / ZGC 的源码注释用 JFR 观察真实 GC 日志 第3阶段深入并发 《Java Concurrency in Practice》 ← 并发圣经 JSR-133 规范 ← JMM 原文值得硬啃 《深入理解Java虚拟机》第 5 部分 ← 线程安全与锁优化 第4阶段深入编译 《编译器设计》Engineering a Compiler← 前端 优化基础 《高级编译器设计与实现》Whale Book ← 优化后端 Graal 的论文和源码 ← 现代 JIT 的实践 第5阶段OpenJDK 源码 hotspot/src/share/vm/ ← C 的 HotSpot ├── memory/ ← GC ├── oops/ ← 对象表示 ├── interpreter/ ← 模板解释器 ├── runtime/ ← 运行时线程、锁、反射 └── compiler/ ← C1 / C2 jdk/src/java.base/ ← JDK 类库Java 代码易读4.2 一些具体的建议① 从读 JDK 类库源码开始比读 HotSpot 的 C 简单得多而且收获直接java.lang.String← 不可变、哈希缓存、CompactStringsjava.lang.Integer← 缓存、装箱java.util.HashMap← 红黑树、扩容、哈希扰动java.util.ArrayList← 动态数组、fail-fast java.util.concurrent.* ←AQS、CAS、各种并发容器② 用工具观察真实 JVM# 查看字节码javap-c-pMyClass# 查看 GCjava-Xlog:gc* MyApp# 查看 JIT 编译java-XX:PrintCompilationMyApp# 查看类加载java-verbose:classMyApp# JFRJava Flight Recorder—— 最强的诊断工具java-XX:StartFlightRecordingduration60s,filenamerec.jfr MyApp③ 对照规范读JVM 规范 Java SE 8 版本系列依据的版本Java 语言规范规范是最好的字典——遇到不确定的行为查它④ 自己动手做小实验// 验证字符串池Stringaabc;Stringbabc;System.out.println(ab);// 验证 Integer 缓存Integerx127,y127;System.out.println(xy);Integerm128,n128;System.out.println(mn);// 验证 finally 的执行顺序// 验证自动装箱的陷阱// 用 jclasslib 看真实的字节码和异常表4.3 最后一点写完 70 篇回头看第 1 篇的HelloWorldpublicclassHelloWorld{publicstaticvoidmain(String[]args){System.out.println(Hello, world!);}}它现在跑在两万个 JVM 上Oracle/Sun 的 HotSpot几百万行 C你自己写的 jvmgo8000 行 Go输出一模一样Hello, world!这就是自己动手的意义——你不需要造得更好你只需要亲手造一遍。造完之后你看 Java 的眼光就完全不同了看到Integer a 100你会想到valueOf和IntegerCache的-128~127看到try-catch你会想到异常表和栈展开看到new你会想到dup和invokespecial看到hello world你会想到StringBuilder和arraycopy看到多态你会想到LookupMethodInClass(ref.Class(), ...)这些看不见的东西就是这 70 篇想给你的。本篇小结本系列收官篇三个部分jvmgo 扩展清单按难度分级——入门级补错误检查、更多 println 类型、更多本地方法、FileOutputStream支持真实文件书里明确留给读者。进阶级把指令抛的异常改成真异常对象最大局限约 30 处调用点、vtable / itable 优化、monitorenter/monitorexit、Class.getName()数组处理。挑战级自己实现 GC要重写内存层、多线程、JIT。JVM 五大高级特性——GC引用计数 vs 可达性分析 / 标记清除 vs 复制 vs 标记整理 / 分代假说 / Serial→Parallel→CMS→G1→ZGC / 三色标记与增量更新 vs SATBJIT分层编译 C1C2 / 方法内联 / 逃逸分析 / 去优化多线程JMM / happens-before 八条规则 / 偏向锁→轻量级锁→重量级锁 / 锁消除与锁粗化语言特性泛型擦除 checkcast/ Lambda 与invokedynamic的四步 Bootstrap / 模块化 JPMS以及 Valhalla 值类型。现代 JVM 的两个方向——GraalVMGraal 编译器 Truffle 语言框架 Native Image核心是写 AST 解释器就能自动获得 JIT的自举魔法和Java 21 虚拟线程M:N 映射、continuation 的栈卸载/装载、不适合 CPU 密集任务。学习路线——五阶段JVM 结构 → GC → 并发 → 编译 → OpenJDK 源码。具体建议从读 JDK 类库源码String/HashMap/AQS开始用javap/ JFR 等工具观察真实 JVM对照规范自己做小实验验证。系列至此完结。从HelloWorld开始到HelloWorld结束——中间是 11 章、约 8000 行代码、70 篇注解。上一篇【第69篇】系列总结系列完结 感谢阅读