为什么要懂 JVM

日常写业务代码时,JVM 似乎是透明的:你 new 出对象、调用方法,剩下的交给它。但一旦出现线上问题——服务在高峰期越来越慢、堆内存被慢慢吃光、某次 Full GC 卡了好几秒——如果不知道内存被谁占着、回收是怎么发生的,排查就只能靠猜。

懂 JVM 带来的是三种能力:知道对象存在哪,从而能判断内存压力来自堆还是栈;知道回收怎么做,从而能解释停顿从哪里来;知道参数怎么配,从而能在容量和延迟之间做出有依据的取舍。它不是面试八股,而是线上问题的诊断语言。

把 JVM 看成一台会自动打扫房间的机器:它替你回收垃圾,但打扫的时机和方式决定了你会不会被「暂停」。理解它的规则,才能安排合适的参数。
本文核心知识点
  • 运行时数据区堆、虚拟机栈、方法区、程序计数器与本地方法栈各司其职。
  • 可达性分析从 GC Roots 出发遍历,走不到的对象才是垃圾。
  • 分代收集新生代频繁回收、老年代少而慢,对应不同的收集算法。
  • 调优方法先看指标再看日志,最后才动参数,避免盲目调参。
JVM 内存模型示意:左侧逐层收窄的栈帧堆叠板,右侧半透明堆容器内漂浮对象并环绕回收箭头与圆环仪表
内存即布局:栈负责执行、堆负责存放,回收机制在两者之间维持平衡。

运行时数据区:堆、栈与方法区

JVM 在执行 Java 程序时把内存划分成几块用途不同的区域,理解每块区域是否线程共享、存放什么、可能抛什么异常,是排查问题的第一步。下面这张表是最实用的速查:

区域线程共享主要存放典型异常
堆(Heap)共享对象实例、数组OutOfMemoryError
虚拟机栈私有栈帧、局部变量、操作数StackOverflowError / OOM
方法区(元空间)共享类元信息、常量、静态变量OutOfMemoryError
程序计数器私有当前字节码行号无
本地方法栈私有本地方法调用状态StackOverflowError

最常打交道的是堆与虚拟机栈。堆是所有线程共享的对象存储区,也是垃圾回收的主战场;虚拟机栈是每个线程私有的,方法每调用一次就压入一个栈帧,方法返回就弹出。栈帧里保存局部变量表和操作数栈,所以递归太深会撑爆栈,而对象不断累积会撑爆堆。

// 两种典型的「撑爆」:一个爆栈,一个爆堆
public class MemoryDemo {

    // 无限递归 -> StackOverflowError
    static long depth = 0;
    static void recurse() {
        depth++;
        recurse();
    }

    // 不断持有对象 -> OutOfMemoryError: Java heap space
    static java.util.List<byte[]> holder = new java.util.ArrayList<>();

    public static void main(String[] args) {
        try {
            recurse();
        } catch (StackOverflowError e) {
            System.out.println("爆栈,调用深度约 " + depth);
        }

        try {
            while (true) {
                holder.add(new byte[1024 * 1024]);   // 每次 1MB
            }
        } catch (OutOfMemoryError e) {
            System.out.println("堆溢出,已占用 " + holder.size() + " MB");
        }
    }
}

运行这段代码时加上 -Xss256k 可以更快看到爆栈,加上 -Xmx64m 可以更快看到堆溢出。主动制造这两种异常,是理解内存边界最直接的方式。

对象的一生:创建、分配与引用

一个对象从被 new 出来到被回收,大致经历几个阶段:类加载检查、分配内存、初始化零值、设置对象头、执行构造方法,之后进入使用期,最后在某个回收周期被判定为不可达并释放。理解这条链路,很多现象就能解释。

  • 分配位置:绝大多数对象分配在堆的 Eden 区;如果逃逸分析发现对象不会被外部引用,可能被优化成栈上分配或标量替换。
  • 指针压缩:开启后对象引用只占 4 字节,能显著减少内存占用,是默认开启的优化。
  • 对象头:保存哈希码、GC 分代年龄、锁状态等信息,所以「一个小对象」实际占用的内存比字段之和要多。
  • 引用类型:强引用绝不会被回收;软引用在内存不足时才回收;弱引用下次回收就走;虚引用只用于跟踪回收事件。

