老李写代码

JVM 内存到底分几块:各区职责、版本演进、启动流程与回收机制

#java#jvm

先说清楚题目:本文讲的是 HotSpot 虚拟机的运行时数据区,也就是 JVM 进程里的内存是怎么划分、谁放什么、什么时候回收。它常被叫作「JVM 内存模型」,但严格说那个名字属于另一件事——Java 内存模型(JMM,JLS 第 17 章),讲的是多线程下的 happens-before、可见性与重排序,和内存怎么分区无关,本文不涉及。

下面按这个顺序走:先看全景图,再逐个区讲职责、参数、溢出报什么错、怎么回收;然后讲从 JDK 6 到 JDK 25 这些区是怎么搬家的;接着讲 java -jar 敲下去之后 JVM 是怎么把这些区建起来的;最后讲 GC 的触发时机与机制,以及排查时先看哪些命令。

全景:线程私有与线程共享

线程私有 · 每个线程一份 线程 1 PC 寄存器 虚拟机栈 本地方法栈 线程 2 PC 寄存器 虚拟机栈 本地方法栈 线程 N PC 寄存器 虚拟机栈 本地方法栈 线程共享 · Java 堆(-Xms / -Xmx) Eden S0 S1 老年代 另含字符串常量池、类静态变量(JDK 7 起) 线程共享 · 本地内存(堆外) 元空间 Metaspace类元数据 + 压缩类空间 代码缓存JIT 编译出的机器码 直接内存NIO DirectByteBuffer JVM 自身线程栈、GC 结构、符号表
图 1 JVM 运行时内存全景:线程私有区随线程生灭,堆由 GC 管理,其余在堆外

先抓住两条主线:

  • 线程私有:PC 寄存器、虚拟机栈、本地方法栈。线程创建时分配,线程结束就释放,不需要 GC。
  • 线程共享:堆(GC 的主战场)、方法区(HotSpot 里的落地是元空间),以及规范之外但真实存在的代码缓存、直接内存。

《Java 虚拟机规范》(JVMS §2.5)只规定了六个区:PC 寄存器、JVM 栈、本地方法栈、堆、方法区、运行时常量池。规范只说「有什么、溢出时抛什么」,不规定怎么实现。元空间、压缩类空间、代码缓存、直接内存这些,是 HotSpot 的实现细节,但排查问题时它们一样会出事,所以一并讲。

线程私有的三块

PC 寄存器

每个线程一个,记录当前正在执行的字节码指令地址。线程切换回来时靠它知道从哪继续。执行 native 方法时它的值是未定义的。

它很小,规范里也是唯一一个没有规定任何 OutOfMemoryError 的区。

虚拟机栈(JVM Stack)

每个线程一个栈,每调用一个方法就压入一个栈帧(Frame),方法返回(正常返回或抛异常)就弹出。一个栈帧里有:

组成 放什么
局部变量表 方法参数和局部变量;实例方法的第 0 个槽是 this;long/double 占两个槽
操作数栈 字节码的「草稿纸」,比如 iadd 从这里弹出两个数、压回结果
动态链接 指向当前类的运行时常量池,用来把符号引用解析成直接引用
返回地址 方法返回后回到调用者的哪条指令继续

局部变量表和操作数栈的大小在编译期就算好了,写在 class 文件的 Code 属性里(max_locals、max_stack),运行时不会变。

一个常见误解:「对象在堆上,基本类型在栈上」。准确说法是:局部变量里存的是基本类型的值或对象引用,对象本身在堆上。JIT 做逃逸分析后可以把不逃逸的对象拆成标量(标量替换),看起来像是「分配在栈上」,但那是优化,不是内存模型的一部分。

  • 大小:-Xss(等价于 -XX:ThreadStackSize),Linux x64 上默认 1MB。
  • 溢出:调用层次超过栈容量时抛 java.lang.StackOverflowError,最常见的是递归没有出口。如果是创建线程时连栈都分配不出来,抛的是 java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached——这时往往不是堆不够,而是线程太多、进程数 / 内存上限(ulimit、容器限制)到了。
  • 回收:方法返回时栈帧直接弹出,线程结束时整个栈释放,跟 GC 无关。

