4、内存性能调优:内存泄漏检测与修复、JVM内存模型与调优、堆外内存管理
内存调优,说白了就是跟JVM“抢”资源。我做了这么多年系统,发现大部分线上故障,最后都指向了内存。要么泄漏,要么GC太频繁,要么堆外内存爆了。今天咱们就把这三块硬骨头啃下来。
4.1 内存泄漏检测与修复
内存泄漏这玩意儿,最坑的地方在于——它不会立刻让你挂。它会慢慢蚕食你的内存,直到某一天,OOM了,你才后知后觉。
核心观点:内存泄漏的本质是“该回收的对象,被无意中持有了引用”。
4.1.1 常见泄漏场景
我个人习惯把泄漏场景归为三类,你对照着排查就行:
- 静态集合类滥用:比如
static List里塞了对象,用完不清理。这玩意儿会一直跟着类加载器,直到你重启。 - 未关闭的资源:数据库连接、IO流、Socket。你以为GC会帮你关?别天真了,GC只管堆内存,不管系统资源。
- 内部类/匿名类持有外部引用:非静态内部类会隐式持有外部类的引用。如果你在Activity里搞了个匿名线程,Activity销毁了线程还在跑,那Activity就泄漏了。
避坑指南:我曾经在一个支付系统中,发现一个HashMap越涨越大。查了两天,最后发现是缓存框架的key没设置过期时间,而且key是动态生成的,永不重复。嗯,这其实就是“无界缓存”泄漏。
4.1.2 检测工具与手法
别靠肉眼猜泄漏,用工具说话:
| 工具 | 适用场景 | 我的用法 |
|---|---|---|
| jmap + MAT | 离线分析堆转储 | 先 jmap -dump:live,format=b,file=heap.hprof 抓快照,再用MAT看“可疑对象”和“GC Root路径” |
| jvisualvm | 实时监控 | 看老年代曲线,如果一直涨不降,基本就是泄漏了 |
| LeakCanary | Android专用 | 自动检测,直接告诉你哪个Activity泄漏了 |
你想想看,如果老年代使用率像心电图一样平稳,那说明GC回收正常。如果它像楼梯一样,一步一个台阶往上走,那恭喜你,中奖了。
4.1.3 修复策略
找到泄漏点之后,修复其实就三板斧:
- 弱引用替换强引用:用
WeakHashMap或WeakReference替代强引用。GC一旦发现弱引用,直接回收。 - 显式清理:在
finally块里关资源,或者用 try-with-resources 语法糖。 - 生命周期绑定:比如Android里,Activity销毁时,把相关的Runnable、Callback都清掉。
注意:别滥用弱引用。弱引用适合“可有可无”的缓存数据。如果是核心业务对象,用弱引用会导致莫名其妙被回收,引发空指针。
4.2 JVM内存模型与调优
JVM内存模型,说白了就是Java程序的地盘划分。你得知道每个区域是干嘛的,才能对症下药。
4.2.1 内存区域划分
我习惯把JVM内存分成三块核心区域:
- 堆(Heap):对象的老巢。新生代、老年代都在这里。GC主要在这干活。
- 栈(Stack):线程私有,存局部变量、方法调用。栈溢出通常是因为递归太深。
- 元空间(Metaspace):存类元数据。以前叫永久代,现在用本地内存了,不容易OOM,但也不是无限的。
为什么会这样?因为Java设计者发现,类元数据很难预测大小,放在堆里容易引发Full GC。干脆挪到本地内存,让GC少操点心。
4.2.2 调优参数实战
调优不是玄学,是数学。我给你一套我常用的参数模板:
# 堆大小
-Xms4g -Xmx4g
# 新生代大小
-Xmn2g
# 元空间
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
# GC选择
-XX:+UseG1GC
# GC日志
-Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
我个人习惯把 -Xms 和 -Xmx 设成一样。为什么?因为如果初始堆太小,JVM会频繁申请内存,引发系统调用,影响性能。干脆一次到位。
小技巧:如果你用的是G1GC,可以关注 -XX:MaxGCPauseMillis=200。这个参数告诉JVM:每次GC尽量别超过200毫秒。JVM会通过调整新生代大小来满足这个目标。
4.2.3 GC调优思路
调优GC,其实就三个目标:
- 减少GC次数:增大堆空间,减少对象创建。
- 缩短GC停顿:用G1或ZGC,替代老旧的CMS。
- 避免Full GC:Full GC是串行的,一旦触发,应用会“卡死”几秒甚至几十秒。
我记得有一次,一个交易系统每半小时就Full GC一次,每次停顿5秒。查了半天,发现是系统里有个定时任务,每30分钟加载一次全量配置,加载完就触发Full GC。解决方案很简单:把配置加载分散到多个小批次,或者用本地缓存+增量更新。
4.3 堆外内存管理
堆外内存,就是JVM堆之外的内存。它不受GC管理,所以你得自己负责回收。听起来麻烦,但它在某些场景下性能极好。
4.3.1 为什么需要堆外内存?
你想想看,如果数据要从磁盘读到堆里,再通过网络发出去,中间要经过几次拷贝?至少两次。但如果用堆外内存,数据可以直接在堆外处理,减少一次拷贝,速度能快30%以上。
典型场景:
- Netty:用DirectBuffer处理网络IO,零拷贝。
- Kafka:用堆外内存做页缓存,读写速度极快。
- RocketMQ:消息存储直接映射到堆外。
4.3.2 堆外内存的陷阱
堆外内存最大的坑是——它不归GC管。你分配了100MB堆外内存,如果不释放,JVM堆里可能才用了200MB,但系统内存已经快爆了。
我曾经踩过的坑:一个实时风控系统,用了大量DirectBuffer做特征计算。上线后,系统内存持续上涨,但JVM堆一直很健康。查了三天,才发现是DirectBuffer没调用 cleaner.clean() 释放。堆外内存泄漏,比堆内泄漏更难发现。
4.3.3 堆外内存监控与调优
监控堆外内存,我推荐两个方法:
- 使用
-XX:MaxDirectMemorySize:限制堆外内存上限,防止它无限制增长。 - 使用
jcmd或JMX:查看java.nio.ByteBuffer的分配情况。
调优建议:
- 堆外内存不是越大越好。一般建议设为堆内存的1/4到1/2。
- 如果用了Netty,记得设置
io.netty.maxDirectMemory参数。 - 频繁分配和释放堆外内存,会导致系统碎片化。建议用对象池复用。
总结一下:内存调优,其实就是“知己知彼”。知己,是知道你的应用需要多少内存、什么类型的内存。知彼,是知道JVM和操作系统怎么管理内存。把这两头摸透了,性能问题基本就解决了一半。
嗯,这一章的内容就到这里。下一章咱们聊聊CPU性能调优,那又是另一番天地了。