JVM-盛大厨房的剖析(中)

第四章:厨房的保洁革命——垃圾回收机制

想象一下你的中央厨房已经运营了一段时间,核心食材区(堆内存)开始出现问题了...

4.1 问题的产生:厨房的垃圾危机

在堆区这个"核心食材区"里:

  • 厨师们不断地取用新食材(new Object()
  • 有些食材用完了就废弃了(对象不再被引用)
  • 切掉的菜叶、用过的包装袋、炒糊的菜(垃圾对象)堆积如山

很快,厨房面临两个严重问题:

  1. 空间不足:新食材没地方放了 → OutOfMemoryError
  2. 空间碎片化:可用空间被分割成无数个小块,大份食材找不到连续空间存放

现实对应:这就是为什么需要垃圾回收——回收不再使用的对象,释放内存空间。

4.2 谁是垃圾?——垃圾识别算法

保洁团队首先要能识别什么是垃圾。JVM用了两种主要方法:

方法一:引用计数法(原始但有问题)

  • 原理:每个食材贴个标签,记录有多少人在使用它
  • 当引用数为0时:说明没人用了,是垃圾
  • 致命缺陷循环引用问题
java
复制代码
// 就像两个食材互相引用,但实际都没用了 宫保鸡丁.sauce = 特调酱汁; 特调酱汁.usedBy = 宫保鸡丁; // 它们互相指着说"还有人用我",但实际上客人都走了

方法二:可达性分析(JVM实际采用)

  • 原理:从一些"根"开始,能找到的对象都是存活的,找不到的就是垃圾

  • GC Roots包括

  • 正在操作的厨师(虚拟机栈中的局部变量

  • 菜谱档案库里的固定配方(方法区中的静态变量

  • 厨房的固定设备清单(方法区中的常量

类比理解:大扫除时,从正在用的灶台、菜谱上的必备食材开始,所有能直接或间接联系到的食材都保留,其他统统扔掉。

4.3 代际假说——厨房的智慧分区

观察发现:大部分食材都是"朝生夕死"的

  • 80%的食材(对象)在第一次使用后就被丢弃
  • 只有少数食材(如老汤、卤水)会长期保存

基于这个发现,厨房进行了分区优化

plain
复制代码
核心食材区(堆) ├── 新生代 (Young Generation) - 占1/3 │ ├── 伊甸园 (Eden) - 新食材都放在这里 │ ├── 幸存者区S0 (Survivor 0) - 经历过一次清扫的食材 │ └── 幸存者区S1 (Survivor 1) - 同上,两个轮流使用 └── 老年代 (Old Generation) - 占2/3 └── 长期保存的珍贵食材

4.4 保洁流程——Minor GC 与 Full GC

Minor GC(新生代清理)

触发条件:伊甸园满了

清理过程

  1. 标记:识别出伊甸园和S0区中的所有存活对象
  2. 复制:把存活对象复制到S1区(如果对象年龄足够大,直接晋升到老年代)
  3. 清空:一次性清空伊甸园和S0区

特点

  • 频率高,但速度快
  • 采用"复制算法",没有内存碎片

类比:每天下班前对操作台进行快速整理,把还要用的食材移到备用区,其他全部清理。

Full GC(全局大扫除)

触发条件:老年代满了、方法区满了、或者调用System.gc()

清理过程

  • 清理整个堆 + 方法区
  • 采用"标记-整理"或"标记-清除"算法
  • Stop-The-World:整个厨房停业整顿,所有工作暂停!

特点

  • 频率低,但停顿时间长,影响巨大
  • 要尽量避免Full GC的发生

类比:每月一次的大扫除,整个厨房停业,彻底清理每个角落。

4.5 不同的保洁团队——垃圾回收器

JVM提供了多种保洁团队,各有特色:

  1. Serial收集器 - 单人保洁队
  • 单线程工作,清理时厨房完全停业
  • 适合小厨房(客户端应用)
  1. Parallel收集器 - 多人保洁队
  • 多线程并行清理,速度更快
  • 注重吞吐量(尽可能多处理订单)
  1. CMS收集器 - 快速响应团队
  • 目标是最小化停顿时间
  • 大部分工作与厨房运营并发进行
  • 但会产生内存碎片
  1. G1收集器 - 智能分区团队(JDK9+默认)
  • 把堆分成多个小区域,优先清理最脏的区域
  • 在停顿时间和吞吐量间取得平衡
  • 预测停顿时间:可以设置"最多停业XX毫秒"
  1. ZGC/Shenandoah - 超低停顿团队
  • 停顿时间控制在10ms以内
  • 适合超大厨房(上百G堆内存)

4.6 作为总厨,如何优化?

理解了保洁机制,你现在可以:

减少垃圾产生

java
复制代码
// 坏做法:在循环中创建大量临时对象 for (int i = 0; i < 10000; i++) { String temp = new String("item" + i); // 产生大量垃圾! process(temp); } // 好做法:重用对象或使用基础类型 StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.setLength(0); sb.append("item").append(i); process(sb.toString()); }

避免内存泄漏

java
复制代码
// 常见内存泄漏:静态集合引用 public class MenuManager { private static Map<Long, Order> allOrders = new HashMap<>(); public void addOrder(Order order) { allOrders.put(order.getId(), order); // 订单永不释放! } // 应该提供移除方法 public void removeOrder(Long orderId) { allOrders.remove(orderId); } }

合理设置JVM参数

bash
复制代码
# 设置堆大小 -Xms4g -Xmx4g # 初始堆4G,最大堆4G(避免动态扩容) # 设置新生代比例 -XX:NewRatio=2 # 新生代:老年代 = 1:2 # 选择垃圾回收器 -XX:+UseG1GC # 使用G1回收器 # 设置目标停顿时间 -XX:MaxGCPauseMillis=200 # G1目标停顿200ms

实战思考题

假设你的餐厅应用出现频繁Full GC,可能是什么原因?

  1. ❌ 对象过早进入老年代?
  2. ❌ 内存泄漏导致老年代撑爆?
  3. ❌ 新生代设置太小?
  4. ❌ 大对象直接分配在老年代?

答案:都有可能!需要你用jstatjmap等工具具体分析。


第五章:JMM内存模型——厨房的协作规则与幽灵问题

想象一下,你的中央厨房现在规模扩大了,有多个厨师团队同时工作。这时候出现了诡异的现象:

  • 厨师A明明往锅里放了盐,但厨师B尝的时候却说"没味道"
  • 厨师C和厨师D同时抢最后一份珍贵松露,结果两人都以为抢到了
  • 有时候做菜的顺序莫名其妙被打乱,导致菜品失败

这就是多线程并发问题。要理解这些问题,我们需要学习JVM的内存模型(JMM)

5.1 主内存 vs 工作内存——中央仓库与私人操作台

在JMM视角下,厨房内存被重新划分:

  • 主内存:中央大仓库,所有食材的"官方记录"都在这里
  • 工作内存:每个厨师的私人操作台,有自己临时存放食材的地方

关键规则:厨师不能直接操作中央仓库的食材!必须先拿到自己操作台上处理,处理完再同步回中央仓库。

这就引出了第一个幽灵问题:

5.2 可见性问题——"我加了盐,你为什么尝不到?"

场景还原

java
复制代码
// 中央仓库记录:saltAdded = false public class Kitchen { private static boolean saltAdded = false; public static void main(String[] args) { // 厨师A线程 new Thread(() -> { try { Thread.sleep(1000); // 准备食材... saltAdded = true; // 厨师A在自己的操作台上标记"已加盐" System.out.println("盐已加入!"); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); // 厨师B线程 new Thread(() -> { while (!saltAdded) { // 死循环:厨师B一直查看自己操作台上的记录 // 但看不到厨师A的修改! } System.out.println("检测到盐已加入,继续下一步..."); }).start(); } }

问题根源:厨师A在自己操作台上改了"盐状态",但没及时同步到中央仓库。厨师B看的还是自己操作台上的旧数据。

面试回答模板

"可见性问题是指一个线程对共享变量的修改,另一个线程不能立即看到。这是因为每个线程有自己的工作内存,修改后需要同步到主内存,其他线程才能从主内存读取到最新值。"

5.3 原子性问题——"最后一份松露之争"

场景:珍贵松露只剩最后一份,两个厨师同时去取:

java
复制代码
public class TruffleProblem { private int truffleCount = 1; // 最后一份松露 public void takeTruffle() { if (truffleCount > 0) { // 线程A执行到这里,暂停! // 线程B也执行到这里,也看到truffleCount > 0 try { Thread.sleep(10); // 模拟处理时间 } catch (InterruptedException e) { e.printStackTrace(); } truffleCount--; // 两人都减了1,结果变成-1! System.out.println(Thread.currentThread().getName() + "取走了松露,剩余:" + truffleCount); } } }

问题根源truffleCount--看似一步,实际包含:读取→修改→写入,不是原子操作。

面试回答模板

"原子性问题是指一个操作在执行过程中被其他线程中断,导致数据不一致。比如i++操作实际上包含三个步骤,在多线程环境下需要同步机制保证原子性。"

5.4 有序性问题——"不按菜谱顺序做菜"

为了优化效率,JVM和处理器会重排序指令

java
复制代码
// 菜谱写的顺序: 1. 热锅 2. 倒油 3. 放食材 // 实际可能执行的顺序: 1. 倒油 2. 热锅 // 糟糕!冷油下锅 3. 放食材

这就是指令重排序,单线程没问题,但多线程会出大问题。

5.5 解决方案——厨房的同步机制

方案一:synchronized(厨房专用锁)

就像给厨房某个区域加锁,一次只允许一个厨师进入:

java
复制代码
public class SafeKitchen { private boolean saltAdded = false; public synchronized void addSalt() { saltAdded = true; // 加锁操作,保证可见性和原子性 } public synchronized boolean checkSalt() { return saltAdded; // 同样加锁读取 } }

synchronized保证

  • ✅ 原子性:锁内代码不会被中断(核心)
  • ✅ 可见性:解锁前会把修改强制刷新到主内存,获取锁时读取主内存(核心)
  • ✅ 有序性:限制重排序(不能完全防止)
方案二:volatile(高亮标签)

给特定食材贴上"易变"标签:

java
复制代码
public class VolatileKitchen { private volatile boolean saltAdded = false; // 告诉所有厨师:这个状态经常变,每次都要去中央仓库查看最新值! }

volatile保证

  • ✅ 可见性:修改立即同步到主内存,读取总是从主内存获取
  • ✅ 有序性:防止指令重排序
  • ❌ 原子性:不保证!

面试高频题:synchronized vs volatile

特性synchronizedvolatile
原子性✅ 保证❌ 不保证
可见性✅ 保证✅ 保证
有序性✅ 保证✅ 保证
使用场景复杂同步简单状态标记

5.6 实战:单例模式的双重检查锁定

这是面试超级高频题

java
复制代码
public class Singleton { // 必须volatile! private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { // 加锁 if (instance == null) { // 第二次检查 instance = new Singleton(); // 创建实例 } } } return instance; } }

面试官必问:为什么要用volatile?为什么要双重检查?

标准答案

"1. volatile防止指令重排序:instance = new Singleton()包含三个步骤:分配内存→初始化对象→赋值引用。如果没有volatile,可能发生重排序,其他线程拿到未初始化的对象。
2. 双重检查是为了减少同步开销:第一次检查避免每次都要加锁,第二次检查在同步块内确保只创建一个实例。"


面试实战演练

面试官:说说你在项目中遇到的多线程问题?

:(结合厨房类比)

"在我们的订单系统中,遇到过库存扣减的原子性问题。就像多个厨师同时抢最后一份食材,我们用synchronized保证了扣减操作的原子性。另外在状态标记上,比如订单状态变更,我们使用volatile保证所有线程都能立即看到状态变化..."

面试官:知道happens-before原则吗?

"happens-before是JMM的核心规则,比如:

  • 程序顺序规则:一个线程内的操作按顺序发生
  • volatile规则:volatile写先于后续的读
  • 锁规则:解锁先于后续的加锁
  • 传递性:如果A先于B,B先于C,那么A先于C"

掌握了JMM,你就能解释多线程中各种"灵异现象",这是区分普通程序员和高级程序员的关键。接下来我们要进入JVM性能调优与故障排查,这也是面试官最爱深挖的方向:

  • 常用监控工具(jstack, jmap, jstat, jvisualvm)
  • 线上故障排查(CPU 100%、内存泄漏、死锁)
  • JVM参数调优实战

我们继续用厨房的类比,看看当厨房出现各种"紧急状况"时,作为总厨该如何应对。


第六章:JVM性能调优与故障排查——厨房的应急预案

想象你的餐厅现在生意火爆,但厨房开始出现各种问题:灶台过热、食材堆积、厨师卡住... 作为总厨,你需要一套完整的监控和应急方案。

6.1 厨房监控中心——JVM监控工具

  1. jps:查看所有正在营业的厨房
bash
复制代码
jps
  • 作用:列出当前运行的所有JVM进程
  • 类比:查看酒店里所有正在运营的厨房列表
  • 输出:进程ID + 主类名
  1. jstat:实时厨房仪表盘

这是最常用的监控工具,面试高频考点!

bash
复制代码
# 监控GC情况,每秒1次,共10次 jstat -gc <pid> 1000 10 # 监控类加载情况 jstat -class <pid>

关键指标解读

plain
复制代码
S0C S1C S0U S1U EC EU OC OU MC MU YGC YGCT FGC FGCT GCT 5120.0 5120.0 0.0 5120.0 33280.0 33280.0 87552.0 43768.5 21248.0 20567.7 100 2.123 5 0.876 2.999

厨房翻译

  • S0U/S1U:两个幸存区使用量(备用食材区)
  • EU:伊甸园使用量(新食材堆积程度)
  • OU:老年代使用量(长期食材堆积)
  • YGC/YGCT:小扫除次数和总耗时
  • FGC/FGCT:大扫除次数和总耗时 ← 重点监控!

面试技巧:如果FGC频繁或FGCT时间很长,说明内存配置有问题!

  1. jstack:查看厨师工作状态
bash
复制代码
jstack <pid> > thread_dump.txt
  • 作用:生成线程快照,查看每个线程在做什么
  • 类比:突然厨房卡住了,你去记录每个厨师当前的操作步骤

分析线程状态

  • RUNNABLE:厨师正在炒菜
  • BLOCKED:厨师在等待某个厨具(锁)
  • WAITING:厨师在等配菜员送食材
  1. jmap:食材库存盘点
bash
复制代码
# 生成堆转储文件(内存快照) jmap -dump:format=b,file=heap.hprof <pid> # 查看堆内存概要 jmap -heap <pid> # 查看对象统计 jmap -histo <pid>
  • 类比:给整个食材仓库拍照,看看都是什么食材占地方

6.2 厨房紧急故障处理

故障一:CPU 100%——灶台过热报警

现象:厨房监控显示CPU持续100%,订单处理缓慢。

排查步骤

  1. 定位问题进程
bash
复制代码
top -c # 找到CPU最高的Java进程
  1. 定位问题线程
bash
复制代码
top -H -p <pid> # 查看该进程内各个线程的CPU占用
  1. 分析线程堆栈
bash
复制代码
# 把线程ID转成16进制 printf "%x\n" <thread_id> # 在jstack结果中搜索这个16进制ID jstack <pid> | grep -A 10 <hex_thread_id>

常见原因

  • 死循环:厨师在不停地搅拌空锅
  • 频繁GC:保洁团队一直在打扫,没人做饭
  • 锁竞争:多个厨师抢同一把菜刀

面试回答模板

"首先用top定位到CPU高的Java进程,然后用top -H查看进程内线程情况,找到CPU高的线程ID转成16进制,最后用jstack分析该线程的堆栈,通常会发现死循环或频繁GC的问题。"

故障二:内存泄漏——食材堆积如山

现象:老年代使用率持续增长,频繁Full GC,最终OOM。

排查步骤

  1. 实时监控
bash
复制代码
jstat -gcutil <pid> 1000
  1. 生成内存快照
bash
复制代码
jmap -dump:live,format=b,file=leak.hprof <pid>
  1. 使用MAT/Eclipse Memory Analyzer分析
  • 查找Dominator Tree(支配树)找到占用内存最大的对象
  • 查看Leak Suspects Report(泄漏嫌疑报告)

常见内存泄漏模式

java
复制代码
// 静态集合引起的内存泄漏 public class MenuManager { private static Map<Long, Order> orderCache = new HashMap<>(); public void cacheOrder(Order order) { orderCache.put(order.getId(), order); // 订单永不移除! } }

面试回答模板

"先用jstat观察老年代使用率是否持续上升,然后用jmap生成堆转储文件,最后用MAT工具分析。通常会发现是静态集合、缓存未设置过期时间、或数据库连接未关闭等原因导致的对象无法回收。"

故障三:死锁——厨师互相等待

现象:请求无响应,但CPU和内存使用率正常。

排查步骤

bash
复制代码
jstack <pid> | grep -i deadlock # 或者直接查看完整的线程转储,jstack会自动检测死锁

典型死锁场景

java
复制代码
// 厨师A:先拿刀,再拿案板 synchronized(knife) { synchronized(cuttingBoard) { // 切菜... } } // 厨师B:先拿案板,再拿刀 synchronized(cuttingBoard) { synchronized(knife) { // 切菜... } }

面试回答模板

"使用jstack生成线程转储,jstack会自动检测并报告死锁。分析线程状态可以看到哪些线程在BLOCKED状态,以及它们等待的锁被哪个线程持有。"


6.3 厨房优化参数——JVM调优实战

基础内存设置

bash
复制代码
# 堆内存:根据系统内存合理分配 -Xms4g -Xmx4g # 初始堆=最大堆,避免动态调整 # 新生代:占堆的1/3到1/2 -Xmn2g # 新生代2G # 幸存区比例 -XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1

GC调优参数

bash
复制代码
# 使用G1回收器(JDK9+默认) -XX:+UseG1GC # 目标停顿时间 -XX:MaxGCPauseMillis=200 # 并行GC线程数 -XX:ParallelGCThreads=4

故障排查参数

bash
复制代码
# OOM时自动生成堆转储 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dumps # 开启GC日志 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log

根据应用类型调优

  1. Web应用(如Spring Boot)
bash
复制代码
# 增大元空间,防止动态类加载OOM -XX:MaxMetaspaceSize=256m # 增大栈空间,应对复杂调用链 -Xss1m
  1. 大数据计算应用
bash
复制代码
# 更大新生代,减少对象晋升 -Xmn8g # 使用并行收集器,追求吞吐量 -XX:+UseParallelGC

6.4 面试实战:调优案例

面试官:如果线上应用频繁Full GC,你会如何排查和优化?

你的回答

"我会分四步处理:

  1. 现象确认:用jstat -gc pid确认FGC频率和老年代使用率
  2. 紧急止血:如果有OOM风险,先扩容堆内存-Xmx
  3. 根因分析:用jmap生成堆转储,用MAT分析对象引用链
  4. 彻底解决
  • 如果是内存泄漏:修复代码,比如及时清理缓存
  • 如果是配置问题:调整新生代大小,避免对象过早进入老年代
  • 优化GC策略:比如切换到G1,设置合理的MaxGCPauseMillis

同时我会设置-XX:+HeapDumpOnOutOfMemoryError,确保下次OOM能自动保存现场。"

面试官:如何设置JVM参数监控GC?

你的回答

"我通常会配置:

这样GC日志会滚动保存,方便后续分析GC频率和停顿时间。"

bash
复制代码
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xloggc:/app/logs/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=10M

调优心法总结

记住这个JVM调优金字塔

  1. 底层:代码优化 - 减少不必要的对象创建,及时释放资源
  2. 中层:JVM参数 - 合理配置堆内存、选择合适GC器
  3. 上层:架构设计 - 缓存策略、异步处理、限流降级

面试金句:"调优不是盲目调整参数,而是先测量、再分析、最后验证的持续过程。"

掌握了这些,你在面试中就是那个"既有理论深度,又有实战经验"的候选人!接下来我们可以聊聊类加载机制或者字节码层面的优化,这两个也是能让你脱颖而出的高级话题。

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
面向对象的王者很迟缓
下载 APP