本地方法栈

给 native 方法(JNI 调用的 C/C++ 代码)用的栈。HotSpot 的实现是把 JVM 栈和本地方法栈合在一起,一个线程只有一条栈,所以 -Xss 同时管两者,溢出时同样是 StackOverflowError。

线程共享的几块

堆(Heap)

几乎所有对象实例和数组都在这里,是 GC 管理的区域,也是最大的一块。

  • 大小:-Xms 初始、-Xmx 最大。不指定时由 JVM 按物理内存推算,最大堆默认取物理内存的 25%(-XX:MaxRAMPercentage=25,在容器里取的是容器的内存限制)。生产环境一般把 -Xms 和 -Xmx 设成一样,避免运行中扩缩。
  • 分代:传统的分代布局是新生代(Eden + 两个 Survivor,常叫 S0/S1)加老年代。G1 起堆被切成许多等大的 Region,「代」变成了 Region 的逻辑标签(后面单独讲)。
  • 溢出:GC 之后仍然放不下新对象时,抛 java.lang.OutOfMemoryError: Java heap space。另外两个常见变体:
    • GC overhead limit exceeded:GC 花了约 98% 的时间却只回收到不足 2% 的堆,JVM 判定「再跑也没意义」主动抛出(主要见于 Parallel GC)。
    • Requested array size exceeds VM limit:申请的数组超过 VM 允许的上限,和剩多少内存无关。
  • 回收:由 GC 负责,细节见后文「GC:什么时候回收、怎么回收」。

JDK 7 之后,字符串常量池(String.intern() 的那张表引用的字符串对象)和类的静态变量也搬进了堆,所以它们的增长体现在堆用量上,而不是元空间。

方法区与运行时常量池

JVMS 里的「方法区」存放每个类的结构信息:运行时常量池、字段和方法数据、方法的字节码、构造器等。它是规范概念,HotSpot 在不同版本里用不同方式实现它:JDK 7 及以前叫永久代(PermGen),JDK 8 起叫元空间(Metaspace)。

运行时常量池是 class 文件里常量池表在运行时的形态:类加载后,字面量、类名、方法名、字段描述符这些「符号引用」都在这里,随着执行逐步被解析成直接引用(指针、偏移量)。注意别和字符串常量池混淆——前者是每个类一份、跟着类走;后者是全局一张 StringTable,里面引用的 String 对象在堆上。

元空间(Metaspace)与压缩类空间

JDK 8 起,类的元数据(HotSpot 内部的 Klass 结构、方法、常量池等)分配在本地内存里,不再占用 Java 堆(JEP 122)。

  • 大小:
    • -XX:MaxMetaspaceSize:上限,默认不限,只受本机内存约束。生产上建议设一个,免得类加载泄漏把整台机器吃光。
    • -XX:MetaspaceSize:名字容易误导,它不是初始大小,而是第一次因元空间触发 GC 的阈值(高水位线),64 位上默认约 21MB。之后这条水位线会根据回收效果动态上调或下调。启动阶段如果频繁看到 Metadata GC Threshold 引起的 GC,可以把它调大。
    • -XX:CompressedClassSpaceSize:压缩类空间的大小,默认 1GB。
  • 压缩类空间:64 位上默认开启压缩类指针,对象头里指向类的指针只用 32 位偏移量,这就要求 Klass 结构都放在一块连续的、有固定上限的区域里,这就是 Compressed Class Space。它是元空间的一部分,方法字节码等其他元数据不在这里。
  • 溢出:元空间满抛 java.lang.OutOfMemoryError: Metaspace;压缩类空间满抛 java.lang.OutOfMemoryError: Compressed class space。常见原因是动态生成大量类(反射代理、字节码增强、脚本引擎)却没有让类加载器被回收。
  • 回收:见后文「元空间什么时候回收」。

代码缓存(Code Cache)

