Ajit Singh 2026-08-11 发在个人博客 singhajit.com 上的一篇 JVM 运行时入门,页面标称 17 分钟读完。核心主张是这台机器可以拆成三块:class loader 把 .class 装进内存并准备好,runtime data areas 安排对象和调用栈,execution engine 先用 interpreter 逐条执行、再由 JIT 把热方法编译成 native code,GC 在旁边回收内存。

从源码到 bytecode#

JVM 从不读 .java 文件,它读 bytecode。javac App.java 把源码翻译成 bytecode 写进 .class,这套指令紧凑且平台无关,不是 Intel 或 ARM 的机器码,而是「一台假想计算机」的机器码——那台假想机器就是 JVM。

原文给了一个最小的方法:

int add(int a, int b) {
    return a + b;
}

javap -c 看,javac 生成的 bytecode 大致是这样:

iload_1      // 把局部变量 a 压到 operand stack 上
iload_2      // 把局部变量 b 压到 operand stack 上
iadd         // 弹出两个,相加,结果压回栈
ireturn      // 返回栈顶

这里没有 CPU 寄存器。JVM 是一台 stack-based machine:指令靠 push/pop operand stack 传值,不像 eaxr0 那样直接点名寄存器。这个设计让 bytecode 保持简单、可移植。

可移植性正是重点。App.class 的字节内容,在 Mac 上编译和在 Linux 服务器上编译完全一致,只有 JVM 本身要为每个平台各编一份。这就是 Write Once, Run Anywhere:同一个 JAR 不用重新编译,笔记本和云容器都能跑。

JVM、JRE、JDK 这三个名词总被混用,原文给的划分是:JVM 是跑 bytecode 的引擎;JRE(Java Runtime Environment)是 JVM 加标准类库;JDK(Java Development Kit)是 JRE 加 javacjar 和各类 profiler 这些开发工具。用 JDK 构建,跑在 JVM 上。

三个子系统#

原文先把整台机器画成一张图,后面每一节只是把其中一个框放大。

graph TD subgraph CL["Class Loader Subsystem"] L1["Loading"] --> L2["Linking"] --> L3["Initialization"] end subgraph RDA["Runtime Data Areas"] H["Heap(对象,所有线程共享)"] MA["Method Area / Metaspace(class 元数据)"] ST["JVM Stacks(每线程一个)"] PC["PC Registers"] end subgraph EE["Execution Engine"] INT["Interpreter"] JIT["JIT Compiler(C1 + C2)"] GC["Garbage Collector"] end CL --> RDA RDA --> EE EE -->|"调用 native 库"| JNI["JNI"] classDef loader fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#0f172a classDef mem fill:#e0f2fe,stroke:#0891b2,stroke-width:2px,color:#0f172a classDef engine fill:#c8e6c9,stroke:#16a34a,stroke-width:2px,color:#0f172a classDef bridge fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#0f172a class L1,L2,L3 loader class H,MA,ST,PC mem class INT,JIT,GC engine class JNI bridge

Class loader:加载、链接、初始化#

Java 不会一次性把整个程序装进内存,class 是懒加载的,在第一次真正被引用时才加载。JVM 遇到没见过的类,class loader 会走三个阶段。

Loading:找到 .class,读它的二进制内容,解析 constant pool 和元数据,把信息存进 method area;同时在堆上创建唯一一个 java.lang.Class 对象代表这个类型。用反射时摸到的就是这个 Class 对象,而反射正是各类框架的底座。

Loading 用的是 parent-first 委派,class loader 排成一条链:

  • Bootstrap class loader 加载 java.base 里的核心类,比如 StringObject。它由 native 代码实现,在链顶。
  • Platform class loader 加载 java.sqljava.xml 这些标准平台模块。
  • Application(system)class loader 加载应用 classpath 或 module path 上的类。

一个 loader 收到请求先交给 parent,只有 parent 加载不到才自己动手。这层委派是个安全设计:它挡住有人塞一个假的 java.lang.String 把真的盖掉。

graph TD App["Application Class Loader(你的 classpath)"] -->|"先向 parent 委派"| Plat["Platform Class Loader(java.sql、java.xml)"] Plat -->|"先向 parent 委派"| Boot["Bootstrap Class Loader(java.base 核心类)"] Boot -.->|"找不到,交回下一层加载"| Plat Plat -.->|"找不到,交回下一层加载"| App classDef top fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#0f172a classDef mid fill:#e0f2fe,stroke:#0891b2,stroke-width:2px,color:#0f172a classDef bot fill:#c8e6c9,stroke:#16a34a,stroke-width:2px,color:#0f172a class Boot top class Plat mid class App bot

