ElIauk

JVM 内存结构与垃圾回收入门

运行时内存怎么分 JVM 的内存可以按“线程私有”和“线程共享”来记。线程私有的部分包括程序计数器、虚拟机栈和本地方法栈;线程共享的部分主要是堆和方法区,JDK 8 之后方法区的实现叫 Metaspace,放在本地内存里,不再受 -Xmx 限制。除此之外还有直接内存,Netty、NIO 用的堆外缓冲

评论
JVM 内存结构与垃圾回收入门

运行时内存怎么分

JVM 的内存可以按“线程私有”和“线程共享”来记。线程私有的部分包括程序计数器、虚拟机栈和本地方法栈;线程共享的部分主要是堆和方法区,JDK 8 之后方法区的实现叫 Metaspace,放在本地内存里,不再受 -Xmx 限制。除此之外还有直接内存,Netty、NIO 用的堆外缓冲区就分配在这里,它同样不计入堆,但会把物理内存吃满。

堆又分新生代和老年代。新生代通常由 Eden 和两个 Survivor 组成,比例默认 8:1:1。对象优先在 Eden 分配,每个线程还有一块 TLAB,避免多线程分配时的指针竞争。Minor GC 时,存活对象从 Eden 复制到一个 Survivor,年龄加一;年龄达到阈值(默认 15)或者 Survivor 放不下,就晋升到老年代。

常用的启动参数:

java -Xms2g -Xmx2g \
     -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m \
     -XX:MaxDirectMemorySize=512m \
     -Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=50m \
     -jar order-service.jar

-Xms 和 -Xmx 设成一样,可以避免堆反复扩缩容带来的抖动。容器环境更推荐 -XX:MaxRAMPercentage=75,让 JVM 按容器内存上限计算堆大小,而不是写死一个宿主机视角的数字。G1 下新生代大小交给自适应即可,除非有明确压测数据,不要用 -Xmn 把它写死。

对象从 Eden 到老年代

新生代回收叫 Minor GC 或 Young GC,停顿时间短,回收频率高。老年代回收叫 Major GC 或 Full GC,通常伴随更长的停顿,G1 下会先做 Mixed GC,逐步回收老年代区域。JDK 17 默认垃圾回收器是 G1,它把堆划分成多个 Region,按回收收益优先处理,目标是把停顿控制在 -XX:MaxGCPauseMillis 指定的范围内。

G1 里有一个容易忽略的概念:Humongous 对象。单个对象超过 Region 大小的一半,会被直接放进连续的大对象区域,频繁创建大数组会加速老年代碎片和 Full GC。日志里看到 Humongous 字样,先检查是不是一次查询捞了太多数据、或者拼接了超大字符串。

对象真正“死亡”要经过可达性分析,从 GC Roots 出发找不到引用才会被回收。GC Roots 包括栈上的局部变量、静态变量、常量、JNI 引用等。很多人以为把变量置为 null 就能立刻回收,实际上只要方法还没退出、栈帧还在,引用就仍然可达。

OOM 不止一种

错误 含义 常见原因
Java heap space 堆内存不足 大对象、缓存无上限、内存泄漏
Metaspace 元空间不足 动态生成类、热部署、类加载器泄漏
Direct buffer memory 堆外内存不足 Netty 缓冲区未释放、NIO 使用不当
unable to create native thread 本地线程创建失败 线程数过多、-Xss 过大、系统限制
GC overhead limit exceeded GC 占用过高 堆几乎满,回收效率极低

排查堆问题,先让 JVM 在 OOM 时自动 dump:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump

再用 jps、jstat、jcmd 做在线观察:

jps -l
jstat -gcutil 12345 1000 10
jcmd 12345 GC.heap_info
jmap -histo:live 12345 | head -n 20

jstat 的 O 列是老年代使用率,FGC 是 Full GC 次数,FGCT 是累计停顿时间。老年代使用率持续上涨、Full GC 后也降不下来,基本可以判定为内存泄漏,而不是“堆不够大”。jmap -histo:live 会触发一次 Full GC,生产环境慎用,优先用 jcmd 或已 dump 的堆文件分析。

常见坑

1. 堆大小超过容器限制

容器限制 2GB,-Xmx 却设了 2GB,堆之外还有 Metaspace、线程栈、直接内存和 JVM 自身开销,进程很快被 OOMKilled。堆应该只占容器内存的 60% 到 75%,剩下的留给非堆区域。

2. 只看堆,不看堆外

堆内存很平稳,进程 RSS 却一直涨,通常是直接内存或 Metaspace 泄漏。Netty 可以打开泄漏检测 -Dio.netty.leakDetection.level=paranoid,Metaspace 用 jcmd VM.metaspace 观察类加载数量。

3. GC 日志没有滚动

只配了 -Xlog:gc*:file=gc.log,没有 filecount 和 filesize,日志会把磁盘写满。生产环境一定要配滚动和保留数量,并把日志目录纳入磁盘监控。

4. 线程数失控

每个线程默认栈大小 1MB,几千个线程就是几 GB 的虚拟内存。unable to create native thread 不一定是内存不够,也可能是 ulimit -u 或 cgroup 的线程数限制。先看线程数,再谈调 -Xss。

5. 用 System.gc() 碰运气

显式调用 System.gc() 只是建议 JVM 回收,生产环境还可能被 -XX:+DisableExplicitGC 忽略。它不能解决泄漏,只会让停顿更不可控。

排查步骤

先定位是堆、元空间还是线程问题:

jcmd 12345 VM.native_memory summary
jcmd 12345 VM.metaspace
jcmd 12345 Thread.print | grep -c '"'

VM.native_memory 需要启动时加 -XX:NativeMemoryTracking=summary,没加就只能看操作系统层面的 RSS。确认是堆问题后,用 MAT 或 VisualVM 打开 heap dump,按支配树(Dominator Tree)找占用最大的对象,再顺着 GC Roots 的引用链看谁在持有它。不要一上来就调大 -Xmx,那只是把 OOM 推迟。

小结

JVM 内存排查的主线是:先分清堆内还是堆外,再看 GC 日志判断是“真的不够”还是“回收不动”,最后用 dump 找持有链。参数调优是最后一步,大多数线上 OOM 的根因在代码里的缓存、集合或连接没有上限。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者