JIT 编译器把热点方法编成本地机器码,放在代码缓存里;解释器本身、各种桩代码(stub)也在这里。

  • 大小:-XX:ReservedCodeCacheSize。开启分层编译时默认 240MB。
  • 分段:JDK 9 起代码缓存被分成三段(JEP 197)——非方法代码(解释器、stub 等)、带 profiling 的代码(C1 编出的、生命周期较短)、不带 profiling 的代码(C2 编出的、可能长期驻留)。分开放是为了降低碎片、让扫描只看需要的那部分。它在开启分层编译且 ReservedCodeCacheSize ≥ 240MB 时默认启用。
  • 满了会怎样:不会抛 OOM,而是打一条警告并停掉 JIT,例如 CodeCache is full. Compiler has been disabled.(分段时是 CodeHeap 'non-profiled nmethods' is full. Compiler has been disabled.)。程序还能跑,但新的热点方法只能解释执行,表现为性能慢慢变差、CPU 升高,比 OOM 更隐蔽。
  • 回收:见后文。

直接内存(Direct Memory)

ByteBuffer.allocateDirect() 分配的内存在堆外,Netty、NIO 文件与网络读写都大量使用。它不归 GC 直接管理,但它的释放挂在 Java 对象上:每个 DirectByteBuffer 对象背后注册了一个 Cleaner,对象被 GC 回收后,Cleaner 才去释放对应的本地内存。

  • 大小:-XX:MaxDirectMemorySize;不设时默认等于最大堆大小(Runtime.getRuntime().maxMemory())。
  • 溢出:java.lang.OutOfMemoryError: Cannot reserve N bytes of direct buffer memory (allocated: …, limit: …)。老版本的文字是 Direct buffer memory,搜日志时两种都要搜。
  • 回收:见后文。

汇总表

区域 私有/共享 存什么 主要参数 耗尽时
PC 寄存器 私有 当前字节码地址 — 不会溢出
虚拟机栈 / 本地方法栈 私有 栈帧 -Xss StackOverflowError;建线程失败为 unable to create native thread…
堆 共享 对象、数组、字符串池、静态变量 -Xms -Xmx OOM: Java heap space
元空间 共享 类元数据 -XX:MetaspaceSize -XX:MaxMetaspaceSize OOM: Metaspace
压缩类空间 共享 Klass 结构 -XX:CompressedClassSpaceSize OOM: Compressed class space
代码缓存 共享 JIT 机器码 -XX:ReservedCodeCacheSize 警告并停用 JIT
直接内存 共享 NIO 直接缓冲区 -XX:MaxDirectMemorySize OOM: Cannot reserve … direct buffer memory

各版本怎么演变

这一节只讲和内存布局直接相关的变化。

