JVM内存模型与关键参数

聊内存溢出,咱们得先搞清楚「内存到底分几块」。这就像修水管,你得知道哪一段堵了,才能对症下药。JVM的内存分区,其实就那几大块:堆、栈、方法区、直接内存。我把它们捋一遍,顺便说说我平时调参的坑。

堆(Heap)—— 大部分对象的“家”

堆是JVM里最大的一块内存,所有new出来的对象都搁这儿。年轻代、老年代,说白了就是堆里的不同区域。年轻代再拆成Eden和两个Survivor区,目的就是提高GC效率。

我在项目里见过一个典型场景:一个定时任务每次生成大量临时对象,但没及时释放,结果年轻代一直Full GC,CPU飙到100%。你想想,对象不断进入老年代,老年代一满,直接OutOfMemoryError。

我曾经过的坑: 一个同事把-Xms和-Xmx设成一样大,以为能避免动态扩容的开销。结果堆本身就4G,系统物理内存才8G,其他进程一跑,直接OOM Killer把JVM杀了。预留一些余量真的很重要。

栈(Stack)—— 每个线程的私房钱

栈存的是局部变量、方法调用帧。每个线程有自己独立的栈,大小可以用-Xss调。默认在Linux上是1MB,Windows上通常512KB。如果你的线程数特别多(比如几千个),栈空间别设太大,否则光栈就吃掉几个G内存。

我调过一个WebSocket服务,连接数一万多,每个线程都开默认1MB栈,结果还没跑业务内存就爆了。后来改成256KB,系统稳如狗。

小经验: 如果遇到java.lang.StackOverflowError,先检查递归深度,再考虑是不是栈帧太大。我之前有个正则表达式嵌套递归,直接让栈溢出,改成迭代就解决了。

方法区(Metaspace / 永久代)—— 类元数据的归宿

方法区存的是类信息、常量、静态变量、JIT编译后的代码。JDK8以后方法区改名叫“元空间”(Metaspace),从堆里挪到了本地内存。默认无上限,但最好用-XX:MaxMetaspaceSize限制一下,不然你动态生成类(比如CGLIB代理)能撑爆物理内存。

我记得有次服务上线后,跑了两天内存缓慢增长,最后OOM。一看日志,是某个框架不停生成新的ClassLoader,导致Metaspace无限膨胀。嗯,加了个-XX:MaxMetaspaceSize=256m就控制了。

直接内存(Direct Memory)—— 堆外的不速之客

直接内存是NIO用的,通过ByteBuffer.allocateDirect()分配。它不在堆里,但也会占用物理内存。很多人只调堆参数,忽略了直接内存。一旦分配过多,同样会OutOfMemoryError:Direct buffer memory。

我在一个大文件传输模块里见过:每读一个文件片,都new一个DirectBuffer,用完又不释放。结果直接内存碎片化,系统卡死。后来用-XX:MaxDirectMemorySize=512m限制,配合池化,问题解决。

一张图理清整体结构

JVM内存分区概览 JVM运行时内存分区 堆 (Heap) 新生代 (Eden + S0/S1) 老年代 (Tenured) -Xms / -Xmx 控制 方法区 (Metaspace) 类信息、常量、JIT代码 -XX:MaxMetaspaceSize 栈 (Stack) 线程私有,方法帧、局部变量 -Xss 控制单线程栈大小 直接内存 (Direct Memory) -XX:MaxDirectMemorySize 限制,堆外NIO缓冲区 GC根? 💡 实际调参时,堆与直接内存、Metaspace都需要设定上限,缺一不可

关键参数速查表

参数 作用域 说明 常见值
-Xms 堆 初始堆大小,启动时分配 512m, 2g
-Xmx 堆 最大堆大小,过大会触发OS swap 2g, 4g
-Xss 栈 每个线程的栈容量 256k, 512k
-XX:MetaspaceSize 方法区 元空间初始大小,达到后触发Full GC 128m, 256m
-XX:MaxMetaspaceSize 方法区 元空间上限,超限报OOM 256m, 512m
-XX:MaxDirectMemorySize 直接内存 最大直接内存,默认等于-Xmx 512m, 1g
-XX:NewRatio 堆内 老年代/年轻代比例 2, 3

我的个人习惯: 线上服务我会统一设置 -Xms=-Xmx,避免动态扩容引起停顿。同时单独配 -XX:MaxMetaspaceSize=256m 和 -XX:MaxDirectMemorySize=512m,让每一个区域都有明确天花板。如果发现某个区域一直接近上限,那就要怀疑代码泄露了。

一段通用的JVM启动参数示例

java -Xms2g -Xmx2g \
     -Xss512k \
     -XX:MetaspaceSize=128m \
     -XX:MaxMetaspaceSize=256m \
     -XX:MaxDirectMemorySize=512m \
     -XX:+UseG1GC \
     -XX:NewRatio=2 \
     -jar myapp.jar

你看,-XX:+UseG1GC 是我现在最常用的GC,大堆低延迟。但这不是本章重点,后面几章会细讲GC调优。这里就记住一句话:参数不是越多越好,每个参数背后的内存区域你心里要有数。

避坑指南: 我曾经在生产环境上忘记设置-XX:MaxMetaspaceSize,结果一个代码生成器动态创建了几万个类,Metaspace飙到1.5G,直接导致NodeManager把进程杀了。从此以后,MaxMetaspaceSize 是我启动参数的标配。

小结一下:堆管着对象,栈管着线程,方法区管着类,直接内存管着NIO。调参的时候,别只盯着-Xmx,看看其他区域有没有被忽视。我见过很多OOM,其实是Metaspace或直接内存撑爆了,堆反而空得很。好,掌握这些分区,咱们后面才好深入排查内存泄漏。