Spring项目堆内存调优实战经验分享
最近接手了一个老Spring项目,上线没几天就频繁报OOM(OutOfMemoryError),服务直接挂掉。运维同事一查日志,发现是堆内存溢出。这种情况在高并发场景下特别常见,尤其是Spring这类依赖注入和Bean管理复杂的框架。问题来了,怎么调?不能光靠加机器内存撑着。
先看现象:堆内存不断上涨,GC频繁但回收效果差
用jstat -gc命令盯着生产环境跑了一阵,发现老年代(Old Gen)使用率一路飙升,Full GC执行完回收的内存寥寥无几,典型的内存泄漏或配置不合理。这时候第一反应不是改参数,而是确认当前JVM堆设置。
查看启动脚本,发现只加了-Xms512m -Xmx512m,最大堆才512M。而这个项目里缓存了不少静态数据,还用了大量第三方库,光Spring Boot自动装配的Bean就有几百个。这点内存根本不够分。
合理设置堆内存大小
根据服务器物理内存情况,调整为初始和最大堆均为2G:
-Xms2g -Xmx2g别一上来就设太大,容易导致GC停顿时间变长。2G是个折中值,兼顾吞吐量和响应时间。如果机器有8G以上内存,可以再往上提,但建议新生代和老年代比例也要跟着调。
调整新生代比例,优化GC效率
默认情况下,新生代占整个堆的1/3左右。但在我们这个项目中,对象大多是短生命周期的请求对象,应该让更多对象在新生代就被回收。
于是加上参数:
-Xmn1g -XX:+UseG1GC -XX:MaxGCPauseMillis=200这里用了G1垃圾收集器,适合大堆内存场景。-Xmn1g明确指定新生代为1G,提高年轻对象的分配空间。MaxGCPauseMillis设置目标暂停时间,让GC更“温柔”一些,避免卡顿影响接口响应。
排查内存泄漏:谁在偷偷吃掉堆内存?
调完参数后观察几天,发现老年代还是涨得快。这时候就得怀疑是不是代码有问题。用jmap生成堆转储文件:
jmap -dump:format=b,file=heap.hprof <pid>然后用Eclipse MAT工具打开分析。结果发现一个Service类里有个static Map,用来缓存用户信息,但没有设置过期机制,也没有容量限制。随着时间推移,越积越多,最终拖垮了整个应用。
改成使用Guava Cache,并加上大小限制和过期策略:
Cache<String, Object> userCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build();这样一来,缓存可控了,内存增长明显放缓。
Spring特有的内存开销点要注意
Spring框架本身也会占用不少内存。比如:
- Bean定义过多,特别是开启@ComponentScan扫描范围太广时;
- 使用@EnableCaching、@Async等注解开启的功能,底层会创建代理对象,增加额外开销;
- 启用了调试日志,大量输出日志字符串进入堆内存。
建议在非必要时不开启冗余功能,扫描路径尽量精确。日志级别在生产环境设为INFO或WARN,避免DEBUG级别输出过多内容。
监控不能少,调优是个持续过程
上了Prometheus + Grafana之后,能实时看到堆内存使用趋势、GC次数和耗时。有一次发现某天凌晨GC突增,一查是定时任务批量加载数据进内存处理,没做分页。后来改成流式处理,每次只加载一小批,问题就解决了。
调优不是一锤子买卖,业务变化、流量增长都会带来新的压力点。定期看看GC日志,跑跑压测,才能保证系统稳得住。