Java 堆 类元数据放哪 JDK 6 对象实例、数组 永久代 PermGen 类元数据 字符串常量池 静态变量 JDK 7 对象实例、数组 字符串常量池 ← 静态变量 ← 永久代 PermGen 类元数据 JDK 8 对象实例、数组 字符串常量池 静态变量 元空间(本地内存) 类元数据 压缩类空间 JDK 21 对象实例、数组 字符串池、静态变量 虚拟线程栈块 ← 元空间 类元数据 弹性归还(16)← 青色加粗 = 该版本新搬入或新增
图 2 从 JDK 6 到 JDK 21:字符串池与静态变量先进堆,类元数据再搬到本地内存
版本 变化 出处
JDK 6 永久代存放类元数据、字符串常量池(intern 的字符串)、类静态变量;大小由 -XX:PermSize / -XX:MaxPermSize 控制,满了报 OutOfMemoryError: PermGen space —
JDK 7 字符串常量池移到堆里;类静态变量随 java.lang.Class 对象移到堆里。永久代仍在,只剩类元数据 JDK 7 发布说明;JDK-6962931、JDK-7017732
JDK 8 移除永久代,类元数据放到本地内存里的元空间;PermSize / MaxPermSize 失效 JEP 122
JDK 9 G1 成为默认 GC;代码缓存分段;统一 JVM 日志 -Xlog(含 GC 日志);CMS 标记为废弃 JEP 248、197、158、271、291
JDK 10 G1 的 Full GC 改为并行;应用类数据共享(AppCDS) JEP 307、310
JDK 11 ZGC(实验);Epsilon——一个只分配不回收的「空」GC,用于测试和极短任务 JEP 333、318
JDK 12 Shenandoah(实验);JDK 自带默认 CDS 归档,开箱就能加快启动;G1 空闲时把未用的堆归还给操作系统 JEP 189、341、346
JDK 14 移除 CMS JEP 363
JDK 15 ZGC、Shenandoah 转正为产品特性(默认仍是 G1) JEP 377、379
JDK 16 弹性元空间:更及时地把空闲元空间还给操作系统,减少碎片 JEP 387
JDK 20 移除代码缓存清扫器(Sweeper),代码缓存的回收改由 GC 周期驱动 JDK-8290025
JDK 21 分代 ZGC;虚拟线程正式发布,虚拟线程的栈以「栈块」(stack chunk)对象的形式存放在堆里 JEP 439、444
JDK 23 ZGC 默认使用分代模式 JEP 474
JDK 24 移除非分代 ZGC;紧凑对象头(实验):64 位上对象头从 96~128 位压到 64 位;分代 Shenandoah(实验) JEP 490、450、404
JDK 25 紧凑对象头转正(仍需 -XX:+UseCompactObjectHeaders 手动开启);分代 Shenandoah 转正(默认仍是单代) JEP 519、521

两点补充:

  • JDK 7 → 8 的搬家影响:升级到 JDK 7 时,大量调用 intern() 的程序会发现堆用量变多、永久代变少;升级到 JDK 8 时,原来的 -XX:MaxPermSize 会被忽略并给出警告,需要换成 -XX:MaxMetaspaceSize。
  • 虚拟线程的栈在堆上意味着:大量虚拟线程的调用栈会体现为堆用量,而不是本地内存。JEP 444 还提到一个限制:G1 不支持「巨型」栈块,虚拟线程的栈如果达到 Region 大小的一半,可能抛 StackOverflowError。

再往后看:JDK 27 已经把紧凑对象头设为默认(JEP 534),Shenandoah 默认改为分代模式则排在 JDK 28(JEP 535,目前状态为 Targeted)。

启动流程:这些区是怎么建起来的

敲下 java -jar app.jar 之后,大致经历下面几步。

① java 启动器解析命令行,找到并加载 libjvm ② JNI_CreateJavaVM解析 -XX 参数,按机器配置选 GC、定堆大小 ③ 划内存预留并提交堆,建元空间、代码缓存,映射 CDS ④ 起线程VM 线程、GC 线程、JIT 编译线程等 ⑤ 核心类就位启动类加载器加载 java.base 并初始化 ⑥ 加载主类加载 → 链接(验证、准备、解析)→ 初始化 ⑦ 执行 main()在 main 线程上,先由解释器执行 ⑧ 分层编译热点方法 C1 → C2,机器码进代码缓存
图 3 从敲下 java 命令到 main() 跑起来

① 启动器:java 命令本身只是个小程序

java 可执行文件是个很薄的 C 程序(启动器),它做的事情是:解析命令行,区分哪些是给自己的(-jar、-cp、主类名)、哪些要转交给 JVM(-Xmx、-XX:…);找到 libjvm.so(Windows 上是 jvm.dll)并动态加载;然后另起一个新线程来创建 JVM 并运行 main,原始线程只负责等待。这样做的一个原因是能按 -Xss 控制主线程的栈大小。

② 创建 JVM:参数与自适应调整(Ergonomics)

启动器调用 JNI_CreateJavaVM 进入 HotSpot。JVM 先解析所有参数,然后做自适应调整:没指定的就按机器条件推算。最典型的两件事:

  • 选 GC:JDK 9 起默认 G1;但如果机器被判定为非「服务器级」(少于 2 个 CPU 或内存不足约 1.8GB),会选 Serial。在小规格容器里跑 Java 时,没显式指定 GC 却发现用的是 Serial,原因就在这里。
  • 定堆大小:最大堆默认取(容器)内存的 25%。

