JVM 内存到底分几块:各区职责、版本演进、启动流程与回收机制
先说清楚题目:本文讲的是 HotSpot 虚拟机的运行时数据区,也就是 JVM 进程里的内存是怎么划分、谁放什么、什么时候回收。它常被叫作「JVM 内存模型」,但严格说那个名字属于另一件事——Java 内存模型(JMM,JLS 第 17 章),讲的是多线程下的 happens-before、可见性与重排序,和内存怎么分区无关,本文不涉及。
下面按这个顺序走:先看全景图,再逐个区讲职责、参数、溢出报什么错、怎么回收;然后讲从 JDK 6 到 JDK 25 这些区是怎么搬家的;接着讲 java -jar 敲下去之后 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 |
各版本怎么演变
这一节只讲和内存布局直接相关的变化。
| 版本 | 变化 | 出处 |
|---|---|---|
| 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 命令本身只是个小程序
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 章定义的类生命周期:
- 加载(Loading):按全限定名找到字节流(jar、目录、网络……),解析成 JVM 内部结构放进元空间,并在堆上创建对应的
java.lang.Class对象。 - 链接(Linking):
- 验证:检查字节码是否合法、类型是否安全,防止恶意或损坏的 class 文件。
- 准备:为静态变量分配空间并设零值(
int为 0、引用为null)。注意此时还没有执行你写的初始值。 - 解析:把常量池里的符号引用换成直接引用。HotSpot 是懒解析——用到时才解析。
- 初始化(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 与晋升
- 分配:新对象在 Eden 分配。为了避免多线程抢同一个指针,每个线程在 Eden 里先划一小块私有缓冲区(TLAB),在里面分配只需挪指针、不用加锁。
- Minor GC:Eden 满了、分配失败时触发。从 GC Roots(加上老年代指向新生代的引用)出发,把 Eden 和当前 Survivor 里的存活对象复制到另一个空的 Survivor,年龄 +1,然后清空 Eden 和原 Survivor。整个过程是 STW 的,但因为只扫新生代,通常很短。
- 来回倒:两个 Survivor 始终有一个是空的,每次 Minor GC 交换角色。
- 晋升:对象年龄达到阈值后搬进老年代。阈值上限由
-XX:MaxTenuringThreshold控制,默认 15(对象头里年龄只有 4 位,所以最大就是 15);JVM 还会动态调整——如果某个年龄及以下的对象已经占满 Survivor 的一定比例,就提前晋升。Survivor 放不下的存活对象也会直接进老年代(过早晋升)。 - 大对象:很大的数组或对象直接在老年代分配,避免在 Survivor 之间来回复制。在 G1 里,大小达到半个 Region 及以上的对象叫 Humongous 对象,直接分配在一组连续的 Humongous Region 里。
- 老年代回收:见下一节。
老年代、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,频繁出现就说明配置或代码有问题。
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 日志,比手调一堆参数更有效。