假设有一座高度发达的“程序城市”,每个 Java 程序都活在这座城市里。城市有海关,负责给外地来的“类文件”办签证;有档案馆,存放全国通用的规章制度;有堆货场,保存所有运行中的对象;每位打工线程手里还有一本便签本,记录当前执行到哪一步。这座城市,就是 JVM。 我们常听到“Java 跨平台”,是因为城市下发的是统一图纸——字节码,而每座城市(不同操作系统)都能按图纸施工。有人担心对象太多没地方放,环卫队(垃圾回收器)会定时清扫没人引用的垃圾对象;有人嫌按图纸施工慢,老师傅(JIT 编译器)会把重复动作变成快捷模具。整座城市环环相扣:类加载受理行李,内存区域安排住宿,执行引擎负责干活,垃圾回收负责保洁。 这篇文章不追求背八股,而是带你从“城市规划”视角看懂 JVM 的内存模型和执行引擎:类从哪里来、住到哪里、如何被执行、又如何被清理。
学会 JVM 内存与执行引擎,你能准确指出线上 OOM、CPU 飙升、频繁 Full GC 的根源,这是后端与 AI 平台工程师排查性能问题的核心能力。
可视化演示
展示JVM运行时内存区域划分(堆/栈/方法区)以及对象在Eden、Survivor、Old Gen之间的分代垃圾回收流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
JVM内存模型 堆内存:Eden区分配新对象 Survivor区:Minor GC后存活对象 Old Gen:长期存活对象晋升 Heap = Eden + S0 + S1 + Old JVM分代内存管理与垃圾回收机制
类加载机制
想象一位持有签证的人来到 JVM 城市。海关不会因为他带着证件就立刻放行,而是要经过登记、验明身份、发放入境凭证等一系列流程。Java 里的 class 文件就像这位外来者,类加载机制就是海关那套完整的“入城手续”。
Java 程序不会在启动时把所有类都塞进内存。只有代码真的用到某个类,比如执行 new 操作、访问静态字段或调用静态方法时,JVM 才临时安排加载。这样按下不表、用到才办的策略,能大幅缩短启动时间。整个“入城”流程可以拆成三关:加载、链接、初始化。加载阶段寻找并读取 .class 文件的二进制流,将其转换成方法区里的运行时数据结构,同时生成一个 Class 对象作为这个类的“身份证”;链接阶段再细分为验证、准备、解析,检查字节码是否合法,为静态变量分配空间,把符号引用替换成真正的内存地址;初始化阶段才真正执行静态代码块和静态变量的赋值。
一个类不是天生就是市民,它先要验明正身、办手续,最后才领到身份证,拥有在城市里被使用的资格。理解这一点,你才会明白为什么线上程序首次访问某个类时会短暂变慢。
- 加载从 class 文件中读取二进制数据,并转换成
Class对象,相当于海关登记个人信息。 - 链接验证字节码合法性,准备静态变量内存,解析符号引用,相当于核对资料并安排住处。
- 初始化执行静态变量赋值和 static 代码块,相当于通电后正式上岗。
双亲委派
JVM 城市的海关不是一个人说了算,而是分成总部、大区分部和基层窗口。基层收到一份“类入城申请”时,不会自作主张直接办理,而是先逐级上报,请上级先看看自己能不能处理。只有总部和分部都表示“这个类不归我管”,基层才会动手。这个“先向上请求,失败后再自己办理”的规则,就是双亲委派模型。
JVM 中最常见的三个类加载器是:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和应用类加载器(Application ClassLoader)。它们像三级海关:启动类加载器负责加载最核心的 Java API,扩展类加载器负责加载扩展库,应用类加载器负责加载我们自己写的 classpath 代码。当应用类加载器收到一个类加载请求时,它会先问父加载器“你来不来”,父加载器再问上一级;一级一级向上推,直到最顶层都无法处理,请求才原路返回,由最开始发起请求的加载器尝试加载。
双亲委派最大的好处是安全:就算你自己写了一个 java.lang.String,也别想混进核心库,因为最顶层的启动类加载器早就把正牌 String 加载了。这也解释了为什么有些场景需要打破双亲委派,比如 Tomcat 容器希望每个 Web 应用拥有独立类加载器,避免不同应用之间互相污染。
- 查缓存先看这个类是否已经被加载过,避免同一个类被加载两次。
- 委托父级缓存没有命中,就调用父加载器的
loadClass,让上级先尝试。 - 自己加载整条父链都失败,最后才调用
findClass读取字节流。
运行时数据区
JVM 城市内部不是一个大平层,而是分成功能明确的区域。有的区域是施工人员私有的小办公室,有的区域则是大家共享的公共仓库。运行时数据区就是这份“城市功能分区图”。
按照 JVM 规范,运行时数据区主要分为五块:程序计数器、虚拟机栈、本地方法栈、堆和方法区。程序计数器是当前线程正在执行的字节码行号指示器,像一个书签,线程切换后能立刻恢复位置;虚拟机栈保存当前线程的局部变量和中间结果,每个方法调用都会压入一个栈帧;本地方法栈服务于 native 方法;堆是几乎所有 Java 对象的家;方法区保存类型信息、常量、静态变量和 JIT 编译后的代码缓存,在 Java 8 之后通常由元空间 Metaspace 实现。
这样分工之后,每个线程的便签不互相干扰,公共货场则统一管理对象。线上排查内存问题时,首先要分清“是堆上对象太多,还是方法区元数据膨胀”,这就是运行时数据区知识给我们的定位能力。
- 程序计数器记录当前执行到哪一条字节码指令,是线程私有的“书签”。
- 虚拟机栈与本地方法栈Java 方法和 native 方法各自需要的临时空间。
- 堆与方法区所有线程共享,对象实例和类型元数据在这里存放。
堆与垃圾回收
堆是 JVM 城市的公共货场,既放刚进货的新商品,也放需要长期保存的老物件。商品每天进进出出,总得有人定期收拾没人要的废品,否则仓库很快爆满。垃圾回收器就是这支环卫队。
为了提升清扫效率,JVM 通常把堆分成新生代和老年代。新生代再分为 Eden 区和两块 Survivor 区,绝大多数对象先在 Eden 区出生;经历一次 Minor GC 仍然存活的对象会进入 Survivor 区,每熬过一次 GC 年龄加一,达到阈值后被送入老年代。GC 判断对象是否该回收,靠的不是数引用次数,而是“可达性分析”:从一组 GC Roots 出发,沿着引用关系一直走,凡是走不到的对象,就是可以被回收的垃圾。标记-清除算法负责标记存活对象并清除垃圾,但会产生内存碎片;复制算法则将存活对象整块复制到另一块空区,适合年轻代这种“大部分都是垃圾”的场景。
理解堆与 GC,你才能看懂 Full GC 为什么会“暂停所有线程”。环卫队清理超大仓库时,必须让所有人停下手中的活,否则对象一边被搬一边被使用,极易出错。这也解释了为什么堆内存不是越大越好:仓库越大,清扫一次的时间可能越长。
- GC Roots 集合栈上的局部变量、静态变量等作为起点,相当于城市几个主要出城口。
- 可达性扩散从 Roots 出发沿引用遍历,标记所有能到达的对象。
- 清除不可达对象不在集合中的对象统一回收。
虚拟机栈详解
每个线程手里都有一本便签本,这就是虚拟机栈。线程每调用一个方法,就在便签本上垫一页新纸;方法执行完,这页纸就被撕掉。这一页一页的纸,术语叫栈帧。
一个栈帧内部有四个主要部分:局部变量表、操作数栈、动态链接、方法返回地址。局部变量表存放方法参数和方法内部定义的局部变量,有点像纸上事先写好的输入清单;操作数栈用来保存计算过程中的中间值,比如计算 a + b * c 时,中间结果会暂时堆在这里;动态链接指向运行时常量池中该方法的引用,让方法能正确找到自己的类信息;方法返回地址则记录“这段代码执行完该回到哪里”,像便签底部写好的回家路线。如果方法递归太深,便签本被垫得太厚,就会触发 StackOverflowError。
栈是程序员最容易感知的内存区域之一:递归深度过大、方法调用链过长,都会让便签本“爆页”。同时,一个线程的栈大小也直接影响可创建的线程数量,线上调优时往往需要在 -Xss 与线程数之间做平衡。
- 局部变量表保存参数和局部变量,以槽位 Slot 为单位分配空间。
- 操作数栈执行字节码时保存中间计算结果,栈深度由编译器确定。
- 动态链接与返回地址将符号引用转为实际调用地址,并记录方法出口。
执行引擎与JIT
城市施工队里有两种工人:一种拿着图纸逐步施工,虽然每一步都标准,但速度一般;另一种是经验丰富的老师傅,干同一件事干了几百次后,直接把流程压缩成一套模具,闭着眼睛也能快速完成。前者是解释器,后者是 JIT 编译器。
执行引擎是一个“翻译并执行”的引擎。它读取字节码指令,解释一条执行一条,这就是解释执行。解释执行的优点是启动快、不需要额外编译时间,但反复执行同一段代码时效率偏低。JIT 编译器的思路是:统计每个方法的调用次数,当调用次数达到某个编译阈值时,认为它是“热点代码”,于是把这一段字节码直接编译成当前操作系统能认识的本地机器码。编译后的机器码被缓存下来,之后再遇到同一方法,就无需再解释,直接运行机器码即可。
JIT 还会做一系列优化,比如逃逸分析:如果一个对象只在一个线程内部使用、不会被外部引用,就没必要非要放到公共堆上,可以尝试在栈上分配,减少 GC 压力。这也解释了为什么“解释执行一定慢”“JIT 编译一定好”都是片面的,JVM 是聪明的裁判,它只在真正值得提速的地方请老师傅出手。
- 方法调用计数JVM 为每个方法维护一个计数器,每次调用就加一。
- 阈值判断计数达到阈值时触发编译,HotSpot 常把阈值简写成
CompileThreshold。 - JIT 编译把热点字节码转换成 CPU 能直接执行的本地指令。
平台无关字节码
为什么 Java 能“一次编写,到处运行”?因为 JVM 城市下发的是统一图纸,但每个城市的施工队都可以按自己的材料和标准干活。代码编译后不直接变成某台机器的指令,而是变成一份中立图纸——字节码。
javac 把 .java 源码编译成 .class 文件,里面的字节码是一套由 JVM 定义的指令集。这套指令不依赖具体操作系统,Linux、Windows、macOS 上的 JVM 都读得懂。真正把字节码翻译成机器码的,是每一台设备上的 JVM 实现。所以跨平台的关键不是 Java 源码,而是字节码加 JVM 适配层。操作系统差异、文件系统差异、底层 I/O 调用差异,都被 JVM 这层“施工队”消化掉了。
理解了这张图,你就明白为什么部署 Java 服务时往往“把 jar 包扔上去就能跑”,也明白为什么如果在代码里直接依赖 Windows 路径、Linux shell 命令,跨平台就会出问题。图纸是统一的,但工地上不能有太多“本地土规矩”。
- 源码编译
javac将.java编译成.class文件。 - 字节码指令Class 文件里存放 JVM 指令,如
iload、invokevirtual等。 - 本地适配不同平台的 JVM 把同一条字节码解释成自己平台的机器码。
内存模型与线程
JVM 城市里有很多线程同时干活,但大家不是随时都直接读写同一块公告栏。为了提高效率,每个线程都有自己的“工作内存”,相当于个人草稿纸。线程先把变量从主内存读到草稿纸上,改完再写回公告栏。如果一个人改了草稿纸却忘了贴回公告栏,其他人看到的仍是旧数字。
Java 内存模型(JMM)定义的正是这种“主内存-工作内存”之间的交互规则。volatile 是其中最常用的关键字:被 volatile 修饰的变量,线程读写时都直接对准主内存,并且禁止编译器对相关指令进行重排序。因此 volatile 能保证可见性,但不能保证 count++ 这种复合操作的原子性。另一个更底层的概念是 happens-before(先行发生原则):如果操作 A happens-before 操作 B,那么 A 对内存的写入结果对 B 可见。比如解锁操作 happens-before 后续对同一把锁的加锁操作;volatile 写 happens-before 后续对同一变量的 volatile 读。
多个线程共享数据时,不能靠“感觉”判断先后,必须依赖 JMM 提供的规则。加锁、volatile、并发容器,本质上都是在“公告栏”和“草稿纸”之间建立可靠的信息传递,而不是玄学般地碰运气。
- 主内存与工作内存共享变量存放在主内存,线程私有副本在工作内存。
- volatile 语义写 volatile 立即刷新主内存,读 volatile 直接从主内存取。
- happens-before 推导若 A 先行发生于 B,且同一变量,则 B 可见 A 的写入。
AI 应用场景
模型服务 OOM 排查
阶段:模型服务部署与监控
解决的问题:传统方式是等进程崩溃后盲目加内存,但加了还是 OOM;掌握 JVM 堆内存、GC Roots 和可达性分析后,可以直接从堆转储文件里找到谁“攥着”对象不放。就像环卫队扫不干净,不是因为扫得不勤,而是有一个仓库管理员一直把所有箱子都标记为“保留”。
具体案例:某 AI 推理服务运行 3 天后频繁 OOM,表现形式是堆内存缓慢上涨直到溢出。开发人员用 jmap 导出堆转储,发现所有历史推理请求的结果对象都被一个静态缓存 List 强引用,导致 GC 永远无法回收。定位到堆上这条“根引用链”后,改成弱引用并限制缓存大小,服务稳定运行。
算法平台插件热更新
阶段:算法引擎版本管理与多租户隔离
解决的问题:多个算法模型 jar 包可能依赖同一个类但版本不同,如果全部交给同一个应用类加载器,会出现类冲突。打破双亲委派,让每个算法包拥有独立类加载器,才能做到像一个个独立“工作室”互不干扰。
具体案例:机器学习平台需要同时运行用户上传的自定义预处理代码。平台为每个任务创建独立类加载器,任务结束就卸载加载器并回收相关类。这样不同用户即使上传同名类,也不会互相覆盖,真正实现隔离式热更新。
高并发服务 GC 调优
阶段:在线推理与高吞吐优化
解决的问题:面对高并发请求,Minor GC 过频、Full GC 过长都直接影响响应时间。掌握分代堆布局和复制算法后,可以根据对象存活率调整 Eden 与 Survivor 比例,让 GC 像错峰清扫一样减少对业务的影响。
具体案例:某在线推荐系统接口 P99 延迟经常突然飙高,查看 GC 日志后发现老年代频繁 Full GC。通过调整新生代大小、增加 Survivor 区空间,并让短期对象尽可能在年轻代被回收,Full GC 频率大幅下降,接口延迟恢复平稳。
Python 演示
# -*- coding: utf-8 -*-# 模拟 JVM 堆上的可达性分析和标记-清理回收 class MiniJVM: def __init__(self): self.heap = {} self.next_id = 1 self.gc_root = None self.gc_count = 0 def alloc(self, refs): oid = "obj%d" % self.next_id self.next_id += 1 self.heap[oid] = list(refs) return oid def mark_sweep(self): self.gc_count += 1 alive = set() if self.gc_root is not None: alive.add(self.gc_root) stack = [self.gc_root] while stack: oid = stack.pop() if oid is None or oid not in self.heap: continue for ref in self.heap[oid]: if ref not in alive: alive.add(ref) stack.append(ref) before = len(self.heap) self.heap = {oid: refs for oid, refs in self.heap.items() if oid in alive} print("GC第%d次: 清理前%d个对象, 清理后%d个对象" % (self.gc_count, before, len(self.heap))) def simulate(): jvm = MiniJVM() a = jvm.alloc([]) b = jvm.alloc([]) c = jvm.alloc([]) d = jvm.alloc([]) # a -> b -> c 是可达链; d 没有被任何根引用 jvm.heap[a].append(b) jvm.heap[b].append(c) jvm.gc_root = a jvm.heap[d] = [a] # 即使 d 引用 a, d 自身不可达 print("初始堆对象:", sorted(jvm.heap.keys())) jvm.mark_sweep() print("回收后堆对象:", sorted(jvm.heap.keys())) if __name__ == "__main__": simulate()
jvm.heap[a].append(b)建立对象引用关系:a 引用了 b。jvm.gc_root = a把 a 作为 GC Roots,模拟线程栈上的局部变量。stack = [self.gc_root]从根出发,用栈做深度优先遍历。alive.add(ref)每发现一个可达对象就标记一次。self.heap = {oid: refs for oid, refs in self.heap.items() if oid in alive}只保留存活对象,完成清除。
常见误区
误区我一直以为子加载器会先自己尝试加载类,如果找不到,再委托给父加载器去处理。
正解正好相反。双亲委派模型是“先父后子”:子加载器收到请求后会先问父加载器,父加载器再往上问,只有整条父链都失败,才轮到自己加载。这就像基层海关收到申请先逐级上报,而不是直接擅自盖章。它的核心目的是避免核心类被重复加载甚至被伪造。
为何常见:我们日常生活中办事往往“先自己解决,不行再找上级”,所以很容易把这种直觉套到类加载器上。
误区线上服务 OOM,第一反应是把堆内存最大上限调大,觉得堆越大越不容易出问题。
正解堆过大可能导致单次 Full GC 耗时更久,因为 GC 需要从 GC Roots 遍历整个更大规模的存活对象图,STW 时间也会上升。正确做法是先分析存活对象大小、对象分配速率和 GC 日志,再确定合理堆大小,而不是盲目调大。
为何常见:人们常把“空间不够”理解为唯一瓶颈,忽略了 GC 暂停时间与堆规模的关系。
误区听说 Java 8 用元空间替换了永久代,那方法区是不是就不存在了?
正解方法区是 JVM 规范定义的一个逻辑区域,用于存放类元数据、常量、静态变量等信息。Java 8 之前,HotSpot 用永久代实现它;Java 8 之后,用元空间实现,字符串常量池也移到了堆中。方法区这个逻辑概念仍然存在,只是底层实现换了块地。
为何常见:大家把“永久代被移除”误理解为“方法区被移除”,其实是实现方式变了,规范仍然保留。
误区既然 JIT 能把字节码编译成机器码,那应该所有代码都 JIT 编译,干脆关闭解释器。
正解JIT 编译需要额外的 CPU 时间和内存缓存。对于只执行一次或几次的冷代码,解释执行反而更快;只有热点代码才值得频繁采样、编译、优化。HotSpot 采用混合模式,解释器负责快速启动,JIT 负责在热点处加速。
为何常见:我们容易对比“运行一次解释 vs 运行一次编译后代码”的耗时,却忽略编译本身也需要时间。
误区只要程序里不再引用某个对象,GC 马上会把它清掉,所以不必担心内存泄漏。
正解GC 判断的依据是可达性,不是“是否还在使用”。如果一个对象仍然被某个静态集合强引用,即使业务上已经不用它了,GC 依然认为它活着。这种无意识持有引用就是最常见的内存泄漏来源。
为何常见:人们把“不再使用”和“不可达”混为一谈,忽略了静态容器和缓存等隐性强引用。
面试题
Q1. 在 JVM 中,负责把 class 文件读入并生成 Class 对象的核心组件是哪个?
- A.字节码解释器
- B.类加载子系统← 正确答案
- C.垃圾回收子系统
- D.JIT 编译子系统
答案:B。类加载子系统完成加载、链接和初始化,其中加载阶段会生成 Class 对象。解释器和 JIT 都是执行引擎的一部分,而不是负责加载类的组件。
Q2. 在双亲委派模型中,子加载器收到类加载请求后首先应该做什么?
- A.由自己直接加载
- B.询问父类加载器← 正确答案
- C.立刻抛出异常
- D.扫描本地磁盘
答案:B。双亲委派的顺序是“先向上,后向下”。子加载器先调用父加载器,父加载器再继续向上,只有整条父链都失败,子加载器才会自己尝试,所以不是“自己先上”。
Q3. 以下哪个区域属于线程私有?
- A.堆数据区
- B.方法区
- C.虚拟机栈← 正确答案
- D.元数据区
答案:C。虚拟机栈、程序计数器和本地方法栈都是线程私有区域;堆、方法区是线程共享区域。元数据区属于方法区实现,也与线程共享。
Q4. 对象进入老年代的直接原因通常是?
- A.被线程栈直接引用
- B.在新生代熬过多次GC← 正确答案
- C.对象正好分配在方法区
- D.类加载器版本不匹配
答案:B。对象在新生代每经历一次 Minor GC 仍存活,年龄就会增加;达到阈值后晋升到老年代。大对象也可能直接进入老年代,但本题四个选项里最常规的晋升原因是“多次存活于 Minor GC”。
Q5. 以下哪些对象可以作为 GC Roots 的起点?
- A.虚拟机栈中的引用← 正确答案
- B.静态属性指向对象← 正确答案
- C.方法区常量持有对象← 正确答案
- D.JNI 全局引用对象← 正确答案
答案:ABCD。GC Roots 常见包括虚拟机栈中的局部变量引用、静态变量引用、方法区常量引用、JNI 引用等。从这些根出发做可达性分析,根能到达的对象存活,到达不了的对象回收。
Q6. 以下哪些属于 JVM 规范中的运行时数据区?
- A.程序计数器← 正确答案
- B.虚拟机栈← 正确答案
- C.方法区← 正确答案
- D.操作数栈
答案:ABC。JVM 规范中的运行时数据区包含程序计数器、虚拟机栈、本地方法栈、堆、方法区。操作数栈是虚拟机栈内某个栈帧的组成部分,不是独立的一块运行时数据区。
Q7. JIT 编译器的主要作用是什么?
- A.把 Java 源码编译成 class 文件
- B.把热点字节码编译为本地机器码← 正确答案
- C.把本地方法栈映射到内核
- D.把堆对象直接复制到老年代
答案:B。javac 负责把 Java 源码编译成 class 文件;JIT 是在运行期把“热点字节码”编译成当前平台机器码,从而加速后续执行。对象晋升是 GC 的事,与 JIT 无关。
Q8. 关于 volatile 的语义,以下哪个说法最准确?
- A.能保证复合操作的原子性
- B.禁止指令重排并保证可见性← 正确答案
- C.会强制把对象移入老年代
- D.可以替代锁的所有功能
答案:B。volatile 保证变量在多个线程之间的可见性,并禁止编译器、CPU 对相关指令重排序。它不保证 i++ 这类复合操作原子性,也不能完全替代 synchronized。
Q9. 以下哪些机制可能优化 JVM 性能?
- A.JIT 热点编译← 正确答案
- B.逃逸分析优化← 正确答案
- C.分代垃圾回收← 正确答案
- D.双亲委派模型
答案:ABC。JIT 把热点代码编译成机器码,逃逸分析减少堆分配,分代 GC 提高回收效率;双亲委派主要保证类加载安全和唯一性,不是性能优化手段。
Q10. 容器为了隔离应用而主动打破双亲委派的典型例子是?
- A.JDBC 驱动加载
- B.Tomcat 独立类加载器← 正确答案
- C.启动类加载器加载 rt.jar
- D.应用类加载器加载 classpath
答案:B。Tomcat 为每个 Web 应用创建独立 WebAppClassLoader,先由自己加载应用类,再委托父加载器,这正是打破双亲委派以隔离应用的例子。JDBC SPI 使用的线程上下文类加载器也是打破案例,但本题最典型的是 Tomcat 独立类加载器。