Linking 把加载好的 class 准备到能跑,分三步:

  • Verification 检查 bytecode 是否合法、是否安全:operand stack 不会下溢、类型对得上、没有指令能跳到方法外面。这就是为什么随便拼一段字节丢给 JVM 并不会跑起来——坏 bytecode 在这一关就被 VerifyError 拒掉。
  • Preparation 给 static 字段分配内存并赋默认值(0falsenull),真正的值还没赋。
  • Resolution 把 constant pool 里的符号引用(「PrintStream 上的 println 方法」这样的名字)换成指向实际内存位置的直接引用。

Initialization 最后跑这个类的静态初始化和 static 块,按源码里的出现顺序给静态字段赋真值。每个类只发生一次,而且 JVM 保证这一步线程安全。到这儿,一个加载并链接过的类才变成真正可用的类。

Runtime data areas:哪些内存共享,哪些私有#

class 加载完,JVM 需要地方放对象、变量、方法调用和 class 元数据,于是把内存划成几块定义明确的 runtime data areas。原文的说法是:要排查内存问题、把 JVM 的内存管好,这几块区域最值得搞懂。有的区域所有线程共享,有的每线程私有。

Heap 是那个大的共享池,所有对象和数组都在里面。写 new User(),对象就分配在堆上。所有线程共用一个堆,它也是 GC 负责的区域。人们说调 -Xmx(最大堆大小)或者追内存泄漏,说的就是这里。java.lang.OutOfMemoryError: Java heap space 意味着这块满了而 GC 腾不出足够空间。

Method area 存每个类的信息:类结构、方法的 bytecode、字段细节、runtime constant pool。JDK 8 拿掉固定大小的 PermGen 之后,这块落在 Metaspace,位于主堆之外的 native memory,所以 OutOfMemoryError: PermGen space 成了历史名词,接棒的是 OutOfMemoryError: Metaspace

JVM stack 每线程一条。每调用一个方法,JVM 就往这条线程的栈上压一个新 frame,frame 里是这个方法的局部变量和它的 operand stack(前面那些 iloadiadd 用的暂存空间)。方法返回,frame 弹掉。递归太深或者无限递归会抛 StackOverflowError,原因就是一口气压帧压到栈没地方。

因为每线程栈是私有的,局部变量天生线程安全。共享堆上的对象不是,这是不少并发 bug 的根源。

每线程还有一个 PC register,记着当前执行到哪条 bytecode 指令;以及一个 native method stack,在 Java 代码通过 JNI(Java Native Interface)调进 native 的 C、C++ 库时使用。

执行引擎:interpreter 起步,JIT 接手#

bytecode 加载好了,内存摆好了,轮到 execution engine。原文认为巧的地方在这儿:JVM 既不是单纯解释执行,也不是单纯提前编译,它两个都做,而且是在运行时临时决定用哪个。

一开始 JVM 解释 bytecode:读一条指令,照做,再读下一条。逐条解释很慢,但它零预热,程序几乎立刻就能启动。interpreter 因此被称为 Tier 0。问题在重复:一个方法跑一百万次,就重复做一百万次同样的翻译。

程序跑起来的同时,JVM 在给它做 profile:统计每个方法被调多少次、循环转了多少圈。跨过阈值的方法被判定为 hot,JIT(Just-In-Time)编译器就把这个方法的 bytecode 直接编成针对你这颗 CPU 优化过的 native machine code。下次再调用,JVM 跑的是编译好的快版本,不再解释。

HotSpot(标准 JVM)用分层编译(tiered compilation),配两个编译器:

  • C1(client compiler) 编译得快,优化较轻。它让热代码尽快拿到 native 速度,同时继续收集 profile 数据。
  • C2(server compiler) 编译得慢,但优化激进:方法 inlining、loop unrolling、dead code elimination,还有 escape analysis——它甚至能让短命对象不必在堆上分配。
