JVM 内存模型与 GC 工作区:你到底该关注哪块地盘?
很多人学 GC,上来就记一堆算法、参数。但我觉得,第一步应该先搞清楚——GC 到底在哪块区域干活?你连地图都没看明白,怎么优化?
JVM 把运行时内存划分成五个区域:堆、方法区、程序计数器、虚拟机栈、本地方法栈。但 GC 只盯着一块半区域猛干——堆是主战场,方法区偶尔也参与。其他区域?基本不关 GC 的事。
这章我们就来把这五块地的边界、用途、GC 参与度彻底捋一遍。我会结合我自己的踩坑经历,告诉你到底哪里容易出问题。
1. 堆(Heap)—— GC 的主战场,没有之一
堆是 Java 对象的老巢。new 出来的对象,数组,全堆在这里。GC 的绝大多数工作就是在这块区域里来回扫荡。
- 线程共享,所有线程都能访问
- GC 主战场(Young Gen → Old Gen → 长期存活)
- 大小由
-Xms/-Xmx控制 - 内部又分为新生代(Eden、S0、S1)和老年代
我记得有一次做个微服务启动优化,发现 Full GC 特别频繁。仔细一看,老年代涨得飞快——原来是某个缓存对象一直在堆里赖着不走。嗯,堆的分配和回收策略,直接影响启动速度。
2. 方法区(Method Area)—— 元数据仓库,GC 的「编外人员」
方法区存的是类信息、常量、静态变量、JIT 编译后的代码。在 JDK 8 之后,它被元空间(Metaspace)取代,不再使用堆内存,而是直接使用本地内存。
GC 在方法区也会干活,但频率低很多。主要是卸载类和清理常量池中的废弃常量。不过说实话,我很少在项目里看到方法区因为 GC 出问题的,除非你特别折腾自定义类加载器。
-XX:MetaspaceSize=256m。
3. 程序计数器(Program Counter Register)—— 小透明,GC 完全不管
程序计数器是线程私有的,记录当前线程执行到哪条字节码指令。它非常小,几乎不消耗内存,而且 GC 压根不看它。你想想,它就是个指向下一条指令的指针,不存在内存回收的问题。
4. 虚拟机栈(JVM Stack)—— 存储栈帧,GC 不直接动它
每个线程都有自己的虚拟机栈,里面装着一堆栈帧(Stack Frame)。每个方法调用都会压入一个栈帧,方法结束就弹出。局部变量表、操作数栈、动态链接、方法出口都在这里面。
GC 不会直接回收栈内存。为什么?因为栈帧随着方法调用自然弹出,是自动管理的。但注意一点:栈里的引用变量指向堆里的对象,如果栈帧被弹出,堆对象就没有强引用了——这时候 GC 才会把它干掉。所以栈只是间接配合了 GC。
5. 本地方法栈(Native Method Stack)—— 与 GC 几乎绝缘
本地方法栈跟虚拟机栈结构类似,区别是它为 native 方法服务。平时我们写的 Java 代码很少直接调 native,除非你用 JNI。这块区域 GC 基本不管。
GC 到底工作在哪些区域?一张表看清
| 内存区域 | 线程共享 | GC 作用 | 典型异常 | 重点优化方向 |
|---|---|---|---|---|
| 堆 | 是 | 主要区域 | OutOfMemoryError | 对象分配、GC 停顿 |
| 方法区(元空间) | 是 | 次要区域 | OutOfMemoryError | 类卸载、常量池回收 |
| 程序计数器 | 否 | 无 | 无(唯一没有 OOM 的区域) | 无 |
| 虚拟机栈 | 否 | 间接影响 | StackOverflowError | 栈深度、局部变量引用 |
| 本地方法栈 | 否 | 无 | StackOverflowError | 极少关注 |
SVG 结构图:JVM 运行时内存布局与 GC 工作区
实际案例:启动优化时我踩的一个坑
我记得有个启动优化项目,应用启动要加载一堆 Spring Bean 和缓存。我上来就调 -Xms 和 -Xmx,但 Full GC 还是频繁触发。后来用 -XX:+PrintGCDetails 一查,发现老年代里有一堆类元数据——是我启动时加载的第三方库反射生成的类。
这就是方法区(元空间)在捣乱。虽然堆没满,但元空间满了也会触发 Full GC。我这才明白,GC 的工作区不只是堆,方法区也得照顾到。
jstat -gc <pid> 看 MC 和 MU 两列,说不定能发现问题。
最后总结一下——GC 的核心工作区就是堆和方法区。程序计数器、虚拟机栈、本地方法栈都是线程私有的自动管理地带,GC 基本不碰。你优化启动速度,重点关注堆的分代配置和元空间大小就对了。其他的,偶尔看看栈深度别溢出就行。