项目上线没几天,突然收到报警,服务挂了。登录服务器一看,日志里清清楚楚写着:java.lang.OutOfMemoryError: Java heap space。这熟悉的错误,八成是 JVM 内存溢出了。别慌,这种情况在开发和运维中太常见了,关键是怎么快速定位、及时处理。
先看现象,分清类型
JVM 内存溢出不是单一问题,不同表现对应不同原因。常见的几种 OutOfMemoryError 得认得:
堆内存溢出:最常见的一种,报错通常是 java.lang.OutOfMemoryError: Java heap space。说明对象太多,GC 回收不掉,堆撑满了。
元空间溢出:Java 8 之后永久代被元空间替代,报错是 java.lang.OutOfMemoryError: Metaspace。一般是动态生成类太多,比如大量使用 CGLIB、反射或者 ASM。
栈溢出:报错 java.lang.StackOverflowError,通常是因为递归调用太深,线程栈不够用了。
直接内存溢出:报错可能带 Direct buffer memory,NIO 使用不当容易触发,尤其是 Netty 这类框架。
加参数不能治本,但能应急
线上服务出问题,第一反应是重启,顺便把堆内存调大点。比如启动时加上:
-Xms2g -Xmx2g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof
这招能缓解一时压力,但要是代码本身有问题,迟早还会爆。而且盲目加大堆内存,GC 停顿时间也会变长,用户体验更差。
抓 dump 文件,用工具分析
有了 -XX:+HeapDumpOnOutOfMemoryError,程序崩溃时会自动生成堆转储文件。拿到这个 .hprof 文件,本地用 VisualVM 或者 Eclipse MAT 打开,一眼就能看出哪些对象占了最多内存。
比如有一次查问题,发现 HashMap<String, String> 实例有上百万个,每个都存了几百 KB 数据。一查代码,原来是某个缓存没设上限,请求参数被误当成 key 不断写入,越积越多。加上 LRU 缓存限制后,问题消失。
代码里常见的坑
静态集合类滥用是最容易踩的雷。比如定义一个 public static List<Object> cache = new ArrayList<>();,往里面不断 add,却不清理,等于给内存埋了个定时炸弹。
还有字符串拼接也得小心。老代码里常见用 + 拼接大量字符串,每次都会生成新对象。改成 StringBuilder 或 StringBuffer 更稳妥。
另外,数据库查询不分页也是常见诱因。一次查十万条记录加载进内存,哪怕每条只占 1KB,也得吃掉近百 MB 堆空间。
监控不能少,预防胜于治疗
平时可以在服务里集成 Prometheus + Grafana,把 JVM 内存、GC 次数、线程数这些指标监控起来。发现老年代使用率持续上升,Full GC 频繁,就得警惕了。
加上 JVM 参数 -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps,把 GC 日志输出到文件,配合工具如 GCViewer 分析,能提前发现问题苗头。
小改动,大效果
有个小项目之前总在晚上高峰期 OOM,后来发现是日志级别设成了 DEBUG,大量临时字符串涌入堆内存。改成 INFO 后,内存占用直接降了一半。有时候问题不在多复杂,就在细节里。
还有一种情况是资源没关。比如 FileInputStream、Connection 用了没 close,虽然最终会被回收,但时机不可控。最好用 try-with-resources,让 JVM 自动处理。