这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 对象从分配、使用、不可达、回收到堆空间复用,贯穿 Young GC、晋升、并发标记和整理过程。理解生命周期,才能解释分配速率高、Survivor 溢出、老年代增长和缓存泄漏。
13.1 生命周期
Allocate
-> Initialize
-> Use
-> Unreachable
-> Collected
-> Heap reused
各阶段职责:
| 阶段 | 责任方 |
|---|---|
| 分配 | JVM,TLAB / Eden / 大 region |
| 初始化 | 构造代码 |
| 使用 | 业务 |
| 不可达 | GC 判定 |
| 回收 | GC |
| 释放 | GC 与堆布局策略 |
13.2 分配速率
观察:
jstat -gc <pid> 1000
关注:
| 指标 | 含义 |
|---|---|
| Eden delta | 两次采样间 Eden 使用变化 |
| YGC delta | Young GC 次数 |
| EU / OU | Eden / Old 使用 |
粗略计算:
allocation rate ≈ Eden delta / sample interval
高分配速率会推高 Young GC 频率、CPU 消耗、缓存和内存带宽压力,并带来晋升压力。
常见来源:
- 日志字符串;
- DTO 重复转换;
- 大对象;
- 高频临时集合;
- JSON 序列化;
- 装箱。
13.3 Young GC
简化流程:
Eden 满
-> 根扫描
-> 复制存活对象到 Survivor / Old
-> 清空 Eden 和空 Survivor
复制收集特点:
| 优点 | 代价 |
|---|---|
| 存活少时快 | 存活多时成本高 |
| 天然整理 | 复制对象 |
| 空间换时间 | 一块 Survivor 空闲 |
对象年龄参数:
-XX:MaxTenuringThreshold=15
-XX:TargetSurvivorRatio=50
HotSpot 可能根据 Survivor 压力动态调整晋升年龄,不一定等到 MaxTenuringThreshold。
13.4 晋升
常见晋升条件:
- 对象年龄达到阈值;
- Survivor 空间不足;
- 达到动态阈值;
- 大对象;
- GC 和堆布局策略。
观察:
jstat -gcutil <pid> 1000
风险:
| 现象 | 可能原因 |
|---|---|
| Old 持续增长 | 缓存或泄漏 |
| 每轮 YGC 后 Old 增长 | 过早晋升 |
| Survivor 高 | 存活对象多 |
| Full GC 后 Old 仍高 | 长期存活对象 |
处理:
- 降低临时对象;
- 调整新生代;
- 优化缓存;
- 拆批;
- 选择更合适 GC;
- 用 GC 日志确认。
13.5 对象存活时间
朝生夕死对象
-> Young 回收,成本低
中等生命周期对象
-> Survivor 压力大,可能晋升
长期对象
-> Old / concurrent marking
缓存对象
-> 应有容量、TTL 和指标
Java 应用常存在弱分代假说:多数对象生命周期很短。但 Web 服务的 session、连接、缓存和框架对象会长期存活,因此需要分代和并发标记。
13.6 引用链
根对象包括:
- 线程栈局部变量;
- 静态字段;
- JNI 引用;
- 系统类加载器;
- 监视器对象;
- JVM 内部根。
GC Roots
-> field
-> object
-> object
-> cached object 不可回收
典型泄漏模式:
| 模式 | 示例 |
|---|---|
| 静态集合 | static Map 无上限 |
| 监听器未注销 | 事件中心持有对象 |
| ThreadLocal | 线程池复用未清理 |
| 缓存 | 无 TTL / 容量 |
| ClassLoader | 热部署引用泄漏 |
| Native | 堆外未释放 |
13.7 ThreadLocal
private static final ThreadLocal<UserContext> CONTEXT =
new ThreadLocal<>();
CONTEXT.set(new UserContext(userId));
try {
process();
} finally {
CONTEXT.remove();
}
线程池复用线程时,未 remove 会导致:
- 数据串用;
- ClassLoader 泄漏;
- 对象生命周期被拉长;
- 安全风险。
优先使用显式参数传递;确需上下文时必须成对清理。
13.8 Native 与堆外生命周期
堆内对象由 GC 回收,堆外资源必须显式释放或通过框架的引用计数处理。
try (ByteBuffer buffer = ByteBuffer.allocateDirect(16 * 1024 * 1024)) {
process(buffer);
}
Direct ByteBuffer 的释放时机不应依赖 GC;Netty 等框架有自己的引用计数:
ByteBuf buf = allocator.directBuffer();
try {
process(buf);
} finally {
buf.release();
}
堆外泄漏表现:
- heap 正常;
- RSS 增长;
- Direct buffer OOM;
- native memory 统计增长。
13.9 对象终止
finalize 已被废弃,不应使用。原因:
- 执行时机不确定;
- 可能 resurrection;
- 延迟对象回收;
- 异常被吞;
- 性能差。
替代:
try (Resource resource = open()) {
process(resource);
}
需要响应堆回收时,优先重新设计生命周期,而不是依赖 finalize 或 Cleaner。
13.10 生产案例
案例一:Young GC 频繁
jstat -gcutil <pid> 1000
现象:YGC 每秒多次,Eden 快速填满。
处理:
- 用 allocation profile 找热点;
- 减少日志拼接;
- 复用对象;
- 修复深层查询;
- 评估新生代大小;
- 压测验证。
案例二:Old 缓慢增长
jstat -gcutil <pid> 10000
jcmd <pid> GC.heap_dump /tmp/app.hprof
MAT 分析:
- Dominator Tree;
- Top consumers;
- Path to GC Roots;
- 重复对象;
- ClassLoader。
本章小结
对象生命周期决定 GC 成本:朝生夕死对象适合 Young 复制,长期对象进入老年代,无上限缓存和未清理监听器会把生命周期意外拉长。堆内看可达性,堆外看显式释放。分析老年代问题要结合 GC 日志和堆 dump 引用链。
思考题
- 如何估算对象分配速率?
- 对象为什么会提前晋升?
- ThreadLocal 在线程池中有什么风险?
- 为什么不推荐 finalize?
- 如何分析 Old 区持续增长?