graph LR T0["Tier 0:Interpreter
立刻启动,顺便收集 profile"] -->|"方法变温"| C1["C1 Compiler
快速产出 native code
轻量优化"] C1 -->|"方法变热"| C2["C2 Compiler
激进优化
inlining、escape analysis"] C2 -.->|"假设破了"| T0 classDef interp fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#0f172a classDef c1 fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#0f172a classDef c2 fill:#c8e6c9,stroke:#16a34a,stroke-width:2px,color:#0f172a class T0 interp class C1 c1 class C2 c2

这个设计解释了一个 Java 开发普遍会注意到的现象:应用刚起来有点慢,然后越来越快,最后稳下来。那就是 JIT 在预热,热方法从解释执行挪到 C1、再挪到 C2。benchmark 一定要先 warmup 再测量,原因也在这里。

图上回到 interpreter 的那条虚线是 deoptimization。C2 会做乐观假设,比如「这个调用点永远指向同一个类」。假设被证伪,JVM 就把编好的代码丢掉,退回解释执行,然后带着更好的信息重新编译。这种先推测、错了再退回来的能力,是一个好的 JIT 在长时间运行的负载上有时能追平甚至超过静态编译语言的重要原因。

原文另外提了一句 AOT(ahead-of-time)编译:GraalVM Native Image 在运行之前就把 bytecode 编成 native 可执行文件,拿峰值吞吐换几乎即时的启动和更低的内存占用。生命周期短的 serverless 函数更适合这个思路,长时间运行的服务通常仍然是 JIT 更擅长。

GC:分代回收#

在 C 这类语言里,自己分配就得自己释放:忘了释放是泄漏,释放早了直接崩。JVM 用 garbage collection 把这份负担整个接走——你只管创建对象,GC 判断哪些对象已经不可达,回收它们在堆上占的内存。

多数 JVM 的收集器是分代的,依据是一个很朴素的观察:大部分对象活得很短。一个请求处理函数造出一堆临时对象,几乎立刻变成垃圾;而另一些对象(cache、连接池)活满整个进程生命周期。

graph LR Eden["Eden:新对象出生在这里"] -->|"minor GC"| Survivor["Survivor spaces:暂时活下来的对象"] Survivor -->|"熬过若干轮 minor GC 后晋升"| Old["Old generation:长命对象"] Old -->|"major / full GC"| Freed["内存被回收"] classDef young fill:#c8e6c9,stroke:#16a34a,stroke-width:2px,color:#0f172a classDef old fill:#e0f2fe,stroke:#0891b0,stroke-width:2px,color:#0f172a classDef done fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#0f172a class Eden,Survivor young class Old old class Freed done

新对象出生在 young generation(具体是 Eden)。便宜又快的 minor GC 会频繁扫这一块,留下幸存者,把待得够久的对象晋升进 old generation。old generation 用更贵的 major(或 full)GC 收集,频率低得多。这样划分内存,意味着 GC 把大部分力气花在小块 young 区域(垃圾的大头在这儿),而不是每次都扫整堆。

原文说默认收集器是 G1(Garbage-First),从 JDK 9 起它接手了这个位置;G1 把堆切成 region,目标是让暂停短且可预期。延迟敏感的系统有 ZGCShenandoah 这类低暂停收集器,按原文的说法,即使堆很大也能把 GC 暂停压到几毫秒。取舍始终在吞吐、延迟、内存占用三者之间。所谓 Java 性能调优,很大程度上就是按自己的负载挑对收集器和堆大小,然后实测。

java App 这一条命令里发生了什么#

原文最后用一条真实命令把前面几块串起来。跑的是:

java App

程序是:

public class App {
    public static void main(String[] args) {
        System.out.println(new Greeter().greet("world"));
    }
}

class Greeter {
    String greet(String name) {
        return "Hello, " + name;
    }
}

原文列出的 JVM 动作顺序:

  1. 启动。 java launcher 起一个 JVM 进程,向 class loader 要 App
  2. 加载并链接 App application class loader 读 App.class,校验 bytecode,准备静态字段,解析引用。因为 main 用到 System.out,JVM 还会(通过 bootstrap loader)加载 SystemString 这些核心类,前提是它们还没在内存里。
  3. 初始化 App,在 main 线程上开始执行 main,为 main 压一个新 frame。
  4. new Greeter() 第一次触发 Greeter 的加载和初始化,然后在堆上分配一个 Greeter 对象。
  5. 调用 greet("world")greet 往 main 线程的栈上压一帧,装着 name 这个局部变量。字符串拼接在堆上造出一个新的 String
  6. 先解释,够热才编。 这种一次性程序全部在 interpreter 里跑完,没有任何东西热到值得 JIT。换成真实服务器,像 greet 这样被调用几百万次的方法会先被 C1、再被 C2 编成 native code。
  7. 打印并返回。 println 执行,方法返回时帧依次弹出,main 跑完 main 线程结束。
  8. GC 如果发生过,会在没有任何引用指向那些短命的 GreeterString 之后回收它们。

原文的结论是:从一行 demo 到巨型微服务,每个 Java 程序走的都是这个形状。

可调的参数与自带的工具#

原文的说法是不调 JVM 也能用它,但性能重要时得知道有哪些杠杆。常见参数:

  • -Xms-Xmx 设堆的初始和最大大小。生产环境把两者设成一样,可以避开扩容带来的暂停。
  • -XX:+UseG1GC-XX:+UseZGC 之类选垃圾收集器。
  • -XX:MaxMetaspaceSize 给 Metaspace 封顶,这样 class 加载泄漏不会吃光 native 内存。
  • -Xss 设每线程栈大小。

观测方面,JDK 自带的工具读的正是上面这些内存区域:

  • jpsjcmd 列出并向运行中的 JVM 发命令。
  • jstat 报当前的 GC 和堆统计。
  • jmap 和 heap dump 用来看堆里被什么占着。
  • Java Flight Recorder(JFR)配 Mission Control 提供开销低、可用于生产环境的 profiling。
  • jdb 通过 VM 暴露的同一批 hook,可以暂停一个跑着的 JVM、打断点、看现场状态。

原文认为最要紧的习惯是:先量再调。没有数据就猜参数,通常只会更糟。看 GC 日志和真实吞吐,一次只改一个东西,改完再量。

原文纠正的几个误区#

  • 「Java 是解释执行的,所以慢。」 原文的评价是最多算半对:Java 以解释执行起步,但热代码会被 JIT 编成 native machine code,长期运行的 Java 服务通常能跑到接近 C++ 的性能。
  • 「有 GC 就没有内存泄漏。」 GC 只回收不可达的对象。往一个 static Map 里只加不删,那些对象永远可达——这就是泄漏,一样会抛 OutOfMemoryError
  • 「堆越大越好。」 巨大的堆可能带来更长的 GC 暂停和浪费的内存。合适的尺寸是负载真正需要的那个,靠量出来。
  • 「JVM 和 JDK 是一回事。」 JVM 跑 bytecode,JDK 是你用来构建的整套工具箱,JVM 只是里面的一个组件。
  • 「bytecode 就是机器码。」 bytecode 面向的是假想的 JVM,不是你的 CPU。真正产出机器码的是 JIT。

几点笔记(个人观点)#

  • 这篇把 parent-first 委派讲得干净,但只讲了默认行为。我自己在 fat jar 上踩到的两个坑都发生在它没写的部分:一是有人覆写 loadClass 把委派顺序打乱,二是 TCCL 和「哪个 class loader」根本不是一回事。同一条 Class.forName 在 IDE 里好好的、打成 fat jar 就在部分线程上找不到类,查的正是这两件事,见 Spring Boot fat jar 下 Class.forName 只在部分线程上找不到类Kafka producer 构造失败:fat jar 下 commonPool 抢走了第一次类初始化。读这类入门图的时候得知道它省略了什么,否则到了容器里还是会懵。
  • GC 暂停的数字,这篇两头都不准。原文说 ZGC、Shenandoah 「即使堆很大也能把暂停压到几毫秒」,而 JEP 439: Generational ZGC(JDK 21)的目标写的是 “Pause times should not exceed 1 millisecond”,Motivation 里还有一句对比:ZGC 的暂停 “consistently measured in microseconds”,G1 则是 “range from milliseconds to seconds”。也就是说 ZGC 被写保守了、G1 被写乐观了。真要选 GC,我会照 JEP 和 HotSpot GC Tuning Guide 的说法来,不会拿这篇当依据。
  • int add(int a, int b) 编出来是 iload_1iload_2,原文没解释为什么不是从 0 开始:这是个实例方法,slot 0 是 this,参数从 1 起。我在 javac 25.0.2 上把同一个方法加上 staticjavap -c,指令确实变成 iload_0iload_1。照抄原文那段去读自己 class 的 javap -c 输出,这是第一个会卡住人的地方。
  • 原文把分层编译简化成「C1、C2 两个编译器」。HotSpot 实际有 5 档,jdk-25-ga 上的 CompLevel 定义写得很明确:0 是 interpreter,1(simple)、2(limited profile)、3(full profile)都标着 C1,4(full optimization)那一行的注释是 C2 or JVMCI。档位和编译器并不是一对一。要动 -XX:TieredStopAtLevel 这类参数就得按 5 档来想,按「两个编译器」想会设错。
  • 原文的结构是典型 SEO 写法:Quick Answer、Key Takeaways、每个术语挂一个 glossary 页。内容本身我没看出硬伤,但结论的强度普遍偏高,上面那条「接近 C++」就是全篇没给任何测量或链接的一句。我把它当索引和查漏补缺的清单用,不当依据。

参考资料#