JVM内存模型:堆、栈、方法区、直接内存

要理解GC抖动,得先把JVM的内存地图刻在脑子里。我刚开始学JVM时觉得这东西虚,直到线上一次Full GC把服务拖成“半死不活”,才开始真正在意这些区域长什么样、怎么协作。其实说白了,JVM的内存模型就是Java程序运行时的“土地规划” —— 你代码里每个变量、每个对象、每个类信息,都得在指定的地块上安家。

堆(Heap)—— 对象的主战场

堆是JVM里最大的一块内存,也是GC最忙的地方。几乎所有的Java对象实例都在这里分配。

  • 新生代(Young Generation):朝生夕死的对象住这里。我习惯把新生代再切成三块 —— Eden、From Survivor、To Survivor。大部分对象刚出生就扔在Eden,一次Minor GC后还活着的,挪到Survivor区。
  • 老年代(Old Generation):熬过多次GC还活着的“老油条”。大对象也可能直接进老年代,比如那种超长的byte数组。
  • 元空间(Metaspace,JDK8+):存放类的元数据,不再用永久代了。我踩过一个坑,元空间没设上限,结果动态生成类太多直接撑爆了OS内存。
调优时我一般先看堆的总大小,再调新生代和老年代的比例。如果是后台任务型应用,对象存活时间短,新生代可以适当放大。默认的1:2比例不一定适合你。

虚拟机栈(VM Stack)—— 线程的私家花园

每个线程都有自己的栈,用来存栈帧。每个方法调用就是一个栈帧入栈,方法结束就出栈。栈帧里存了局部变量表、操作数栈、动态链接、方法出口这些东西。

我记得有一次排查栈溢出,就是一个递归调用没写终止条件,结果栈帧一层层叠上去,最后抛出StackOverflowError。线程栈大小可以通过-Xss调整,但别调太大,否则线程数一多,总内存扛不住。

方法区(Method Area / Metaspace)

这里存的是类信息、常量、静态变量、JIT编译后的代码等。JDK8之前叫永久代,之后改成了元空间。为什么要改?因为在永久代时代,GC很难回收类的元数据,经常出现PermGen OOM。我有个项目用Groovy动态编译脚本,每次编译都会生成新类,结果元空间一路涨,最后直接撑爆。

如果你的应用频繁生成动态代理、CGLIB、或者用Groovy/Python这种脚本语言,一定要设置-XX:MaxMetaspaceSize,不然元空间可以撑到物理内存上限。

直接内存(Direct Memory)

直接内存不是JVM运行时数据区的一部分,但NIO里频繁用到。它通过DirectByteBuffer分配,绕过了堆,直接在OS层面分配内存。好处是减少了GC压力和堆内外的数据拷贝,但坏处是——你没法用-Xmx约束它。我见过一个案例,堆只设了2GB,直接内存却吃了8GB,导致系统OOM Killer直接杀了进程。

// 直接内存分配示例
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 100); // 100MB直接内存

用直接内存时,记得通过-XX:MaxDirectMemorySize限制它的大小。默认它和堆的最大值一样大,如果你不设,它可能偷偷吃掉大量内存。

对象分配流程与TLAB

一个对象从创建到“安家”,要经过一条明确的路径。我带你看一遍流程,你想想看,GC抖动经常就发生在这条路的几个关键节点上。

  1. 栈上分配:如果对象没有逃逸,JVM可能直接把它分配到栈上。方法结束,栈帧弹出,对象自动销毁,连GC都不用触发。这个优化叫逃逸分析,但实际效果看JVM实现。
  2. TLAB分配(Thread Local Allocation Buffer):每个线程在堆的Eden区里有一块私有缓冲区,叫TLAB。线程在TLAB上分配对象时不需要锁,效率极高。我一般建议把TLAB大小调成默认,除非你发现分配压力特别大才动它。
  3. Eden区分配:如果TLAB装不下新对象,就去Eden区公共区域分配,这时候就需要加锁了。
  4. 直接进入老年代:如果对象大于-XX:PretenureSizeThreshold,直接分配到老年代,避免大对象在新生代折腾。
  5. 晋升老年代:对象在Survivor区熬过一定次数的GC后(默认15次),晋升到老年代。
内存区域存储内容是否受GC管理异常类型
堆对象实例、数组是OutOfMemoryError: Java heap space
虚拟机栈局部变量、操作数栈、动态链接否StackOverflowError / OOM
方法区/元空间类信息、常量、静态变量是(有限制)OutOfMemoryError: Metaspace
直接内存NIO缓冲区、本地数据否OutOfMemoryError (OS级别)
TLAB线程私有对象缓冲区(在Eden内)是(随Eden一起)无独立异常
核心思路:对象分配的路径决定了GC的触发频率。TLAB不足 → Eden快速占满 → Minor GC频率上升 → 晋升老年代速度加快 → Full GC增多 → 抖动出现。你品品,是不是这个链条?

TLAB实战细节

TLAB虽然叫“线程本地”,但它其实是在堆的Eden区里划出来的一块连续空间。每个线程都有自己的一份副本,分配对象时直接在这个区域里“划一块”,不需要CAS或者锁。为什么需要它?因为高并发下,大家都在Eden里抢位置,锁竞争会成为瓶颈。

我曾经在一个后台任务调度系统里发现,几十个线程同时创建临时对象,Eden区的分配效率极低,使用TLAB后直接提升了30%的吞吐量。调TLAB大小可以用这两个参数:

-XX:+UseTLAB              # 启用TLAB(默认开启)
-XX:TLABSize=512k         # 设置每个TLAB的大小

有个细节要注意:TLAB满了之后,线程会尝试重新申请一个新的TLAB,但如果Eden区剩余的连续空间不够,JVM会尝试在Eden公共区域加锁分配。这个时候,TLAB的“浪费”问题就出现了——每个TLAB在生命周期结束时,可能只剩一点点碎片空间,加起来就不少了。

如果你发现Eden区使用率很高但存活对象很少,可以检查一下TLAB浪费率。通过-XX:+PrintTLAB打印日志,看看每个线程的TLAB浪费了多少空间。有时调大TLABSize反而减少浪费。
JVM内存模型与对象分配流程图 堆 (Heap) 新生代 (Young Generation) Eden区 TLAB (线程私有) TLAB 2 S0 From S1 (To) 老年代 (Old Generation) 大对象 (PretenureSizeThreshold) 晋升对象 (Age阈值) 方法区 / 元空间 (Metaspace) 类信息 · 常量 · 静态变量 · JIT代码 直接内存 (Direct Memory) DirectByteBuffer · NIO缓冲区 虚拟机栈 (VM Stack) 线程私有 · 栈帧 (Frame) 局部变量表 · 操作数栈 动态链接 · 方法出口 TLAB分配 (无锁) 公共分配 (加锁) 晋升 (Age ≥ 15) 大对象直入 栈上分配(逃逸分析)优先 GC抖动:Minor GC → Full GC TLAB浪费率影响分配效率

这张图覆盖了整个内存模型,以及对象分配的三条路径。你从左下角开始看:对象先尝试栈上分配,不行就轮到TLAB(无锁),TLAB满了去Eden公共区(加锁),碰上大对象直接发配老年代。在新生代里寿终正寝的,顺着箭头去老年代。右边是方法区、直接内存和虚拟机栈,它们虽然不参与对象分配的主流程,但每个都和内存泄漏或GC抖动有着千丝万缕的关系。