Skip to content

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 容器线程池复用会串数据。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 画出 JVM 运行时数据区,标出哪些线程私有、哪些线程共享,各自的 OOM 类型是什么。
  2. 一个对象由哪几部分组成?Mark Word 里存了哪些信息?为什么分代年龄最大是 15?
  3. 为什么 JVM 用可达性分析而不用引用计数?列出至少 4 种 GC Roots。
  4. 描述一次 Minor GC 的完整过程,并解释对象在什么条件下晋升老年代。

本节小结

JVM 内存 = 线程私有(PC/栈/本地方法栈)+ 线程共享(堆/方法区);对象 = 对象头 + 实例数据 + 对齐填充,锁状态和年龄在 Mark Word 里;垃圾用可达性分析 + GC Roots判定;新生代复制、老年代整理,朝生夕灭的对象用 Minor GC 快速回收。这套模型是后面 G1/ZGC 与调优的起点。