想看最终生效的值,用:

java -XX:+PrintFlagsFinal -version | grep -E 'Use.*GC |MaxHeapSize|MaxRAMPercentage'

③ 划内存

  • 堆:按 -Xmx 预留一段连续的虚拟地址空间,再按 -Xms 提交(真正向操作系统要内存)其中一部分,然后初始化所选 GC 的数据结构(G1 的 Region 表、卡表等)。「预留」和「提交」的区别很重要:top 里看到的 VIRT 很大不代表真用了那么多。
  • 元空间与压缩类空间:预留压缩类空间的地址范围。
  • 代码缓存:按 ReservedCodeCacheSize 预留,分段时切成三段。
  • CDS:把 JDK 自带的类数据共享归档(JDK 12 起默认就有,JEP 341)直接映射进内存,核心类的元数据不用再逐个解析,这是启动变快的重要原因。

④ 起线程

除了将来执行 main 的那个线程,JVM 还会创建一批内部线程:VM 线程(执行需要全局停顿的操作,比如 GC 的 STW 阶段)、GC 工作线程、JIT 编译线程(C1/C2)、信号分发、Finalizer、Reference Handler 等。用 jstack 看一个空程序也有十几个线程,就是这些。

⑤ 核心类与三层类加载器

启动类加载器(Bootstrap,C++ 实现,Java 里表现为 null)先加载 java.lang.Object、String、Class、System 等核心类并完成初始化。JDK 9 引入模块系统后(JEP 261),类加载器分三层:

加载器 负责
Bootstrap java.base 等核心模块
Platform(JDK 8 及以前叫 Extension) 其余 Java SE / JDK 平台模块
Application(System) classpath / module path 上的应用类

委派方式还是熟悉的「双亲委派」:先问上级,上级找不到才自己加载。

⑥ 加载主类:加载 → 链接 → 初始化

启动器通过应用类加载器加载主类(-jar 时从 MANIFEST 的 Main-Class 读),这一步走的就是 JVMS 第 5 章定义的类生命周期:

  1. 加载(Loading):按全限定名找到字节流(jar、目录、网络……),解析成 JVM 内部结构放进元空间,并在堆上创建对应的 java.lang.Class 对象。
  2. 链接(Linking):
    • 验证:检查字节码是否合法、类型是否安全,防止恶意或损坏的 class 文件。
    • 准备:为静态变量分配空间并设零值(int 为 0、引用为 null)。注意此时还没有执行你写的初始值。
    • 解析:把常量池里的符号引用换成直接引用。HotSpot 是懒解析——用到时才解析。
  3. 初始化(Initialization):执行编译器生成的类构造器 <clinit>,也就是静态变量赋值语句和 static {} 块,按源码顺序。JVM 保证它在多线程下只执行一次——这就是静态内部类单例线程安全的原因。

类的初始化是按需触发的:首次 new、首次访问静态字段或静态方法、反射、子类初始化时先初始化父类、作为主类启动等。仅仅加载并不一定会初始化。

⑦ 执行 main()

启动器在第 ① 步创建的那个线程上查到 public static void main(String[]) 并调用它,这个线程就是我们看到的 main 线程。一开始所有方法都由解释器执行,每个调用都在虚拟机栈上压帧。

⑧ 分层编译

JDK 8 起默认开启分层编译:方法调用次数和循环回边次数达到阈值后,先由 C1 快速编译(带 profiling),收集到足够信息后再由 C2 做激进优化。编译结果放进代码缓存,之后调用直接跑机器码。如果优化所依据的假设被推翻(比如加载了新的子类),就去优化回到解释执行。这就是 Java 程序「越跑越快」的原因,也是压测前要预热的原因。

GC:什么时候回收、怎么回收

先判断谁是垃圾:可达性分析

