核心洞察:GC 不是“内存满了就清一下”。它是一套围绕对象存活时间、分代假设和暂停预算运转的资源调度机制。
霓虹港的告警聚合服务在高峰时偶尔出现 1.8 秒延迟。接口线程没有阻塞,CPU 也不高,但 GC 日志里连续出现了停顿。要修复这类问题,第一步不是背收集器参数,而是顺着一个对象的生命周期走一遍。
对象先在哪里出生
多数 Java 对象首先进入 Eden。线程通常先从自己的 TLAB 分配,这是一小段私有缓冲区:只需移动指针,不必每次竞争堆锁。告警 JSON、DTO、临时集合大多在这里短暂存在。
Eden 不够时发生 Young GC。仍然存活的对象被复制到 Survivor 区;多次存活、达到年龄阈值,或者 Survivor 放不下的对象才晋升到老年代。这个流程解释了一个常见现象:高吞吐服务并不害怕“对象多”,更害怕“对象活得比预期久”。
| 区域 |
主要对象 |
诊断重点 |
| 栈 |
局部变量与调用帧 |
深递归、线程数量 |
| Eden |
短命请求对象 |
分配速率 |
| Survivor |
经历 Young GC 的对象 |
晋升年龄与占用 |
| 老年代 |
长寿命对象 |
增长趋势、Mixed GC |
| 元空间 |
类元数据 |
动态类加载与泄漏 |
## 分代假设为何有效
分代收集建立在“绝大多数对象朝生夕死”上。一次请求创建的解析结果、日志参数、序列化缓冲区通常在请求结束后即可回收;把它们和长生命周期缓存分开处理,能让回收器把精力集中在更小的新生代。
但如果告警批处理把所有原始事件暂存在一个全局列表,短命对象就被意外延长。GC 看见的不是“临时对象”,而是一批持续存活、不断晋升的对象。老年代压力上升,最终触发 Full GC 或更昂贵的 Mixed GC。
## 先读证据,再改参数
排查顺序应固定:先看 GC 日志的暂停时间和回收前后堆占用;再用 JFR 或堆转储确认谁持有大对象;最后检查分配热点。若老年代每次回收后基线持续上升,应优先怀疑引用链和缓存失控,而不是扩大堆。
例如,下面的聚合方式会保留整批原始载荷:
```java
Map
> groups = events.stream()
.collect(Collectors.groupingBy(AlertEvent::deviceId));
```
若下游只需要计数和最新时间,应在流中聚合为轻量结果,而不是保存所有 `AlertEvent`。减少对象存活集合,往往比调大 `-Xmx` 更稳定。
## G1 的正确打开方式
G1 把堆切成 Region,并优先回收“垃圾比例高、回收收益大”的 Region。它解决的不是“永远不暂停”,而是让暂停目标更可预测。对延迟敏感服务,`MaxGCPauseMillis` 是目标提示,不是硬保证;堆大小、存活集、并发标记速度都会影响结果。
不要把每次停顿都当故障。关键是建立基线:分配率、晋升率、老年代曲线、P95/P99 停顿和接口延迟必须放在同一时间轴观察。
## 易错点
- **只看堆使用率**:低使用率也可能有高分配率和频繁 Young GC。
- **把缓存当免费性能**:缓存保存的是对象图,也会改变晋升与回收行为。
- **一次调很多参数**:每次只改变一个变量,保留压测和 GC 日志对照。
## 结语
JVM 调优的起点不是参数表,而是对象的命运:在哪里分配、为何仍被引用、何时晋升、回收后基线是否下降。把这条路径看清,GC 才从玄学变成可验证的工程问题。
讨论与反应