JVM 基础
Java 中级 · Java 进阶
「一次编写,到处运行」靠的就是 JVM。本篇讲清楚:JVM 内存到底分成哪几块、对象在堆里长什么样、垃圾是怎么被判定的、分代收集为什么这么设计。这是后续「JVM 深入(G1/ZGC/调优)」的地基。
一、JVM 运行时数据区
| 区域 | 线程 | 存放 | 异常 |
|---|---|---|---|
| 程序计数器 | 私有 | 当前字节码行号 | 无 OOM |
| 虚拟机栈 | 私有 | 栈帧(局部变量、操作数栈) | StackOverflowError / OOM |
| 本地方法栈 | 私有 | native 方法栈帧 | 同上 |
| 堆 | 共享 | 对象实例、数组 | OutOfMemoryError: Java heap space |
| 方法区/元空间 | 共享 | 类元信息、运行时常量池 | OutOfMemoryError: Metaspace |
JDK 8 的「永久代 → 元空间」
JDK 7 及以前方法区用永久代(PermGen),受 -XX:MaxPermSize 限制;JDK 8 改为元空间(Metaspace),使用本地内存,默认只受物理内存限制。字符串常量池在 JDK 7 就从永久代移到了堆里。
二、对象在堆里的结构
一个对象由三部分组成(HotSpot):
- Mark Word:存哈希码、GC 分代年龄(4 bit,所以最大 15)、锁标志位。
- 压缩指针:64 位 JVM 默认开启
-XX:+UseCompressedOops,引用从 8 字节压到 4 字节,省内存。
2.1 对象创建过程
text
new 指令
→ 1. 类加载检查(类是否已加载)
→ 2. 分配内存(指针碰撞 / 空闲列表;并发用 TLAB 或 CAS)
→ 3. 内存置零
→ 4. 设置对象头(Mark Word + 类型指针)
→ 5. 执行 <init> 构造方法逃逸分析与栈上分配
JIT 会做逃逸分析:如果一个对象不会逃出方法(不被外部引用),可能直接在栈上分配甚至标量替换(打散成局部变量),根本不进堆。所以「对象一定在堆上」并不绝对。
三、垃圾判定:可达性分析
JVM 不用「引用计数」(解决不了循环引用),而用可达性分析:从一组 GC Roots 出发,能走到的对象存活,走不到的可回收。
GC Roots 包括:
- 虚拟机栈中局部变量引用的对象
- 方法区静态属性引用的对象
- 方法区常量引用的对象
- 本地方法栈 JNI 引用的对象
- 活跃线程、同步锁持有的对象
3.1 引用类型
| 类型 | 回收时机 | 典型用途 |
|---|---|---|
| 强引用 | 永不(除非不可达) | new 出来的对象 |
软引用 SoftReference | 内存不足时 | 缓存 |
弱引用 WeakReference | 下次 GC 必收 | ThreadLocal key、WeakHashMap |
虚引用 PhantomReference | 随时,仅用于回收通知 | 堆外内存清理(DirectByteBuffer) |
四、垃圾收集算法与分代收集
4.1 三种基础算法
| 算法 | 做法 | 缺点 |
|---|---|---|
| 标记-清除 | 标记存活对象,清除其余 | 产生内存碎片 |
| 标记-复制 | 存活对象复制到另一半 | 浪费一半空间 |
| 标记-整理 | 标记后把存活对象向一端移动 | 移动成本高 |
4.2 分代收集(弱分代假说)
绝大多数对象朝生夕灭,熬过多次 GC 的才进入老年代。于是:
- 新生代用复制算法(Eden : S0 : S1 = 8 : 1 : 1),Minor GC 频繁但快。
- 老年代用标记-整理(或与新生代配合),Major/Full GC 慢。
- 对象「晋升老年代」的条件:年龄达阈值(默认 15)、大对象、Survivor 中同年龄对象占比超一半(动态年龄判断)。
4.3 常见收集器
| 收集器 | 作用区 | 特点 |
|---|---|---|
| Serial | 新生代 | 单线程,客户端/小内存 |
| ParNew | 新生代 | 多线程版 Serial |
| Parallel Scavenge | 新生代 | 吞吐量优先(JDK 8 默认) |
| Parallel Old | 老年代 | 吞吐量优先 |
| CMS | 老年代 | 并发标记清除,低停顿(JDK 9 废弃,14 移除) |
| G1 | 全堆 | Region 化,可预测停顿(JDK 9+ 默认) |
| ZGC | 全堆 | 染色指针,超低停顿(大堆首选) |
五、一次 Minor GC 的完整流程
text
1. 新对象在 Eden 分配(TLAB 优先)
2. Eden 满 → 触发 Minor GC,Stop-The-World
3. 可达性分析,标记 Eden + 一个 Survivor 中的存活对象
4. 复制到另一个空的 Survivor,年龄 +1;太大的直接进老年代
5. 清空 Eden 和原 Survivor
6. 若某对象年龄达阈值 → 晋升老年代
7. 若老年代空间不足 → 触发 Full GC(更慢)频繁 Full GC 的典型原因
- 内存泄漏:
ThreadLocal没 remove、静态集合只增不减、监听器未注销。 - 大对象直接进老年代:一次查全表、超大数组。
- 元空间不足:动态生成类过多(CGLIB 代理、热部署)。
- Survivor 太小 / 晋升阈值不合理,导致对象过早晋升。
六、最佳实践
- 理解「堆内存 = 新生代 + 老年代」,调优先看 GC 日志再动参数。
-Xms与-Xmx设成一样,避免堆反复伸缩。- 监控 GC:
-Xlog:gc*(JDK 9+)、GCEasy 在线分析;关注 Full GC 频率与耗时。 - 减少对象创建:复用、对象池、避免循环内
new/字符串拼接。 ThreadLocal必须remove,Web 容器线程池复用会串数据。
📚 官方文档与参考资料
- Oracle · Java Virtual Machine Specification (SE 21)
- Oracle · JVM 内存管理/GC 调优指南
- JDK 21 HotSpot VM 选项
- OpenJDK · G1 GC
✏️ 练习与自测
- 画出 JVM 运行时数据区,标出哪些线程私有、哪些线程共享,各自的 OOM 类型是什么。
- 一个对象由哪几部分组成?Mark Word 里存了哪些信息?为什么分代年龄最大是 15?
- 为什么 JVM 用可达性分析而不用引用计数?列出至少 4 种 GC Roots。
- 描述一次 Minor GC 的完整过程,并解释对象在什么条件下晋升老年代。
本节小结
JVM 内存 = 线程私有(PC/栈/本地方法栈)+ 线程共享(堆/方法区);对象 = 对象头 + 实例数据 + 对齐填充,锁状态和年龄在 Mark Word 里;垃圾用可达性分析 + GC Roots判定;新生代复制、老年代整理,朝生夕灭的对象用 Minor GC 快速回收。这套模型是后面 G1/ZGC 与调优的起点。