HotSpot 不用引用计数,而是从一组 GC Roots 出发沿引用链遍历,走不到的对象就是垃圾。主要的 GC Roots:

  • 各线程栈帧中的局部变量、操作数栈里的引用
  • 类的静态字段(随类加载器的存活而存活)
  • JNI 引用(native 代码持有的对象)
  • 被 synchronized 持有锁的对象
  • JVM 内部引用:基本类型的 Class 对象、常驻异常对象、系统类加载器等
  • 跨代收集时,「另一代」指向本代的引用(靠后面说的卡表 / 记忆集找出来)

SoftReference、WeakReference、PhantomReference 是在这个基础上的细分:软引用在内存紧张时才回收,弱引用下次 GC 就回收,虚引用只用来得到「已回收」的通知——DirectByteBuffer 背后的 Cleaner 就是基于虚引用。

三种基本算法

算法 做法 优点 代价
标记-清除 标记存活对象,清掉其余 不移动对象 产生碎片
复制 把存活对象复制到另一块空区域,原区域整体清空 无碎片,只和存活对象数量有关 要留一块空区域
标记-整理 标记后把存活对象往一端挪 无碎片、不浪费空间 移动对象成本高

为什么新生代用复制:分代假说——绝大多数对象朝生夕死。一次 Minor GC 时 Eden 里通常只有很少对象存活,复制它们的成本很低,而且复制完 Eden 整块清空,分配时只需移动指针(bump-the-pointer),非常快。老年代对象存活率高,复制不划算,所以多用标记-整理(或标记-清除)。

新生代:Minor GC 与晋升

new 对象 新生代 EdenTLAB 快速分配 S0幸存区 S1幸存区 1 2 3 大对象 5 4 老年代 Old长期存活的对象 6 吃紧 → Mixed / Full GC
图 4 对象在分代堆里的一生(编号对应正文)
  1. 分配:新对象在 Eden 分配。为了避免多线程抢同一个指针,每个线程在 Eden 里先划一小块私有缓冲区(TLAB),在里面分配只需挪指针、不用加锁。
  2. Minor GC:Eden 满了、分配失败时触发。从 GC Roots(加上老年代指向新生代的引用)出发,把 Eden 和当前 Survivor 里的存活对象复制到另一个空的 Survivor,年龄 +1,然后清空 Eden 和原 Survivor。整个过程是 STW 的,但因为只扫新生代,通常很短。
  3. 来回倒:两个 Survivor 始终有一个是空的,每次 Minor GC 交换角色。
  4. 晋升:对象年龄达到阈值后搬进老年代。阈值上限由 -XX:MaxTenuringThreshold 控制,默认 15(对象头里年龄只有 4 位,所以最大就是 15);JVM 还会动态调整——如果某个年龄及以下的对象已经占满 Survivor 的一定比例,就提前晋升。Survivor 放不下的存活对象也会直接进老年代(过早晋升)。
  5. 大对象:很大的数组或对象直接在老年代分配,避免在 Survivor 之间来回复制。在 G1 里,大小达到半个 Region 及以上的对象叫 Humongous 对象,直接分配在一组连续的 Humongous Region 里。
  6. 老年代回收:见下一节。

老年代、Mixed GC 与 Full GC

  • Parallel / Serial:老年代满了触发 Major GC(实际通常是整堆的 Full GC),用标记-整理,全程 STW。

  • G1:老年代占用达到阈值(-XX:InitiatingHeapOccupancyPercent,默认 45%,JDK 9 起还会自适应调整)时,启动并发标记;标记完成后,接下来几次 GC 是 Mixed GC——除了整个新生代,还会挑一批「垃圾最多」的老年代 Region 一起回收(这就是 Garbage-First 名字的由来),每次回收多少按停顿目标 -XX:MaxGCPauseMillis(默认 200ms)来定。

  • Full GC 是最后的兜底,常见触发原因:

    • 回收速度赶不上分配速度(G1 里叫 to-space exhausted / evacuation failure)
    • Humongous 对象找不到足够的连续 Region
    • 元空间到达阈值且普通 GC 无法释放
    • 显式调用 System.gc()(除非 -XX:+DisableExplicitGC)

    JDK 10 起 G1 的 Full GC 是多线程并行的(JEP 307),但它依然是整堆 STW,频繁出现就说明配置或代码有问题。

