现象
通过 jstat -gcutil pid 5000 ,发现 fgc 次数很多而且频繁,此时老年代占比已经大约 70%左右,且已经回收不了内存,我们这边设置的 fgc 阈值是老年代的 70%。此时因为还有 30%的老年空间,所以整体内存相对还算稳定,CPU 也比较稳定,但是有很大的潜在的风险,就是内存一直上涨,不释放。
[service@ZQ-SE-331-V05 ~]$ jstat -gcutil 1087 5000
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 55.09 88.41 72.10 92.64 85.22 9223 1169.442 435 168.866 1338.307
57.54 0.00 82.24 72.31 92.64 85.22 9224 1169.542 436 168.877 1338.418
0.00 63.75 5.33 72.50 92.64 85.22 9225 1169.642 436 168.877 1338.519
0.00 63.75 34.02 72.50 92.64 85.22 9225 1169.642 436 168.877 1338.519
0.00 63.75 59.26 72.50 92.64 85.22 9225 1169.642 436 168.877 1338.519
0.00 63.75 81.37 72.50 92.64 85.22 9225 1169.642 436 168.877 1338.519
55.60 0.00 11.75 72.71 92.64 85.22 9226 1169.742 436 168.877 1338.619
55.60 0.00 40.07 72.71 92.64 85.22 9226 1169.742 436 168.877 1338.619
55.60 0.00 67.86 72.70 92.64 85.22 9226 1169.742 437 169.541 1339.284
0.00 56.04 4.21 72.59 92.64 85.22 9227 1169.838 437 169.541 1339.379
0.00 56.04 30.01 71.73 92.64 85.22 9227 1169.838 438 169.649 1339.487
0.00 56.04 57.75 71.73 92.64 85.22 9227 1169.838 438 169.649 1339.487
0.00 56.04 79.01 71.73 92.64 85.22 9227 1169.838 438 169.649 1339.487
55.39 0.00 2.54 71.92 92.64 85.22 9228 1169.988 438 169.649 1339.638
55.39 0.00 24.70 71.92 92.64 85.22 9228 1169.988 438 169.649 1339.638
55.39 0.00 47.89 71.92 92.64 85.22 9228 1169.988 438 169.649 1339.638
55.39 0.00 82.01 71.89 92.64 85.22 9228 1169.988 439 170.207 1340.196
复制代码
复制代码
初步猜测是出现了内存泄露,通过jmap -histo/-histo:live pid >> log
导出 fgc 前后的 histo 对比,发现了一个实例数很大的对象CarnivalOneDayInfo
,达到了 2kw 级别,而且一直在增加
num #instances #bytes class name
----------------------------------------------
1: 28906840 1387528320 java.util.HashMap
2: 38675870 1237627840 java.util.HashMap$Node
3: 18631826 745273040 xxx.CarnivalOneDayInfo
num #instances #bytes class name
----------------------------------------------
1: 31092889 1492458672 java.util.HashMap
2: 35749328 1143978496 java.util.HashMap$Node
3: 20355334 814213360 xxx.CarnivalOneDayInfo
复制代码
复制代码
排查
直接看 CarnivalOneDayInfo(嘉年华)相关代码,因为之前的测试大概知道这个问题,所以很快的定位到是在每分钟的 MinuteJob 中检查所有在线玩家的时间活动的时候,相关逻辑会克隆一个 CarnivalOneDayInfo。所以初步定位是 CarnivalOneDayInfo 不断 clone 的问题
后端 y 同学看了一下相关逻辑,本地把相关 clone 逻辑注释后,再次本地观察 histo,发现该对象实例不在增加,再次确认 1 的推论,但看了一下活动相关代码,所有活动的检查都是 clone 了一份,为什么只有嘉年华活动泄露?看相关代码,发现 clone 出来的对象都是临时对象,应该会被 fgc 的,如何泄露呢?
所以直接开始操作第一步,准备查CarnivalOneDayInfo
是被谁持有引用,查这个问题的话,必须要把内存堆快照 dump 出来,然后利用工具检查,如 mat。但是线上玩家很多,而且堆内存很多,导出一次很花费时间,会 stw,所以直接连开发服务器,查了一下,一样存在 CarnivalOneDayInfo 泄露的问题,所以直接利用jmap -dump:live,format=b,file=2388_heap.bin 2388
,导出开发服务器 java 进程的堆内存快照。
直接利用 mat 打开,对 mat 使用有经验的话,操作步骤是
选择 dominator_tree
搜索 CarnivalOneDayInfo
List Objects
选择 With incoming references,谁持有了它的引用
直接发现是被
queue-executor-handler-5
java.lang.ThreadLocal$ThreadLocalMap @ 0x8104eec0
java.lang.ThreadLocal$ThreadLocalMap$Entry[64] @ 0x866710f0
java.lang.ThreadLocal$ThreadLocalMap$Entry @ 0x86671608
java.util.IdentityHashMap @ 0x86671628
复制代码
复制代码
java.lang.Object[]
,通过查看这个属性,可以看到其大量持有了CarnivalOneDayInfo和HashSet
从上面可以很确定的是,CarnivalOneDayInfo
是直接被逻辑线程的 threadlocal 持有。
艰难的分析之路
逻辑线程的 threadlocal 怎么会持有 CarnivalOneDayInfo,从代码和想法上感觉不可思议,我这边的第一步是直接在 ide 中查所有 ThreadLocal 的引用,发现
logback
protobuf
业务自己的 threadlocal
BeanUtils 的 threadlocal
排查后,简单测试了一下,和查了一下相关代码,发现没有什么思路和投诉。觉得 beanutils 和 logback 有点问题,但是感觉和CarnivalOneDayInfo
关系也不大,于是下面我的重点在于想从 mat 入手,看看能不能从中找出 threadlocal 的名字,但最终是被证明是徒劳的,只有引用地址,不过我们 z 同学从是 debug 的排查思路出发的在ThreadLocal
的 set 和setInitialValue
打断点,然后跑游戏,重点断点在 MinuteJob,向逻辑线程投递消息检查活动状态这块每次调用checkTimeActivity
,都去观察每个ThreadLocal$ThreadLocalMap$Entry
然后重点观察是否出现 IdentityHashMap,因为上面 mat 分析到了是这个 map 终于定位到堆栈。
堆栈
MinuJob -> 遍历在线所有玩家,向逻辑线程投递消息 -> ActivityManager#checkTimeActivity
遍历所有个人活动 -> CarnivalActivityInfo#checkActivityState
CarnivalActivityInfo diff = playerInfo.clone() // 在这里 clone 了一份
checkIsSameState -> activityBaseInfo.getCarnivalDaysMap().equals(carnivalDaysMap) // 调用 equals 比较
去依次比较 CarnivalDaysMap 中每一个 CarnivalOneDayInfo
调用 CarnivalOneDayInfo -> BaseCarnivalOneDayInfo # equals
BeanUtils.isDirty
具体代码
private static Set<String> getDirtyNamesByBean(AugmentedBean augmentedBean) {
IdentityHashMap<AugmentedBean, Set<String>> dirtyNamesMap = dirtyNames.get();
if (dirtyNamesMap == null) {
dirtyNamesMap = new IdentityHashMap<>();
dirtyNames.set(dirtyNamesMap);
}
return dirtyNamesMap.computeIfAbsent(augmentedBean, k -> Sets.newHashSet());
}
public static boolean isDirty(AugmentedBean augmentedBean) {
return getDirtyNamesByBean(augmentedBean).size() > 0;
}
复制代码
复制代码
从上面可以非常容易的看到是这里创建了一个 IdentityHashMap,然后 set 到了 threadlocal 中和之前的分析如出一辙至此完全定位问题是,是 BeanUtils 的锅。
复盘和总结
其实非常怀疑过 beanutils 的问题,但是没有太仔细注意,现在一看,和上面的分析一模一样,分析内存泄露,思路:
对比 fgc 前后的 histo,确认那些对象实例数一直在增加,而且明显偏大
分析代码,如果直接定位问题,最好
如果不能直接定位,需要确认是谁持有该对象引用,那么需要 dump 堆内存快照
但是不能在线上 dump,需要在开发服务器复现(内存泄露通常比较容易复现),然后在开发服务器 dump
dump 出来后,利用 mat 工具分析泄露,List Objects With incoming references,找到引用
下一步是需要确认为什么这里会引用,可以分析代码,解决问题,最好
如果不能,那么可以使用 debug 的方式,在上一步引用相关对象的代码出加断点,确认线程堆栈
也解释了为什么只有嘉年华活动有泄露,因为只有它调用了 beanutils 生成的 basexx 的 equals 方法,其他都没有调用,beanutils 是当初 clone 对象的一个解决方案,用来回滚和 diff,增量更新,后来该方案废弃,因为会随着对象的复杂度提高而导致 clone 成本高,但是遗留了一大部分生成的代码,而这次的 bug 也是因为调用了废弃的生成代码的方法。所以下一个版本一定将所有生成的废弃代码清理一遍
后续解决办法。
发现了 beanutils 的这个问题后,那么很容易解决泄露问题了。我写了一个 beanshell 脚本,向逻辑线程投递了消息,调用 BeanUtils.clean,清理所有 threadlocals
import x.BeanUtils;
import y.HandlerModule;
for (int i = 1; i <= 16; i++) {
HandlerModule.instance.addQueueTask(i, new Runnable() {
public void run() {
BeanUtils.clean();
}
});
}
复制代码
复制代码
在用 jstat 看了一下,CarnivalOneDayInfo 经过 fgc 后,从 kw 到了百万,老年代占用也从 70 到了 30
$ jstat -gcutil 1087 5000
74.73 0.00 16.02 72.48 92.61 85.04 10156 1313.117 575 271.355 1584.472
74.73 0.00 34.71 72.48 92.61 85.04 10156 1313.117 575 271.355 1584.472
74.73 0.00 54.42 72.48 92.61 85.04 10156 1313.117 575 271.355 1584.472
74.73 0.00 73.29 72.48 92.61 85.04 10156 1313.117 575 271.355 1584.472
74.73 0.00 89.41 72.48 92.61 85.04 10156 1313.117 575 271.355 1584.472
0.00 71.54 9.25 72.74 92.64 85.06 10157 1313.303 576 272.188 1585.492
0.00 71.54 28.30 72.73 92.64 85.06 10157 1313.303 577 272.188 1585.492
0.00 71.54 55.85 72.73 92.64 85.06 10157 1313.303 577 272.463 1585.766
0.00 71.54 78.05 72.73 92.64 85.06 10157 1313.303 577 272.463 1585.766
69.21 0.00 1.70 70.98 92.64 85.06 10158 1313.438 578 273.320 1586.758
69.21 0.00 19.97 63.09 92.64 85.06 10158 1313.438 578 273.320 1586.758
69.21 0.00 39.82 53.33 92.64 85.06 10158 1313.438 578 273.320 1586.758
69.21 0.00 59.75 41.61 92.64 85.06 10158 1313.438 578 273.320 1586.758
69.21 0.00 75.12 31.79 92.64 85.06 10158 1313.438 578 273.320 1586.758
69.21 0.00 94.13 31.79 92.64 85.06 10158 1313.438 578 273.320 1586.758
0.00 86.02 15.60 32.07 92.64 85.06 10159 1313.761 578 273.320 1587.081
0.00 86.02 94.86 32.07 92.64 85.06 10159 1313.761 578 273.320 1587.081
[service@ZQ-SE-331-V05 config]$ jmap -histo 1087 | grep CarnivalOneDayInfo
10: 1408627 56345080 xxx.CarnivalOneDayInfo
复制代码
复制代码
另外后面的优化方案是重构活动代码,另外即使用 equals,也不用之前 beanutils 生成的类的 equals 比较,避免 beanutils threadlocals 的泄露问题。
关于内存问题,在正式上线之前,一定要可根据同时在线人数,dau 等准确预估整体占用内存,如一个 player 的实际的占用内存,全局静态数据如排行榜的实际占用内存等。可以通过代码和工具获取。这样能快速确认是否是出现了内存泄露还是真的比较占内存。
看完三件事❤️
如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
关注公众号 『 java 烂猪皮 』,不定期分享原创知识。
同时可以期待后续文章 ing🚀
评论