模块三 · 并发与 JVM
JVM 三层理解:内存布局、GC、可观测性
JVM 不是"背参数",而是理解对象在哪、什么时候被回收、出问题怎么查。这一节按内存、回收、类加载、排查四层讲。
读完这一节,你能做到
- 说清 JVM 内存区域的划分与各自存放什么。
- 解释分代回收与常见回收器的取舍。
- 说清双亲委派与打破它的场景。
- 用工具定位 OOM、CPU 飙高与频繁 GC。
一、内存布局
| 区域 | 存放 | 线程 | 是否会 OOM |
|---|---|---|---|
| 程序计数器 | 当前字节码行号 | 私有 | 否 |
| 虚拟机栈 | 栈帧、局部变量 | 私有 | StackOverflowError |
| 本地方法栈 | native 调用 | 私有 | 是 |
| 堆 | 对象实例、数组 | 共享 | OutOfMemoryError: Java heap space |
| 方法区/元空间 | 类元信息、常量、静态变量 | 共享 | Metaspace |
| 直接内存 | NIO DirectByteBuffer | 共享 | Direct buffer memory |
JDK 8 用元空间替代永久代,元空间在本地内存,默认不设上限(受物理内存限制),需要显式 -XX:MaxMetaspaceSize。
栈帧包含局部变量表、操作数栈、动态链接、返回地址。递归过深会 StackOverflow;栈大小用 -Xss 调整。
二、对象与分配
对象优先在 Eden 分配;大对象直接进老年代(-XX:PretenureSizeThreshold);长期存活的对象晋升老年代(年龄阈值默认 15)。
两个优化:
- TLAB:每个线程在 Eden 里有一块私有分配缓冲,减少同步。
- 逃逸分析:对象不会逃出方法时,可能栈上分配或标量替换,减少堆压力。
对象的内存布局:对象头(Mark Word + 类型指针)、实例数据、对齐填充。Mark Word 存哈希、GC 年龄、锁状态。
三、GC:分代与回收器
分代假设:大多数对象朝生夕死,少数长期存活。于是把堆分为年轻代(Eden + Survivor)和老年代,分别用不同算法。
| 回收器 | 特点 | 适用 |
|---|---|---|
| Serial | 单线程,STW | 小堆、客户端 |
| Parallel | 多线程吞吐优先 | 批处理 |
| CMS | 并发标记清除,已废弃 | 低延迟旧系统 |
| G1 | 分区,可预测停顿 | 大堆、通用 |
| ZGC | 染色指针,超低停顿 | 超大堆、低延迟 |
G1 把堆分成大小相等的 Region,按回收价值排序,优先回收垃圾最多的区域(Garbage First)。ZGC 的停顿与堆大小基本无关。
GC 的核心动作:可达性分析 → 标记 → 清除/复制/整理。判断对象存活用可达性分析,不用引用计数(解决循环引用)。
四、类加载与双亲委派
加载 → 验证 → 准备 → 解析 → 初始化
双亲委派:类加载请求先交给父加载器,父加载器加载不了才自己加载。好处是核心类不会被篡改,避免重复加载。
打破双亲委派的三个场景:
- SPI:JDBC 驱动由线程上下文类加载器加载。
- Tomcat:每个 Web 应用独立类加载器,实现隔离。
- 热部署:自定义类加载器重新加载类。
五、可观测性与排查
| 工具 | 用途 |
|---|---|
| jps | 查看 Java 进程 |
| jstack | 线程栈、死锁 |
| jmap | 堆快照、对象统计 |
| jstat | GC 频率与耗时 |
| Arthas | 在线诊断、方法耗时 |
| MAT | 分析堆转储,找泄漏根 |
三类高频问题的排查路径:
- CPU 飙高:jstack 找到最忙线程,看栈顶方法;可能是死循环、频繁 GC、锁竞争。
- OOM:先看是哪种 OOM(堆、元空间、直接内存),再用 jmap dump + MAT 找大对象。
- 频繁 Full GC:jstat 看频率,检查大对象、缓存无界、元空间增长。
六、常用参数
-Xms4g -Xmx4g # 堆初始与最大,建议相等
-Xmn2g # 年轻代大小
-Xss512k # 线程栈
-XX:MetaspaceSize=256m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
参数不是越多越好:先加 GC 日志与 OOM dump,再根据数据调。
七、常见坑
- 把
-Xmn设得过大,导致老年代过小、频繁 Full GC。 - 认为元空间不会 OOM。
- 用
System.gc()触发回收,造成不必要的 STW。 - 缓存无上限,堆被撑满。
- 只看 GC 次数,不看停顿时间与吞吐。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 用
jstat -gcutil观察一次压测的 GC 变化。 - 构造一个 OOM,开启 HeapDump,用 MAT 找占用最大的对象。
- 用 Arthas 的
trace看一个方法的耗时分布。
② 让 AI 写同一题
把第 2 题(线上内存排查)交给 AI,补一句 给出从告警到定位的完整命令链。
③ 对比要盯的点
- 它只讲堆,忘了堆外(DirectByteBuffer、元空间)吗?
jmap、jstack、jcmd的用途它区分清楚了吗?- 它给的 GC 参数是当前 JDK 默认值吗?还是抄了 JDK 8 的老配置。
④ 一句话取舍
按它的排查步骤走,哪一步会先因为“没开参数”而卡住?
九、自测
- JVM 内存区域各自存放什么?
- 对象什么时候晋升老年代?
- G1 与 CMS 的核心差异?
- 双亲委派解决了什么问题?
- CPU 飙高怎么排查?
参考答案要点:堆/栈/元空间/直接内存;年龄与大小阈值;分区与可预测停顿;核心类安全与重复加载;jstack 找最忙线程。
附录:高频追问
问:为什么用可达性分析而不是引用计数? 引用计数无法处理循环引用,且维护计数有开销。
问:Minor GC 与 Full GC 的区别? 前者只回收年轻代,快;后者回收整个堆与方法区,慢,通常伴随较长 STW。
问:什么时候触发 Full GC? 老年代空间不足、元空间不足、显式 System.gc、晋升失败、并发模式失败。
问:G1 一定比 CMS 好吗? 不一定。G1 适合大堆与可预测停顿;小堆或旧版本上 CMS 可能更稳,但 CMS 已废弃。
问:什么是 STW? Stop The World,GC 时暂停应用线程;停顿越长,接口抖动越明显。
问:类初始化什么时候触发? new、访问静态字段/方法、反射、子类初始化、主类启动。
问:什么情况下会栈溢出? 递归过深、栈帧过大;调大 -Xss 只是缓解,根本要改算法。
问:直接内存会 OOM 吗? 会,报 Direct buffer memory;NIO 大量使用堆外内存时要限制上限。
问:如何判断内存泄漏还是内存不足? 泄漏是 GC 后仍持续增长;不足是峰值超过堆上限,扩容或优化对象数量。
问:为什么建议 Xms 与 Xmx 相等? 避免运行期扩缩容带来的抖动与额外 GC。
OOM 的几种类型
| 类型 | 含义 | 常见原因 |
|---|---|---|
| Java heap space | 堆不足 | 大对象、缓存无界 |
| Metaspace | 元空间不足 | 动态生成类、热部署 |
| Direct buffer memory | 堆外内存不足 | NIO 未释放 |
| unable to create new native thread | 线程数超限 | 线程泄漏 |
先看错误信息定位类型,再 dump 分析,不要一上来就加堆。
类加载器的层级
Bootstrap(核心类)→ Platform/Ext(平台类)→ Application(应用类)→ 自定义。双亲委派从下往上问,加载不了再自己加载。
GC 日志看什么
看三个数:Young GC 频率、Full GC 次数、单次停顿。Young 频繁说明分配率高;Full 频繁说明晋升过快或老年代不足;停顿长说明堆大或回收器不合适。
补遗:解释执行、JIT 与逃逸分析
JVM 先解释执行字节码,热点代码由 JIT 编译成本地代码:C1(编译快、优化少)、C2(编译慢、优化多),分层编译兼顾启动与峰值。
常见优化:方法内联、逃逸分析(栈上分配/标量替换/锁消除)、循环展开。-Xint 强制解释、-Xcomp 强制编译,只用于排查。
注意“微基准陷阱”:预热不足、死代码消除、常量折叠会误导结论,压测用 JMH。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★★ JVM 的运行时数据区有哪些?分别存什么?
- ★★★★☆ 分代收集是怎么回事?常见垃圾收集器怎么选?
- ★★★★☆ 什么是双亲委派模型?它为什么会被打破?
- ★★★★☆ 线上频繁 Full GC 或 OOM,你怎么定位根因?
提示:读完可以顺手做几道对应题目,检验是否真的理解