Lovi.space

Java 后端

JVM 内存模型:一次对象分配如何走到 GC 回收

以霓虹港告警服务的延迟尖刺为线索,建立从对象分配、存活到回收的完整 JVM 心智模型。

·1 min read
#Java#JVM#GC#性能诊断

核心洞察: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 才从玄学变成可验证的工程问题。

读到这里,说明你也在认真对待这个问题。

讨论与反应

⌘ K