E O E O S O H 跨 3 个 Region E O E O E O E O S E O O E O E 伊甸区 S 幸存区 O 老年代 空闲 Region H 巨型对象(≥ 半个 Region)
图 5 G1 的堆:等大的 Region,角色随时可变,回收时按「垃圾多少」挑 Region

G1 把堆切成许多等大的 Region(大小是 2 的幂,按堆大小自动推算,也可用 -XX:G1HeapRegionSize 指定)。每个 Region 某一时刻扮演 Eden、Survivor、Old 或 Humongous 中的一种,回收后变回空闲,下次可能换个角色。好处是不用一次处理整个老年代,可以按停顿目标「挑着收」。

卡表与记忆集:只收新生代时,怎么知道老年代谁引用了它

Minor GC 只想扫新生代,但老年代里可能有对象引用新生代对象,这些引用也必须算作根,否则会误杀。扫整个老年代又太慢,于是有了:

  • 卡表(Card Table):把老年代按 512 字节切成一张张「卡」,用一个字节数组记录每张卡是否「脏」。每当程序执行引用字段赋值时,JIT 插入的写屏障把对应的卡标脏。Minor GC 时只扫脏卡即可。
  • 记忆集(Remembered Set,RSet):G1 里每个 Region 都有一个,记录「哪些其他 Region 有指向我的引用」(底层仍基于卡)。回收某个 Region 时只需看它的 RSet,不用扫全堆。代价是额外的内存和写屏障开销。

元空间什么时候回收

元空间里的类元数据只能随类卸载一起回收,而一个类能被卸载的条件很严格:加载它的类加载器本身不可达(同时该加载器加载的所有类的实例和 Class 对象都不可达)。元空间是按类加载器分块管理的,一个加载器死掉,它名下的元数据整体释放。

这意味着:

  • 由 Bootstrap / Platform / App 这三个内置加载器加载的类,基本上永远不会被卸载。
  • 会被卸载的主要是自定义加载器加载的类:应用服务器里的 Web 应用热部署、OSGi 插件、某些脚本引擎和动态代理。热部署后旧加载器被某个静态集合、线程或 ThreadLocal 引用住,就是经典的元空间泄漏。

触发时机:元空间已提交的大小达到当前高水位线(初始值即 -XX:MetaspaceSize)时,触发一次 GC 来尝试卸载类(GC 日志里原因是 Metadata GC Threshold),之后按回收效果调整水位线。JDK 16 的弹性元空间(JEP 387)之后,卸载后空出来的内存会更积极地还给操作系统。

代码缓存什么时候回收

编译好的方法(nmethod)在两种情况下会被清除:它依赖的类被卸载了,或者它被去优化后不再使用(以及长期不用的冷代码)。JDK 20 之前,有一个专门的「清扫器」(Sweeper)线程周期性清理;JDK 20 移除了 Sweeper(JDK-8290025),改为在 GC 周期中顺带卸载失效的 nmethod。所以现在代码缓存的回收节奏跟着 GC 走。

直接内存什么时候回收

直接内存只有在对应的 DirectByteBuffer 对象被 GC 回收后,才由 Cleaner 释放。问题在于:DirectByteBuffer 对象本身很小,堆压力不大时可能一直不触发 GC,结果堆外内存越积越多。所以 JDK 在 allocateDirect 时如果发现超过 MaxDirectMemorySize,会先尝试处理待清理的引用,不够再主动调用 System.gc() 并短暂重试,仍然不够才抛 OOM。

由此有一个常见坑:如果加了 -XX:+DisableExplicitGC,这条自救路径就被关掉了,使用直接内存较多的应用(比如基于 Netty 的)更容易遇到直接内存 OOM。

收集器对比

