8.0 开篇:JVM 到底在管什么
先记住一个总类比,后面所有概念都往它上面挂:
JVM = 一家餐厅
- 类加载 = 把菜谱从总部调过来、验货、上架
- 方法区 = 菜谱库(所有员工共享)
- 堆 = 后厨仓库(所有食材,大家共用,需要保洁)
- 虚拟机栈 = 每个厨师的操作台(一人一张,互不干扰)
- 程序计数器 = 操作台上的一张便签:这道菜做到第几步了
- GC = 保洁阿姨,专门清理不再使用的食材
- JIT = 老师傅:同一道菜做多了,就不再翻菜谱,直接肌肉记忆
为什么面试必问 JVM:它决定了三件事——程序能跑多大(内存)、跑多久会卡(GC)、出问题怎么查(工具)。业务代码写得再好,一次 Full GC 停 5 秒,用户就跑了。
学习地图(建议按这个顺序读):
内存长什么样 → 对象怎么放进去 → 垃圾怎么找 → 垃圾怎么清
→ 清垃圾的几套人马(收集器)→ 类怎么进来 → 代码怎么变快 → 出问题怎么查
版本坐标(2026-10,别再用 JDK 8 的资料答):
| 事项 | 现状 |
|---|---|
| 默认收集器 | G1(JDK 9 起) |
| CMS | JDK 14 已移除 |
| 永久代 | JDK 8 已移除,换成元空间 |
| ZGC | JDK 15 转正,JDK 23 起默认分代,JDK 24 移除非分代模式 |
| 紧凑对象头 | JDK 25 正式(JEP 519),对象头从 12/16 字节降到 8 字节 |
| GC 日志 | JDK 9+ 统一用 -Xlog:gc*,-XX:+PrintGCDetails 已废弃 |
8.1 运行时数据区:内存长什么样
一张图记住
┌─────────────────────────────────────────────┐
│ 线程共享区 │
│ ┌───────────────────────────────────────┐ │
│ │ 堆 Heap │ │
│ │ 新生代(Eden + S0 + S1)/ 老年代 │ │
│ └───────────────────────────────────────┘ │
│ ┌───────────────────────────────────────┐ │
│ │ 方法区 / 元空间 Metaspace │ │
│ │ 类元信息、运行时常量池、静态变量 │ │
│ └───────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ 线程私有区 │
│ 每线程:程序计数器 PC │
│ 每线程:虚拟机栈(栈帧 × N) │
│ 每线程:本地方法栈 │
└─────────────────────────────────────────────┘
外加:直接内存(堆外,不受 -Xmx 管)
逐个拆解
| 区域 | 存什么 | 线程 | 会 OOM 吗 | 餐厅类比 |
|---|---|---|---|---|
| 程序计数器 PC | 当前字节码行号 | 私有 | ❌ 唯一不会 OOM 的区域 | 便签:做到第几步 |
| 虚拟机栈 | 栈帧(局部变量表、操作数栈、动态链接、返回地址) | 私有 | ✅ StackOverflowError / OutOfMemoryError | 厨师的操作台,每调一个方法就摆一层 |
| 本地方法栈 | native 方法调用 | 私有 | ✅ | 请外援厨师的操作台 |
| 堆 | 对象实例、数组 | 共享 | ✅ OutOfMemoryError: Java heap space | 后厨仓库 |
| 方法区/元空间 | 类结构、方法字节码、常量池、静态变量 | 共享 | ✅ Metaspace | 菜谱库 |
| 直接内存 | DirectByteBuffer 等堆外内存 | 共享 | ✅ Direct buffer memory | 仓库门外的露天堆场 |
三个必须记住的细节
1. 栈帧里有什么
public int add(int a, int b) {
int c = a + b;
return c;
}
每次调用 add,压入一个栈帧:
| 部分 | 作用 |
|---|---|
| 局部变量表 | 参数 + 局部变量(a、b、c),以 Slot 为单位 |
| 操作数栈 | 执行字节码时的工作区,a + b 先把 a、b 压栈再相加 |
| 动态链接 | 指向运行时常量池中该方法的引用 |
| 方法返回地址 | 方法结束后回到哪一行继续执行 |
StackOverflowError 的典型原因:递归没有终止条件。默认栈大小
-Xss通常 512KB ~ 1MB,每层栈帧占几十到几百字节,几万层就爆了。
2. 为什么程序计数器不会 OOM
它只存一个行号,每线程固定一小块,生命周期和线程一样。JVM 规范里它是唯一没有规定 OOM 情况的区域。
3. 永久代为什么被干掉
JDK 7 及以前方法区用永久代实现(在堆里),容量固定、调优困难,还容易出现 PermGen space OOM——尤其是有大量动态生成类(CGLIB、JSP、反射)的应用。
JDK 8 换成元空间:方法区移到本地内存,默认不设上限(受物理内存限制),只受
-XX:MaxMetaspaceSize约束。菜谱库从”店内的小书架”搬到了”店外的仓库”,不再和食材抢地方。
4. 字符串常量池在哪
- JDK 6:永久代
- JDK 7+:堆
- JDK 8+:随永久代一起,仍在堆里
这就是
intern()行为变化的原因:JDK 7 之后intern()返回的是堆里已有的引用,不再复制到永久代。
8.2 对象的一生
创建过程(new 到底做了什么)
1. 类加载检查 → 这个类加载过吗?没有就先加载
2. 分配内存 → 在堆里划一块地
3. 零值初始化 → 所有字段先置 0 / null(所以对象字段有默认值)
4. 设置对象头 → 写入 Mark Word、类型指针
5. 执行 <init> → 构造器、实例块、父类构造器
6. 返回引用 → 交给调用方
⚠️ 注意第 3 步和第 5 步的顺序:先零值初始化,再执行构造器。这就是为什么构造器里能直接读
int字段得到 0,而局部变量必须先赋值。
内存怎么分配:两种方式
| 方式 | 适用场景 | 原理 |
|---|---|---|
| 指针碰撞 | 堆内存规整(GC 后有压缩) | 一个指针指向”已用”和”空闲”的分界,分配就是指针往后挪 |
| 空闲列表 | 堆内存不规整(标记-清除后) | 维护一张空闲块表,找一块够大的 |
类比:指针碰撞 = 停车场从前往后依次停;空闲列表 = 停车场随便停,墙上挂个空位表。
并发分配:TLAB
多线程同时 new,指针碰撞会打架。解决方案是 TLAB(Thread Local Allocation Buffer):
每个线程先向仓库领一小块专属区域,自己的小件放自己这块地,不用每次都排队找仓库管理员。用完了再领下一块。
- 默认开启,大小由 JVM 自适应(
-XX:TLABSize可调) - 大对象不会走 TLAB,直接在 Eden 分配
对象在内存里长什么样
┌──────────────────────┐
│ Mark Word(8 字节) │ hashCode、GC 年龄、锁状态
├──────────────────────┤
│ 类型指针(4 字节) │ 指向方法区的类元信息(压缩指针)
├──────────────────────┤
│ 实例数据 │ int、引用等字段
├──────────────────────┤
│ 对齐填充 │ 补齐到 8 字节的整数倍
└──────────────────────┘
Mark Word 是锁升级的核心(多线程篇讲过):
| 状态 | 存储内容 |
|---|---|
| 无锁 | hashCode + 分代年龄 + 偏向标志 |
| 偏向锁 | 线程 ID + epoch |
| 轻量级锁 | 指向栈中锁记录的指针 |
| 重量级锁 | 指向 Monitor 的指针 |
JDK 25 的紧凑对象头(JEP 519):把 Mark Word 和类型指针压缩进 8 字节,小对象的内存占用明显下降,缓存命中率更高。面试提一句就是加分项。
指针压缩:32GB 的由来
64 位 JVM 里引用本来是 8 字节,太浪费。开启 -XX:+UseCompressedOops(默认开启)后变成 4 字节,但只能寻址 2³² × 对齐粒度:
堆小于 32GB 时指针压缩生效,超过 32GB 就失效——这就是”堆不要设成 32GB 以上,宁可用多个小堆”这个经验的来源。32~48GB 区间是最尴尬的:堆大了,指针也变大了,性价比反而下降。
对象一定在堆上吗
不一定。 经过逃逸分析后,如果对象不会逃出方法,JIT 可以做:
- 标量替换:把对象拆成几个局部变量,直接放栈/寄存器
- 栈上分配:本质是标量替换的结果,对象根本不进堆
- 锁消除:对象只被一个线程访问,
synchronized直接去掉
public int sum() {
Point p = new Point(1, 2); // p 没逃逸
return p.x + p.y; // 优化后等价于 return 1 + 2
}
类比:小件食材不用进仓库上架,直接放在手边——用完就没了,保洁都不用管。 这也是为什么”所有对象都在堆上”这句面试答案,要补一句”JIT 优化后可能有栈上分配”。
8.3 垃圾回收(上):怎么找出垃圾
两种找法
| 算法 | 原理 | 问题 | 采用 |
|---|---|---|---|
| 引用计数 | 每个对象记一个被引用次数,归零就是垃圾 | 循环引用永远清不掉 | ❌ JVM 不用(Python 用) |
| 可达性分析 | 从 GC Roots 出发遍历,走不到的对象就是垃圾 | 需要 STW 保证一致性 | ✅ JVM 用 |
类比:引用计数像”每本书借出时记一笔”,还回来划掉,但两个人互相借对方的书,两本都永远显示”借出中”。 可达性分析像从大门进入大楼,能走到的房间都有人用;走不到的房间,不管里面东西怎么互相引用,一律清空。
GC Roots 有哪些
- 虚拟机栈中局部变量表引用的对象(正在执行的方法参数、局部变量)
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象(字符串常量池等)
- 本地方法栈中 JNI 引用的对象
- 活跃线程本身
synchronized持有的锁对象- JVM 内部引用(基本类型 Class、常驻异常、系统类加载器)
记住一个判断标准:只要还能从”正在运行的程序”直接摸到,就不是垃圾。
四种引用:垃圾的”优先级”
| 引用类型 | 回收时机 | 类比 | 典型用途 |
|---|---|---|---|
强引用 new | 永不主动回收,除非不可达 | 家人:只要还在,就不走 | 普通对象 |
软引用 SoftReference | 内存不足时才回收 | 应急储备:平时留着,缺钱再卖 | 本地缓存 |
弱引用 WeakReference | 下次 GC 必回收 | 一次性用品:看到就扔 | ThreadLocal 的 key、WeakHashMap |
虚引用 PhantomReference | 回收时收到通知,无法通过它拿对象 | 影子:只能知道”人没了” | 堆外内存回收(Cleaner) |
// ThreadLocal 的内存泄漏就藏在这里
ThreadLocalMap.Entry extends WeakReference<ThreadLocal<?>>
// key 是弱引用(会被回收),value 是强引用(不会)
// → 线程池里线程不结束,value 永远清不掉 → 必须手动 remove()
垃圾什么时候开始收
| 类型 | 触发条件 | 范围 |
|---|---|---|
| Minor GC / Young GC | Eden 区满 | 新生代 |
| Major GC / Old GC | 老年代满 | 老年代 |
| Mixed GC | G1 特有,老年代占用达到阈值(默认 45%) | 新生代 + 部分老年代 |
| Full GC | 元空间不足、晋升失败、System.gc()、G1 的兜底 | 整个堆 + 方法区 |
⚠️ 不要显式调用
System.gc():它只是建议,JVM 可能真的做一次 Full GC。线上可以加-XX:+DisableExplicitGC屏蔽。很多第三方库(尤其是 NIO 的DirectByteBuffer)会调它,需要甄别。
finalize() 为什么被废弃
- 执行时机不确定,可能永远不执行
- 让对象”复活”,逻辑复杂
- 拖慢 GC(需要额外队列和线程)
JDK 9 标记废弃,JDK 18 标记为 deprecated for removal。替代方案是
Cleaner(java.lang.ref.Cleaner)或 try-with-resources。
8.4 垃圾回收(下):怎么清理
三种基础算法
| 算法 | 做法 | 优点 | 缺点 | 类比 |
|---|---|---|---|---|
| 标记-清除 | 标记垃圾 → 直接清掉 | 简单 | 内存碎片 | 把空房间的东西搬走,房间还是碎的 |
| 标记-复制 | 存活对象复制到另一半 → 清空当前半区 | 无碎片、快 | 浪费一半空间 | 把有用的东西全搬进新仓库,旧仓库直接清空 |
| 标记-整理 | 标记 → 存活对象向一端移动 → 清理边界外 | 无碎片、不浪费 | 移动成本高 | 把有用的东西码到仓库一头,另一头整体清空 |
分代收集:三种算法各司其职
核心假设:绝大多数对象朝生夕死(弱代假设)。所以:
| 区域 | 特点 | 算法 | 比例 |
|---|---|---|---|
| 新生代 | 对象大量死亡,存活少 | 复制(Eden : S0 : S1 = 8 : 1 : 1) | 占堆约 1/3 |
| 老年代 | 对象活得久,存活多 | 标记-整理(或标记-清除) | 占堆约 2/3 |
一次 Minor GC 的过程:
Eden 满
→ 把 Eden + 当前 Survivor 里的存活对象复制到另一块 Survivor
→ 清空 Eden 和原 Survivor
→ 存活对象年龄 +1
→ 年龄达到阈值(默认 15)→ 晋升老年代
为什么 Survivor 要两块:复制算法需要一块空的”目标区”。两块 Survivor 轮流当”当前”和”目标”,始终有一块是空的。所以新生代实际可用空间是 90%(Eden 80% + 一块 Survivor 10%)。
并发标记的难题:三色标记
GC 线程和应用线程同时跑时,对象的引用关系还在变。用三种颜色描述状态:
| 颜色 | 含义 |
|---|---|
| 白色 | 还没访问到(最终可能是垃圾) |
| 灰色 | 自己访问了,但引用的对象还没访问完 |
| 黑色 | 自己和所有引用都访问完了 |
漏标发生在两个条件同时满足时:
- 黑色对象新增了一条指向白色对象的引用
- 灰色对象删掉了到该白色对象的所有引用
类比:你已经检查完房间 A(涂黑),宣布”这间没东西了”;结果有人从 A 里搬了个箱子到 B,又把 B 里通向箱子的路标拆了——最后箱子被当成垃圾扔掉。
两种解决方案:
| 方案 | 做法 | 使用者 |
|---|---|---|
| 增量更新 | 记录新增的引用,标记结束后重新扫描 | CMS |
| 原始快照 SATB | 记录删除前的引用,按开始时的快照标记 | G1、Shenandoah |
对应到代码里就是写屏障(write barrier)——每次引用赋值时插一段小逻辑,像小区门口的登记本。
Stop The World(STW)
GC 时必须暂停应用线程,否则对象在标记过程中乱跑,结果不可信。
类比:保洁要清点仓库,必须让大家先停手,不然刚数过的货架又被搬进新货。
收集器的进化史,本质就是一部”缩短 STW”的历史:
Serial → 全停,单线程
Parallel → 全停,多线程(吞吐优先)
CMS → 大部分并发,但碎片化、有 Concurrent Mode Failure
G1 → 分区域、可预测停顿
ZGC → 亚毫秒级停顿,几乎与堆大小无关
8.5 垃圾收集器:几套人马
总览
| 收集器 | 分代 | 并发 | 停顿 | 适用 | 现状 |
|---|---|---|---|---|---|
| Serial | 新生代 | ❌ 串行 | 长 | 客户端、小堆 | 仍在 |
| ParNew | 新生代 | ❌ 并行 | 中 | 配合 CMS | 随 CMS 边缘化 |
| Parallel Scavenge | 新生代 | ❌ 并行 | 中 | 吞吐量优先 | 仍在 |
| Serial Old | 老年代 | ❌ 串行 | 长 | 小堆兜底 | 仍在 |
| Parallel Old | 老年代 | ❌ 并行 | 中 | 吞吐优先 | 仍在 |
| CMS | 老年代 | ✅ 并发 | 短 | 低延迟 | JDK 14 移除 |
| G1 | 整堆分 Region | ✅ 并发 | 可预测 | 默认,中大堆 | 主力 |
| ZGC | 整堆 | ✅ 并发 | < 1ms | 超大堆、极低延迟 | JDK 15 转正,23 默认分代 |
| Shenandoah | 整堆 | ✅ 并发 | 低 | 低延迟 | 非 HotSpot 默认 |
| Epsilon | — | — | — | 压测、短命程序 | JDK 11 |
G1:现在的默认
核心思想:把堆切成大小相等的 Region(1~32MB,2 的幂),每个 Region 动态扮演 Eden / Survivor / Old / Humongous(大对象)。
类比:不再是”三个固定的大仓库”,而是”一栋楼里很多小房间”,每次只挑垃圾最多、回收收益最高的几个房间清理(G1 的 “Garbage First” 由此得名)。
| 特性 | 说明 |
|---|---|
| 可预测停顿 | -XX:MaxGCPauseMillis=200,G1 按这个目标挑 Region |
| Mixed GC | 同时回收新生代 + 部分老年代(按收益排序) |
| Remembered Set | 记录跨 Region 引用,避免全堆扫描 |
| SATB | 并发标记用原始快照,漏标靠写屏障记录 |
| Humongous | ≥ Region 一半的大对象,直接进连续 Region |
| Full GC 兜底 | 并发失败时会退化成串行 Full GC(要尽量避免) |
调优参数:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿,不是硬保证
-XX:G1HeapRegionSize=4m # Region 大小,通常不用手动设
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的堆占用阈值
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=60
ZGC:为超大堆和极低延迟
| 关键技术 | 作用 |
|---|---|
| 染色指针(Colored Pointers) | 把标记信息存在指针的高位,不用额外对象头 |
| 读屏障(Load Barrier) | 读取引用时检查并修正指针,实现并发重定位 |
| 并发标记 / 并发转移 / 并发重映射 | 几乎所有阶段都与应用线程并发 |
| 分代(JDK 21+,23 起默认) | 区分新生代/老年代,回收更精准 |
-XX:+UseZGC
-Xmx16g
-Xlog:gc*:file=gc.log
注意:ZGC 的吞吐量通常低于 G1(读屏障有开销),不适合极致吞吐场景。它的战场是”堆几十 GB、要求每次 GC 停顿 < 1ms”的在线服务。
怎么选(面试可直接答)
小堆(< 4G)、单核、客户端 → Serial
吞吐优先(批处理、离线计算) → Parallel
通用在线服务(默认) → G1
超大堆 / 严格低延迟(金融、实时) → ZGC
特殊:想完全关掉 GC 做压测 → Epsilon
GC 日志怎么看
-Xlog:gc*:file=gc.log:time,level,tags # JDK 9+ 统一日志
日志里重点看:
Pause Young (Normal)/Pause Young (Concurrent Start):Young GC 次数与耗时Pause Remark/Pause Cleanup:并发标记的停顿点Pause Full:Full GC,重点告警对象Heap after GC:回收后占用,持续上涨说明有内存泄漏Metaspace:类加载过多(动态代理、热部署)时重点看
8.6 内存分配与回收策略
五条规则
| 规则 | 说明 | 参数 |
|---|---|---|
| 对象优先在 Eden 分配 | 绝大多数对象在新生代”出生即死” | — |
| 大对象直接进老年代 | 避免 Eden/Survivor 间大量复制 | -XX:PretenureSizeThreshold |
| 长期存活对象进老年代 | 年龄达到阈值 | -XX:MaxTenuringThreshold=15 |
| 动态年龄判定 | Survivor 中同龄对象总和 > 一半,直接晋升 | — |
| 空间分配担保 | Minor GC 前检查老年代剩余空间是否够 | -XX:+HandlePromotionFailure(旧) |
新生代默认比例
-Xmn1g # 新生代总大小
-XX:SurvivorRatio=8 # Eden : 单个 Survivor = 8 : 1
-XX:MaxTenuringThreshold=15 # 晋升年龄
也就是说:新生代 1G → Eden 800M、S0 100M、S1 100M。
一个完整的分代示例
1. new 对象 → Eden
2. Eden 满 → Minor GC:存活对象 → S0,年龄=1
3. Eden 又满 → Minor GC:Eden + S0 存活 → S1,年龄+1
4. 年龄到 15(或动态年龄判定)→ 晋升老年代
5. 老年代占用达到阈值 → 并发标记 → Mixed GC(G1)
6. 老年代也回收不动 → Full GC
常见 OOM 类型对照
| 报错 | 区域 | 常见原因 |
|---|---|---|
Java heap space | 堆 | 内存泄漏、一次查太多数据、缓存无上限 |
GC overhead limit exceeded | 堆 | GC 花了 98% 时间却只回收不到 2%,即将 OOM |
Metaspace | 元空间 | 动态生成类过多(CGLIB、反射、脚本引擎) |
unable to create new native thread | 栈/线程 | 线程数过多,或 ulimit -u 限制 |
Direct buffer memory | 直接内存 | Netty/NIO 堆外内存未释放,或 -XX:MaxDirectMemorySize 太小 |
Requested array size exceeds VM limit | 堆 | 数组长度超过 Integer.MAX_VALUE - 2 |
8.7 类加载机制:菜谱怎么进店
类的生命周期
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
└──────────── 连接 ────────────┘
| 阶段 | 做什么 | 餐厅类比 |
|---|---|---|
| 加载 | 读字节码,生成 Class 对象 | 把菜谱从总部取回来 |
| 验证 | 检查字节码合法、安全 | 验货,防止假菜谱 |
| 准备 | 静态变量分配内存、赋零值 | 先把调料瓶摆好(空的) |
| 解析 | 符号引用 → 直接引用 | 把”生抽”翻译成货架编号 |
| 初始化 | 执行 <clinit>(静态变量赋值 + 静态块) | 按菜谱真正开始备料 |
⚠️ 高频陷阱:准备阶段赋的是零值,不是代码里写的值。
static int a = 10;在准备阶段a = 0,到初始化阶段才变成 10。 但static final int A = 10;是编译期常量,直接进常量池,甚至不会触发类初始化。
类加载器
| 加载器 | 加载什么 | 实现 |
|---|---|---|
| Bootstrap ClassLoader | JAVA_HOME/lib 核心类(java.lang.*) | C++ 实现,Java 里拿到是 null |
| Platform ClassLoader | JDK 平台模块(JDK 9 前叫 Extension) | Java 实现 |
| Application ClassLoader | 应用 classpath | Java 实现 |
| 自定义 ClassLoader | 指定路径、热部署、加密类 | 继承 ClassLoader,重写 findClass |
双亲委派模型
protected Class<?> loadClass(String name, boolean resolve) {
// 1. 自己加载过吗?
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2. 交给父加载器
c = parent.loadClass(name);
// 3. 父加载器搞不定,自己找
if (c == null) c = findClass(name);
}
return c;
}
类比:新员工要查一份资料,先问直属领导,领导再问上级,一直问到总部;总部没有,才回到自己这一层找。 好处:
- 安全:你写一个
java.lang.String也不会被加载,永远用 JDK 的- 避免重复:同一个类在内存里只有一份
什么时候会打破双亲委派
| 场景 | 怎么破 | 为什么 |
|---|---|---|
| SPI(JDBC 等) | Thread.currentThread().getContextClassLoader() | 核心类要加载实现类,但实现类在应用 classpath,父加载器看不到子加载器的类 |
| Tomcat | 每个 webapp 一个 WebappClassLoader,先自己后父类 | 不同应用要隔离同名类、不同版本库 |
| OSGi | 网状委派 | 模块化热插拔 |
| 热部署 / 插件 | 自定义加载器 + 卸载旧类 | 重新加载修改后的类 |
| JDK 9 模块化 | 按模块图加载,不再是树 | 模块依赖关系 |
SPI 那个最常考:JDBC 的
DriverManager在java.base模块里,由 Bootstrap 加载;它要加载 MySQL 驱动,但驱动在应用 classpath。于是它借用当前线程的上下文类加载器,绕过了双亲委派的限制。
类初始化的 6 种主动引用
new、读取/设置静态字段、调用静态方法- 反射调用(
Class.forName) - 初始化子类时,父类先初始化
- 包含
main方法的主类 MethodHandle解析结果- 接口的
default方法被实现类调用时,接口初始化
被动引用(不会初始化):
- 通过子类引用父类的静态字段 → 只初始化父类
- 定义数组
User[] arr = new User[10]→ 不初始化User - 引用
static final常量 → 编译期已进常量池
8.8 编译与执行:代码怎么变快
解释 vs 编译
解释执行 = 逐句翻译,说一句翻一句,启动快、执行慢。 编译执行 = 整段翻好再执行,启动慢、执行快。
JVM 两者都用:先解释执行,热点代码再 JIT 编译。
热点探测
| 计数器 | 触发场景 |
|---|---|
| 方法调用计数器 | 方法被反复调用 |
| 回边计数器 | 循环体反复执行 |
分层编译(默认开启)
| 层级 | 编译方式 | 特点 |
|---|---|---|
| 0 | 解释执行 | 启动快 |
| 1 | C1 简单优化 | 快 |
| 2 | C1 + 少量 profiling | 过渡 |
| 3 | C1 + 完整 profiling | 收集类型信息 |
| 4 | C2 深度优化 | 峰值性能最高 |
类比:新菜先按菜谱做(解释),做多了进入”熟练”(C1),再过一阵子变成”闭着眼睛都能做”(C2)。如果某天食材变了(类型变化),可能退化回解释执行——这就是去优化(Deoptimization)。
JIT 常见的优化手段
| 优化 | 说明 | 面试考点 |
|---|---|---|
| 方法内联 | 把小方法直接展开到调用处 | 最重要的优化,其他优化的前提 |
| 逃逸分析 | 判断对象是否逃出方法 | 栈上分配、锁消除 |
| 标量替换 | 把对象拆成字段 | “对象一定在堆上吗”的答案 |
| 锁消除 | 单线程访问的锁直接去掉 | StringBuffer 局部变量 |
| 循环展开 | 减少循环控制开销 | — |
| 常量折叠 | 编译期算好常量表达式 | "a" + "b" |
JIT 的代价
- 预热:刚启动时是解释执行,吞吐低。压测要跑够时间(通常几分钟)再看稳定值。
- Code Cache:JIT 编译结果放在
-XX:ReservedCodeCacheSize里,满了会停用 JIT(只解释执行),性能骤降。 - 去优化:运行时类型变化导致已编译代码失效。
AOT 与 GraalVM Native Image(2026 现状)
| JIT | AOT / Native Image | |
|---|---|---|
| 启动 | 慢(数百 ms ~ 数秒) | 极快(数十 ms) |
| 峰值性能 | 高(久跑之后) | 略低 |
| 内存 | 较高 | 低 |
| 动态性 | 完整(反射、动态代理) | 受限,需要配置/编译期处理 |
| 适用 | 长驻服务 | Serverless、CLI、边缘 |
Spring Boot 3 起支持 AOT 处理与 Native Image。但大多数业务后端仍在用 JIT —— 启动时间是分钟级的部署流程里不是瓶颈,而动态性更值钱。
8.9 排查工具与故障剧本
工具箱
| 工具 | 用途 |
|---|---|
jps | 列出 Java 进程 |
jstat -gcutil <pid> 1000 | 每秒打印 GC 统计(最常用) |
jmap -heap <pid> / jmap -histo <pid> | 堆信息 / 对象直方图 |
jmap -dump:format=b,file=heap.hprof <pid> | 导出堆快照 |
jstack <pid> | 线程栈 |
jcmd <pid> GC.heap_info | 统一入口,JDK 9+ 推荐 |
| JFR | 低开销飞行记录仪,JDK 11 开源,生产可用 |
| JMC / VisualVM | 图形化分析 |
| MAT | 堆转储分析(支配树、泄漏嫌疑) |
| Arthas | 线上诊断:dashboard、thread、watch、trace |
剧本一:CPU 100%
# 1. 找到占 CPU 的 Java 进程
top
# 2. 找到进程里占 CPU 的线程(-H 显示线程)
top -Hp <pid>
# 3. 线程 ID 转 16 进制(jstack 里是 hex)
printf "%x\n" <tid>
# 4. 抓线程栈,搜索 nid=0x<hex>
jstack <pid> > stack.log
grep -A 30 "nid=0x<hex>" stack.log
常见结论:死循环、频繁 Full GC(看
jstat)、大量线程上下文切换、正则回溯爆炸。
剧本二:OOM
# 启动参数必须加:OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
# 分析
jmap -dump:format=b,file=heap.hprof <pid>
# 用 MAT 打开 → Leak Suspects → Dominator Tree
看什么:哪个类的实例最多、谁持有它们的引用(GC Root 路径)、是不是缓存没有上限、是不是一次性
select *查了百万行。
剧本三:内存泄漏 / 频繁 GC
jstat -gcutil <pid> 1000 20
# 关注:O(老年代占用)是否持续上涨、FGC 次数是否在增加
- 老年代回收后仍持续上涨 → 大概率泄漏
- Young GC 很频繁但每次回收后很低 → 可能是 Eden 太小或对象创建速率过高
- Full GC 频繁但回收效果差 → 检查大对象、元空间、直接内存
常用参数速查
-Xms4g -Xmx4g # 堆初始/最大,生产建议设成一样
-Xmn2g # 新生代
-Xss1m # 线程栈
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
-Xlog:gc*:file=/data/logs/gc.log:time,level,tags
-XX:+ExitOnOutOfMemoryError # OOM 后自杀,让编排系统重启
容器里的坑
| 问题 | 说明 |
|---|---|
| JDK 8u191 之前不认 cgroup | 容器限制 2G,JVM 却按宿主机 64G 算堆,直接被 OOMKilled |
| JDK 10+ 容器感知 | -XX:+UseContainerSupport 默认开启 |
| 堆占比 | 默认 MaxRAMPercentage=25,容器里通常要调到 60~75 |
| 非堆内存 | 堆外、元空间、线程栈、Code Cache 都不在 -Xmx 里,容器 limit 要留余量 |
# 容器推荐写法
-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=70
2026 年跑在 K8s 里,别写死
-Xmx,用百分比让 JVM 自己读 cgroup limit。同时给resources.limits.memory留出 25%~30% 给堆外。
8.10 面试速查
| 问题 | 一句话答案 |
|---|---|
| JVM 内存区域有哪些 | 线程私有:PC、虚拟机栈、本地方法栈;共享:堆、方法区(元空间);另有直接内存 |
| 哪个区域不会 OOM | 程序计数器 |
| 永久代和元空间区别 | JDK 8 起方法区移到本地内存的叫元空间,默认无上限,避免 PermGen OOM |
| 对象创建过程 | 类加载检查 → 分配内存 → 零值初始化 → 设对象头 → 构造器 |
| 对象一定在堆上吗 | 不一定,逃逸分析后可能栈上分配/标量替换 |
| 指针压缩为什么和 32G 有关 | 4 字节引用最多寻址 2³² × 8 字节 = 32GB,超过就失效 |
| 怎么判断对象是垃圾 | 可达性分析,从 GC Roots 出发走不到的就是垃圾 |
| GC Roots 有哪些 | 栈中局部变量、静态变量、常量、JNI 引用、活跃线程、锁对象 |
| 四种引用 | 强、软(内存不足回收)、弱(下次 GC 回收)、虚(回收通知) |
| 为什么不用引用计数 | 循环引用无法回收 |
| 三种 GC 算法 | 标记-清除(碎片)、标记-复制(浪费空间)、标记-整理(移动成本) |
| 新生代为什么用复制 | 存活对象少,复制成本低;Eden:S0:S1 = 8:1:1 |
| 三色标记漏标 | 黑→白新增引用 + 灰→白删除引用;增量更新 / SATB 解决 |
| G1 和 CMS 区别 | G1 分 Region、可预测停顿、整体标记-整理局部复制;CMS 已移除 |
| ZGC 为什么快 | 染色指针 + 读屏障,标记/转移/重映射几乎全并发,停顿 < 1ms |
| 默认收集器 | JDK 9+ 是 G1 |
| 双亲委派 | 先问父加载器,父加载不了才自己加载;保证安全和唯一 |
| 怎么打破双亲委派 | 重写 loadClass;SPI 用线程上下文类加载器;Tomcat 应用优先 |
| 类初始化时机 | new、静态字段/方法、反射、子类初始化、main 类、MethodHandle |
| 准备阶段赋什么值 | 零值;static final 常量在编译期进常量池 |
| JIT 优化手段 | 方法内联、逃逸分析、标量替换、锁消除、常量折叠 |
| CPU 100% 怎么查 | top → top -Hp → printf %x → jstack 搜 nid |
| OOM 怎么查 | -XX:+HeapDumpOnOutOfMemoryError → MAT 分析支配树 |
| 线上频繁 Full GC 怎么排查 | jstat -gcutil 看老年代趋势 → dump → 找大对象/泄漏 → 看元空间和直接内存 |
下一篇:网络与 IO 篇——TCP 握手挥手、HTTP/2 与 HTTP/3、五种 IO 模型、epoll、零拷贝和 Netty。