运行时内存怎么分
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 的根因在代码里的缓存、集合或连接没有上限。
评论