收集器 默认情况 停顿特征 适用场景
Serial 非服务器级机器的默认 单线程,全程 STW 小堆、单核容器、客户端工具
Parallel JDK 8 在服务器级机器上的默认 多线程,全程 STW,吞吐优先 批处理、离线计算,能容忍停顿
G1 JDK 9 起默认(JEP 248) 年轻代 / Mixed 回收 STW,标记并发;按停顿目标(默认 200ms)调度 大多数服务端应用的通用选择
ZGC 非默认,-XX:+UseZGC 几乎全部并发,停顿通常不到 1 毫秒,与堆大小无关 大堆、低延迟;JDK 21 起有分代模式,JDK 23 起默认分代
Shenandoah 非默认,-XX:+UseShenandoahGC 并发整理,停顿与堆大小基本无关 低延迟;JDK 25 起分代模式为正式特性

CMS 曾是低延迟的主力,JDK 9 废弃、JDK 14 移除(JEP 291、363),新项目不用再考虑。

实战:先看什么

遇到内存问题,我的建议是先确认「是哪一块」,再谈调参。下面这些命令覆盖了大部分情况。

1. 打开 GC 日志(JDK 9+ 统一日志,几乎没有性能负担,生产环境建议常开):

java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m -jar app.jar

看 GC 的原因(Allocation Failure、G1 Evacuation Pause、Metadata GC Threshold……)、频率、每次停顿时长,以及 GC 后老年代是否持续上涨。

2. 看各 GC 区的使用率,每秒一次、共 10 次:

jstat -gcutil <pid> 1000 10

输出里 S0 S1 E O M CCS 分别是两个 Survivor、Eden、老年代、元空间、压缩类空间的使用百分比,YGC/FGC 是次数,YGCT/FGCT/GCT 是累计耗时。O 在每次 Full GC 后还居高不下,基本就是内存泄漏或堆太小。

3. 看堆的整体情况:

jcmd <pid> GC.heap_info

4. 看进程整体内存都花在了哪里(堆外问题必看)。需要启动时打开本地内存跟踪:

java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary

输出按类别列出 Java Heap、Class(元空间)、Thread(线程栈)、Code(代码缓存)、GC、Internal 等的 reserved / committed。进程 RSS 远大于 -Xmx 时,就靠它找出多出来的那部分在哪。可以先 jcmd <pid> VM.native_memory baseline,过一段时间再 jcmd <pid> VM.native_memory summary.diff 看增长。注意 NMT 本身有少量开销,且直接内存不一定完整计入,要结合 BufferPoolMXBean 等方式看。

5. OOM 时自动留现场:

java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dumps -jar app.jar

生成的 .hprof 用 Eclipse MAT 或 VisualVM 打开,看支配树(Dominator Tree)里谁占得最多、它的 GC Root 引用链是什么。

6. 按报错快速定位:

看到的错误 先查什么
Java heap space 堆 dump 看大对象 / 泄漏;GC 日志看老年代趋势
GC overhead limit exceeded 同上,通常是堆基本满了
Metaspace / Compressed class space jcmd <pid> VM.classloader_stats、类加载数是否一直涨
unable to create native thread… 线程数(jstack)、ulimit -u、容器的 pids 限制
… direct buffer memory MaxDirectMemorySize、是否禁用了显式 GC、ByteBuf 是否泄漏
StackOverflowError 栈顶的重复调用,基本是递归
CodeCache is full 警告 调大 ReservedCodeCacheSize,查是否动态生成了大量类

小结

  • 线程私有的三块(PC、虚拟机栈、本地方法栈)随线程生灭,方法返回即释放,不归 GC 管。
  • 堆是 GC 的主战场;JDK 7 起字符串常量池和静态变量也在堆里。
  • 方法区是规范概念:JDK 7 及以前是永久代,JDK 8 起是本地内存里的元空间,只有类加载器死了才回收。
  • 代码缓存满了不报 OOM 而是停 JIT,直接内存靠 GC 带动 Cleaner 释放,这两块最容易被忽略。
  • 版本越新,默认值越好:G1 默认、CDS 默认、元空间弹性归还、ZGC 分代……多数情况下先升级 JDK、打开 GC 日志,比手调一堆参数更有效。