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内存。
虚拟机栈(VM Stack)—— 线程的私家花园
每个线程都有自己的栈,用来存栈帧。每个方法调用就是一个栈帧入栈,方法结束就出栈。栈帧里存了局部变量表、操作数栈、动态链接、方法出口这些东西。
我记得有一次排查栈溢出,就是一个递归调用没写终止条件,结果栈帧一层层叠上去,最后抛出StackOverflowError。线程栈大小可以通过-Xss调整,但别调太大,否则线程数一多,总内存扛不住。
方法区(Method Area / Metaspace)
这里存的是类信息、常量、静态变量、JIT编译后的代码等。JDK8之前叫永久代,之后改成了元空间。为什么要改?因为在永久代时代,GC很难回收类的元数据,经常出现PermGen OOM。我有个项目用Groovy动态编译脚本,每次编译都会生成新类,结果元空间一路涨,最后直接撑爆。
-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抖动经常就发生在这条路的几个关键节点上。
- 栈上分配:如果对象没有逃逸,JVM可能直接把它分配到栈上。方法结束,栈帧弹出,对象自动销毁,连GC都不用触发。这个优化叫逃逸分析,但实际效果看JVM实现。
- TLAB分配(Thread Local Allocation Buffer):每个线程在堆的Eden区里有一块私有缓冲区,叫TLAB。线程在TLAB上分配对象时不需要锁,效率极高。我一般建议把TLAB大小调成默认,除非你发现分配压力特别大才动它。
- Eden区分配:如果TLAB装不下新对象,就去Eden区公共区域分配,这时候就需要加锁了。
- 直接进入老年代:如果对象大于
-XX:PretenureSizeThreshold,直接分配到老年代,避免大对象在新生代折腾。 - 晋升老年代:对象在Survivor区熬过一定次数的GC后(默认15次),晋升到老年代。
| 内存区域 | 存储内容 | 是否受GC管理 | 异常类型 |
|---|---|---|---|
| 堆 | 对象实例、数组 | 是 | OutOfMemoryError: Java heap space |
| 虚拟机栈 | 局部变量、操作数栈、动态链接 | 否 | StackOverflowError / OOM |
| 方法区/元空间 | 类信息、常量、静态变量 | 是(有限制) | OutOfMemoryError: Metaspace |
| 直接内存 | NIO缓冲区、本地数据 | 否 | OutOfMemoryError (OS级别) |
| TLAB | 线程私有对象缓冲区(在Eden内) | 是(随Eden一起) | 无独立异常 |
TLAB实战细节
TLAB虽然叫“线程本地”,但它其实是在堆的Eden区里划出来的一块连续空间。每个线程都有自己的一份副本,分配对象时直接在这个区域里“划一块”,不需要CAS或者锁。为什么需要它?因为高并发下,大家都在Eden里抢位置,锁竞争会成为瓶颈。
我曾经在一个后台任务调度系统里发现,几十个线程同时创建临时对象,Eden区的分配效率极低,使用TLAB后直接提升了30%的吞吐量。调TLAB大小可以用这两个参数:
-XX:+UseTLAB # 启用TLAB(默认开启)
-XX:TLABSize=512k # 设置每个TLAB的大小
有个细节要注意:TLAB满了之后,线程会尝试重新申请一个新的TLAB,但如果Eden区剩余的连续空间不够,JVM会尝试在Eden公共区域加锁分配。这个时候,TLAB的“浪费”问题就出现了——每个TLAB在生命周期结束时,可能只剩一点点碎片空间,加起来就不少了。
-XX:+PrintTLAB打印日志,看看每个线程的TLAB浪费了多少空间。有时调大TLABSize反而减少浪费。
这张图覆盖了整个内存模型,以及对象分配的三条路径。你从左下角开始看:对象先尝试栈上分配,不行就轮到TLAB(无锁),TLAB满了去Eden公共区(加锁),碰上大对象直接发配老年代。在新生代里寿终正寝的,顺着箭头去老年代。右边是方法区、直接内存和虚拟机栈,它们虽然不参与对象分配的主流程,但每个都和内存泄漏或GC抖动有着千丝万缕的关系。