JVM 内存模型与垃圾回收机制详解
JVM 内存模型与垃圾回收机制详解
前言
JVM(Java Virtual Machine)是 Java 生态的基石。理解 JVM 内存模型与垃圾回收(GC)机制,是写出高性能、低延迟、不泄漏的 Java 应用的前提。本文将从内存区域划分、对象内存布局、GC 算法、主流收集器、调优实战等维度,系统讲解 JVM 内存与 GC 的方方面面。
一、JVM 内存区域划分
JVM 在运行时将内存划分为若干区域,不同区域承担不同职责,生命周期也各不相同。
1.1 总览
┌─────────────────────────────────────────────┐
│ JVM 运行时内存 │
├──────────────┬──────────────────────────────┤
│ 线程私有 │ 线程共享 │
├──────────────┼──────────────────────────────┤
│ 程序计数器 │ 堆(Heap) │
│ 虚拟机栈 │ 方法区(Method Area) │
│ 本地方法栈 │ │
└──────────────┴──────────────────────────────┘
1.2 程序计数器(Program Counter Register)
- 作用:记录当前线程执行的字节码行号,分支、循环、跳转、异常处理都依赖它。
- 特性:线程私有,唯一不会发生 OOM 的区域(容量固定)。
- 意义:线程切换后能恢复到正确执行位置。
1.3 虚拟机栈(VM Stack)
-
作用:存放方法栈帧。每个方法调用创建一个栈帧,包含:
- 局部变量表:存放基本类型、对象引用、returnAddress。
- 操作数栈:执行字节码指令的工作区。
- 动态链接:指向运行时常量池中该方法的引用。
- 方法出口:方法返回信息。
-
异常:
StackOverflowError:栈深度超过限制(如无限递归)。OutOfMemoryError:栈容量动态扩展时内存不足。
-
线程私有,生命周期与线程相同。
1.4 本地方法栈(Native Method Stack)
- 作用:为 Native 方法(C/C++ 实现)服务,与虚拟机栈作用类似。
- 特性:线程私有,HotSpot 中与虚拟机栈合二为一。
1.5 堆(Heap)
- 作用:存放对象实例和数组,是 GC 的主战场。
- 特性:线程共享,所有线程都可访问。
- 分代结构(HotSpot):
┌──────────────────────────────────────────┐
│ 堆 │
├─────────────────┬────────────────────────┤
│ 新生代 │ 老年代 │
│ (Young Gen) │ (Old Gen) │
├───────┬─────────┤ │
│ Eden │ Survivor│ │
│ │ S0 | S1 │ │
└───────┴─────────┴────────────────────────┘
- 异常:
OutOfMemoryError: Java heap space。
1.6 方法区(Method Area)
- 作用:存放类信息、常量池、静态变量、JIT 编译后的代码。
- 演进:
- JDK 7 及以前:HotSpot 用"永久代"(PermGen)实现,易 OOM。
- JDK 8+:移除永久代,改用"元空间"(Metaspace),使用本地内存,容量受限于机器物理内存。
- 异常:
OutOfMemoryError: Metaspace(JDK 8+)。
1.7 运行时常量池
- 方法区的一部分,存放编译期生成的各种字面量和符号引用。
- JDK 7+ 字符串常量池被移至堆中。
1.8 直接内存(Direct Memory)
- 作用:NIO 使用
ByteBuffer.allocateDirect()分配的堆外内存,避免堆内外数据拷贝,提升 IO 性能。 - 特性:不归 JVM GC 直接管理(通过 Cleaner 机制回收),但受
-XX:MaxDirectMemorySize限制。 - 异常:
OutOfMemoryError: Direct buffer memory。
二、对象内存布局
在 HotSpot 中,一个 Java 对象在堆中由三部分组成:
┌──────────────────────────────────┐
│ 对象头(Object Header) │
├──────────────────────────────────┤
│ 实例数据(Instance Data) │
├──────────────────────────────────┤
│ 对齐填充(Padding) │
└──────────────────────────────────┘
2.1 对象头
- Mark Word:存放 hashCode、GC 分代年龄、锁状态标记、偏向锁线程 ID 等,64 位 JVM 占 8 字节。
- 类型指针(Klass Pointer):指向类元数据,开启指针压缩时占 4 字节,否则 8 字节。
- 数组长度:仅数组对象有,占 4 字节。
2.2 实例数据
存放对象字段值,字段顺序受字段类型、继承顺序影响,相同宽度的字段会被分配在一起(紧凑布局)。
2.3 对齐填充
对象大小必须是 8 字节的整数倍,不足则补齐。
2.4 计算示例
一个空对象 Object 在开启指针压缩的 64 位 JVM 上:
- Mark Word:8 字节
- Klass Pointer:4 字节
- 实例数据:0 字节
- Padding:4 字节
- 合计:16 字节
这也是为什么频繁创建小对象会带来显著内存开销。
三、对象访问定位
JVM 规范未规定对象访问方式,HotSpot 默认使用直接指针:
reference ──→ 对象实例(堆) ──→ 类元数据(方法区)
另一种方式是句柄:
reference ──→ 句柄池 ──→ 对象实例(堆)
──→ 类元数据(方法区)
| 方式 | 优势 | 劣势 |
|---|---|---|
| 直接指针 | 访问快,少一次间接 | GC 移动对象时需更新所有引用 |
| 句柄 | GC 移动对象只需改句柄 | 多一次间接寻址,访问稍慢 |
HotSpot 选择直接指针,牺牲 GC 效率换取访问速度。
四、GC 根节点(GC Roots)
GC 从 GC Roots 出发,通过引用链判断对象存活。可作为 GC Roots 的对象:
- 虚拟机栈中引用的对象(局部变量、参数)。
- 本地方法栈中 JNI 引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 同步锁(synchronized)持有的对象。
- JVM 内部引用(如基本类型异常对象、类加载器)。
关键:GC Roots 是可达性分析的起点,对象只要从任一 GC Roots 可达,就不会被回收。
五、引用类型
Java 提供四种引用强度,影响 GC 回收时机:
| 引用类型 | 类 | 回收时机 | 典型用途 |
|---|---|---|---|
| 强引用 | Object |
永不回收(除非置 null) | 普通对象引用 |
| 软引用 | SoftReference |
内存不足时回收 | 内存敏感缓存 |
| 弱引用 | WeakReference |
下次 GC 即回收 | WeakHashMap、监听器 |
| 虚引用 | PhantomReference |
随时回收,get() 永远返回 null | 跟踪对象被回收的时机 |
5.1 软引用缓存示例
public class ImageCache {
private final Map<String, SoftReference<BufferedImage>> cache = new ConcurrentHashMap<>();
public void put(String key, BufferedImage image) {
cache.put(key, new SoftReference<>(image));
}
public BufferedImage get(String key) {
SoftReference<BufferedImage> ref = cache.get(key);
if (ref == null) return null;
BufferedImage img = ref.get();
if (img == null) {
cache.remove(key); // 已被 GC 回收
}
return img;
}
}
六、GC 算法
6.1 标记-清除(Mark-Sweep)
1. 标记:从 GC Roots 遍历,标记所有存活对象
2. 清除:遍历堆,回收未标记对象
- 缺点:产生内存碎片,分配大对象时可能触发提前 GC。
- 效率:随堆增大而下降。
6.2 标记-复制(Copying)
1. 将内存分为两块
2. GC 时把存活对象复制到另一块
3. 清空原区域
- 优点:无碎片,分配快(指针碰撞)。
- 缺点:可用内存减半。
- 适用:新生代,因为新生代对象存活率低,复制开销小。
6.3 标记-整理(Mark-Compact)
1. 标记存活对象
2. 将存活对象向一端移动,整理紧凑
3. 清理边界外空间
- 优点:无碎片,不浪费空间。
- 缺点:移动对象需更新引用,STW 时间长。
- 适用:老年代。
6.4 分代收集
HotSpot 采用分代策略,不同区域用不同算法:
- 新生代:标记-复制(Eden + Survivor0 + Survivor1,比例默认 8:1:1)。
- 老年代:标记-清除 或 标记-整理。
新生代 GC 流程
1. 新对象优先分配到 Eden
2. Eden 满触发 Minor GC
3. Eden + Survivor 中存活对象复制到另一 Survivor
4. 年龄 +1,达到阈值(默认 15)晋升老年代
5. 大对象直接进入老年代(避免在新生代来回复制)
七、GC 收集器详解
7.1 收集器对比总览
| 收集器 | 区域 | 算法 | 线程 | STW | 适用场景 |
|---|---|---|---|---|---|
| Serial | 新生代/老年代 | 复制/整理 | 单线程 | 全程 | 客户端、小堆 |
| ParNew | 新生代 | 复制 | 多线程 | 部分 | 配合 CMS |
| Parallel Scavenge | 新生代 | 复制 | 多线程 | 部分 | 吞吐量优先 |
| Parallel Old | 老年代 | 整理 | 多线程 | 部分 | 吞吐量优先 |
| CMS | 老年代 | 标记-清除 | 多线程 | 部分 | 低延迟 |
| G1 | 全堆 | 整体复制+局部整理 | 多线程 | 部分 | 大堆、低延迟 |
| ZGC | 全堆 | 染色指针+读屏障 | 多线程 | <10ms | 超低延迟、大堆 |
| Shenandoah | 全堆 | 转发指针 | 多线程 | <10ms | 超低延迟、大堆 |
7.2 CMS(Concurrent Mark Sweep)
目标:最小化 STW 时间,适合对延迟敏感的应用。
四个阶段:
1. 初始标记(STW):标记 GC Roots 直接关联对象,速度快
2. 并发标记:从 GC Roots 遍历对象图,与用户线程并发
3. 重新标记(STW):修正并发标记期间变动的引用
4. 并发清除:清除未标记对象,与用户线程并发
优点:并发标记和清除,STW 时间短。
缺点:
- 基于"标记-清除",产生碎片。
- 对 CPU 敏感,并发占用用户线程。
- 无法处理浮动垃圾(并发标记期间产生的新垃圾)。
- JDK 9 起被标记为废弃,JDK 14 移除。
7.3 G1(Garbage First)
目标:在可控延迟下处理大堆(4GB+),取代 CMS。
内存布局:放弃固定分代,将堆划分为多个大小相等的 Region:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│E │E │S │O │O │H │H │O │E │S │ E=Eden S=Survivor O=Old H=Humongous
├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤
│O │E │O │S │O │E │O │O │E │O │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
每个 Region 动态扮演 Eden/Survivor/Old 角色,Humongous 存放大对象。
GC 流程:
1. 初始标记(STW):标记 GC Roots 直接关联,借助 Minor GC
2. 根区域扫描:扫描 Survivor 引用的 Old Region
3. 并发标记:遍历对象图
4. 重新标记(STW):处理 SATB 记录
5. 筛选回收(STW):按"回收收益"排序,回收价值高的 Region(Garbage First)
特点:
- 可预测停顿:
-XX:MaxGCPauseMillis设定目标停顿时间,G1 优先回收收益高的 Region。 - 整体看是标记-整理,局部看是复制,无碎片。
- JDK 9+ 默认收集器。
7.4 ZGC(Z Garbage Collector)
目标:亚毫秒级停顿,支持 TB 级堆。
核心技术:
- 染色指针:在 64 位指针的高位嵌入标记信息(Marked0/Marked1/Remapped/Finalizable)。
- 读屏障:每次读取对象引用时检查指针颜色,按需转发。
- 并发整理:对象移动与用户线程并发执行。
性能(JDK 15+ 生产可用):
- 堆范围:8MB ~ 16TB。
- STW 时间:< 10ms,且不随堆增大而增长。
- 吞吐量开销:约 15%。
7.5 Shenandoah
由 RedHat 开发,与 ZGC 目标类似,核心用转写屏障(Brooks Pointer)实现并发整理:
- 每个对象多一个转发指针,指向自身或新副本。
- GC 移动对象时,先复制到新位置,再更新转发指针。
- 用户线程访问时通过转发指针自动定位新对象。
八、内存分配策略
8.1 对象分配流程
1. TLAB 分配(Thread Local Allocation Buffer)
└─ 成功 → 返回
2. Eden 区分配(指针碰撞)
└─ 成功 → 返回
3. 触发 Minor GC
4. 大对象 → 直接老年代(-XX:PretenureSizeThreshold)
5. 老年代分配
└─ 失败 → Full GC
8.2 逃逸分析与栈上分配
JIT 编译器通过逃逸分析判断对象是否逃逸出方法/线程:
- 未逃逸:可在栈上分配,方法结束自动回收,无需 GC。
- 可替换为标量:对象拆解为基本类型变量,进一步优化。
public int add() {
Point p = new Point(1, 2); // 未逃逸,可能栈上分配
return p.x + p.y;
}
8.3 TLAB 优化
每个线程在 Eden 区预分配一小块(TLAB),线程内对象分配无需同步,避免指针碰撞的 CAS 开销。
九、常见 OOM 类型与排查
9.1 OOM 类型
| 类型 | 原因 | 解决方向 |
|---|---|---|
Java heap space |
堆内存不足,对象过多 | 增大堆、排查泄漏、优化缓存 |
Metaspace |
类元数据过多 | 检查动态代理、类加载器泄漏 |
GC overhead limit |
GC 回收效率低(98% 时间 GC 但回收 <2%) | 排查内存泄漏 |
Direct buffer memory |
堆外内存不足 | 检查 NIO 使用、限制 DirectMemory |
StackOverflowError |
栈深度超限 | 检查递归、增大栈 |
unable to create new native thread |
线程数超系统限制 | 控制线程数、用线程池 |
9.2 排查工具
- jmap:
jmap -histo:live <pid>查看对象直方图。 - jstack:
jstack <pid>查看线程栈,定位死锁、阻塞。 - jstat:
jstat -gcutil <pid> 1000实时查看 GC 情况。 - MAT:分析 heap dump,定位泄漏引用链。
- Arthas:在线诊断,
dashboard、thread、heapdump等命令。
9.3 内存泄漏典型场景
// 1. 静态集合持有对象
static List<Object> cache = new ArrayList<>();
cache.add(bigObject); // 永不释放
// 2. 未关闭资源
InputStream is = new FileInputStream("file");
// 忘记 is.close(),Finalizer 延迟释放
// 3. 监听器未反注册
button.addListener(listener);
// 移除组件时未 removeListener
// 4. ThreadLocal 未清理
threadLocal.set(bigObject);
// 线程池中线程复用,ThreadLocal 持续累积
// 5. 内部类持有外部引用
class Outer {
class Inner { } // Inner 隐式持有 Outer.this
}
十、GC 调优实战
10.1 常用参数
| 参数 | 作用 |
|---|---|
-Xms / -Xmx |
初始堆 / 最大堆 |
-Xmn |
新生代大小 |
-XX:NewRatio |
老年代:新生代 比例 |
-XX:SurvivorRatio |
Eden:Survivor 比例 |
-XX:MaxTenuringThreshold |
晋升老年代年龄阈值 |
-XX:PretenureSizeThreshold |
大对象直接进老年代阈值 |
-XX:+UseG1GC |
启用 G1 |
-XX:MaxGCPauseMillis |
G1 目标停顿时间 |
-XX:+UseZGC |
启用 ZGC |
-XX:+HeapDumpOnOutOfMemoryError |
OOM 时自动 dump |
-XX:HeapDumpPath |
dump 文件路径 |
10.2 调优原则
- 先保证不泄漏,再谈调优:架构与代码层面避免内存泄漏是根本。
- 堆大小适度:并非越大越好,堆大意味着 GC 单次耗时长。一般让堆在物理内存的 50%-70%。
- 新生代不宜过小:过小导致频繁 Minor GC,对象过早晋升老年代。
- 优先 G1/ZGC:JDK 11+ 默认 G1,大堆低延迟场景用 ZGC。
- 监控驱动调优:基于 GC 日志和监控数据调整,而非拍脑袋。
10.3 场景化配置
吞吐量优先(批处理):
-Xms4g -Xmx4g
-XX:+UseParallelGC
-XX:MaxGCPauseMillis=200
-XX:GCTimeRatio=99
延迟优先(Web 服务):
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=8m
超低延迟(金融、游戏):
-Xms8g -Xmx8g
-XX:+UseZGC
-XX:ZCollectionInterval=5
10.4 GC 日志分析
启用 GC 日志(JDK 9+ 统一格式):
-Xlog:gc*=info:file=gc.log:time,uptime,level,tags:filecount=10,filesize=100m
关注指标:
- 停顿时间:
Pause Young/Pause Old的实际耗时。 - GC 频率:相邻 GC 间隔,过密说明堆或新生代过小。
- 回收效率:GC 前后堆使用量差值。
- 晋升速率:老年代增长速度,过快说明新生代过小或 Survivor 不够。
十一、对象生命周期与 finalize 替代
11.1 finalize 的问题
@Override
protected void finalize() throws Throwable {
// 不推荐!
}
- 执行时机不确定,由 Finalizer 线程异步调用。
- Finalizer 线程优先级低,可能导致对象长期不回收,引发 OOM。
- 异常被吞没,无法感知。
- JDK 9 起废弃,JDK 18 移除。
11.2 替代方案:Cleaner
public class FileResource implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
private final Cleaner.Cleanable cleanable;
private final FileChannel channel;
public FileResource(String path) throws IOException {
this.channel = FileChannel.open(Path.of(path));
this.cleanable = cleaner.register(this, new CleanupAction(channel));
}
@Override
public void close() {
cleanable.clean(); // 显式关闭
}
private static class CleanupAction implements Runnable {
private final FileChannel channel;
CleanupAction(FileChannel channel) { this.channel = channel; }
@Override
public void run() {
try {
channel.close();
} catch (IOException e) {
// 记录日志
}
}
}
}
最佳实践:优先
try-with-resources显式管理资源,Cleaner 作为兜底。
十二、实战案例:Full GC 频繁排查
12.1 现象
某服务每 10 分钟一次 Full GC,每次停顿 3 秒,接口 P99 抖动明显。
12.2 排查步骤
- 查看 GC 日志:
jstat -gcutil <pid> 1000
发现老年代使用率持续在 90%+,每次 Full GC 后只下降到 80%。
- dump 堆分析:
jmap -dump:format=b,file=heap.hprof <pid>
用 MAT 打开,发现 HashMap 占用 60% 堆,其中 50 万个 UserSession 对象。
- 定位代码:
public class SessionManager {
private static final Map<String, UserSession> sessions = new HashMap<>();
public static void put(String token, UserSession session) {
sessions.put(token, session);
}
// 缺少 remove / 过期清理逻辑
}
SessionManager 用静态 HashMap 缓存会话,从未清理过期会话,导致内存泄漏。
- 修复:
public class SessionManager {
private static final Cache<String, UserSession> sessions = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterAccess(30, TimeUnit.MINUTES)
.build();
public static void put(String token, UserSession session) {
sessions.put(token, session);
}
public static UserSession get(String token) {
return sessions.getIfPresent(token);
}
}
改用 Caffeine 缓存,设置容量上限和过期策略。
- 验证:Full GC 频率降至每小时一次,停顿 < 200ms。
十三、总结
JVM 内存与 GC 是 Java 性能调优的核心。要点回顾:
- 内存区域:堆、方法区、栈、直接内存各司其职,OOM 类型对应不同区域。
- 对象布局:对象头 + 实例数据 + 对齐填充,小对象开销不容忽视。
- GC 算法:复制(新生代)、整理(老年代)、分代组合是主流。
- 收集器演进:Serial → Parallel → CMS → G1 → ZGC/Shenandoah,趋势是并发、低延迟、大堆。
- 调优核心:先治泄漏,再调参数;监控驱动,场景化配置。
- 新趋势:ZGC 在 JDK 21 引入分代模式,吞吐与延迟兼顾,未来可期。
理解 JVM 不是为了背参数,而是为了在问题发生时能快速定位、合理决策。把内存模型、GC 管法、收集器特性内化为思维模型,比记住任何调优模板都重要。
- 点赞
- 收藏
- 关注作者
评论(0)