引用类型直接影响缓存策略。用 SoftReference 做内存敏感缓存、用 WeakHashMap 做伴随键的生命周期管理,都是很常见的做法。反过来,如果一个本该被释放的对象被静态集合一直强引用着,它就会永远活着——这正是内存泄漏的主要来源。

垃圾回收:怎么判断「垃圾」

回收的第一步是找出哪些对象已经没用了。主流虚拟机采用可达性分析:从一组称为 GC Roots 的起点出发,沿着引用链向下遍历,能走到的对象是「存活」,走不到的就是「可回收」。

GC Roots 通常包括:虚拟机栈中局部变量表引用的对象、方法区中静态属性引用的对象、常量引用的对象、本地方法栈中引用的对象,以及活跃线程本身。理解了这一点,就明白为什么「把对象挂到静态集合上」会让它一直存活。

确定存活对象后,回收本身有三种经典思路,各自的取舍不同:

  • 标记—清除:直接清理垃圾,实现简单,但会产生大量内存碎片。
  • 标记—整理:清除后把存活对象向一端移动,避免碎片,代价是移动开销。
  • 复制算法:把存活对象复制到另一块空间再整体清空,效率高但需要额外空间。

正因为没有一种算法全能,虚拟机采用分代收集:新生代对象「朝生夕死」,用复制算法快速回收;老年代对象存活率高,用标记—整理类算法。对象在新生代经历多次回收仍然存活,就会被晋升到老年代,这也是为什么长时间持有的大对象更容易触发老年代压力。

常见垃圾收集器与选型

收集器是回收算法的工程实现,不同收集器在吞吐量和停顿时间上各有侧重。选择哪一个,取决于你的应用更怕「慢」还是更怕「卡」。

收集器作用范围特点适用场景
Serial新生代 / 老年代单线程,简单高效客户端、小内存
ParNew / Parallel新生代多线程并行回收吞吐优先的后台服务
Parallel Scavenge新生代关注吞吐量,可自动调节批处理、计算密集型
CMS老年代并发标记清除,低停顿旧版本的响应优先场景
G1整堆分区域回收,可预测停顿大堆、均衡场景(现代默认)
ZGC / Shenandoah整堆超低停顿,超大堆延迟极其敏感的服务

选型的经验顺序是:先确认堆大小与延迟目标,再选收集器。堆在几十 GB 以内、希望兼顾吞吐与停顿,G1 是稳妥的默认值;堆非常大且停顿要求苛刻,才考虑 ZGC 这类低延迟收集器;纯粹的后台批处理任务,吞吐量优先的 Parallel 反而更省资源。需要注意的是,收集器之间并非可以任意组合,具体搭配要参考所使用 JDK 版本的官方说明。

内存溢出与内存泄漏的排查

这两个词经常被混用,但成因完全不同。内存溢出是内存真的不够用,可能是容量配置偏小,也可能是短时间内产生了过多对象;内存泄漏则是对象本该被回收却一直被引用着,导致可用空间越来越少,最终同样表现为溢出。

  • 先看异常信息:Java heap space 指向堆,Metaspace 指向元空间,unable to create new native thread 往往是线程数过多。
  • 再看回收日志:老年代占用是否持续走高、Full GC 后是否回落,是区分溢出与泄漏的关键。
  • 然后取堆快照:用 jmap -dump 导出,再用分析工具查看占用最大的对象及其引用链。
  • 最后定位代码:沿着引用链往回找,看是哪一层把它挂在了长生命周期的容器上。

常见的泄漏模式有几种:静态集合只增不减、监听器或回调注册后忘记注销、线程池里的任务持有外部对象、缓存没有设置淘汰策略。它们共同的特征是——引用的生命周期比对象本身长得多。