一次艰难的内存泄露排查

一次艰难的内存泄露排查

  1. 现象

    • 2019.4.26 22:00左右,通过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
      
  2. 排查

    1. 直接看CarnivalOneDayInfo(嘉年华)相关代码,因为之前的测试大概知道这个问题,所以很快的定位到是在每分钟的MinuteJob中检查所有在线玩家的时间活动的时候,相关逻辑会克隆一个CarnivalOneDayInfo。所以初步定位是CarnivalOneDayInfo不断clone的问题

    2. 后端y同学看了一下相关逻辑,本地把相关clone逻辑注释后,再次本地观察histo,发现该对象实例不在增加,再次确认1的推论

    3. 但看了一下活动相关代码

      所有活动的检查都是clone了一份,为什么只有嘉年华活动泄露?

      看相关代码,发现clone出来的对象都是临时对象,应该会被fgc的,如何泄露呢?

    4. 所以直接开始操作第一步,准备查CarnivalOneDayInfo是被谁持有引用

      查这个问题的话,必须要把内存堆快照dump出来,然后利用工具检查,如mat

      但是线上玩家很多,而且堆内存很多,导出一次很花费时间,会stw

    5. 所以直接连开发服务器,查了一下,一样存在CarnivalOneDayInfo泄露的问题,所以直接利用

      jmap -dump:live,format=b,file=2388_heap.bin 2388,导出开发服务器java进程的堆内存快照

      直接利用mat打开

    6. 对mat使用有经验的话,操作步骤是直接

      选择dominator_tree

      搜索CarnivalOneDayInfo

      List Objects

      选择With incoming references,谁持有了它的引用

    7. 直接发现是被

      queue-executor-handler-5

      java.lang.ThreadLocal$ThreadLocalMap @ 0x8104eec0

      java.lang.ThreadLocalThreadLocalMapEntry[64] @ 0x866710f0

      java.lang.ThreadLocalThreadLocalMapEntry @ 0x86671608

      java.util.IdentityHashMap @ 0x86671628

      java.lang.Object[],通过查看这个属性,可以看到其大量持有了CarnivalOneDayInfo和HashSet

    8. 从上面可以很确定的是,CarnivalOneDayInfo是直接被逻辑线程的threadlocal持有

  3. 艰难的分析之路

    1. 逻辑线程的threadlocal怎么会持有CarnivalOneDayInfo,从代码和想法上感觉不可思议

    2. 我这边的第一步是直接在ide中查所有ThreadLocal的引用,发现

      logback

      protobuf

      业务自己的threadlocal

      BeanUtils的threadlocal

    3. 排查后,简单测试了一下,和查了一下相关代码,发现没有什么思路和投诉。觉得beanutils和logback有点问题,但是感觉和CarnivalOneDayInfo关系也不大

    4. 于是下面我的重点在于想从mat入手,看看能不能从中找出threadlocal的名字,但最终是被证明是徒劳的,只有引用地址

    5. 不过我们z同学从是debug的排查思路出发的

      在ThreadLocal的set和setInitialValue打断点

      然后跑游戏,重点断点在MinuteJob,向逻辑线程投递消息检查活动状态这块

      每次调用checkTimeActivity,都去观察每个ThreadLocalThreadLocalMapEntry

      然后重点观察是否出现IdentityHashMap,因为上面mat分析到了是这个map

      终于定位到堆栈

    6. 堆栈

      MinuJob -> 遍历在线所有玩家,向逻辑线程投递消息 -> ActivityManager#checkTimeActivity

      遍历所有个人活动 -> CarnivalActivityInfo#checkActivityState

      CarnivalActivityInfo diff = playerInfo.clone() // 在这里clone了一份

      checkIsSameState -> activityBaseInfo.getCarnivalDaysMap().equals(carnivalDaysMap) // 调用equals比较

      去依次比较CarnivalDaysMap中每一个CarnivalOneDayInfo

      调用CarnivalOneDayInfo -> BaseCarnivalOneDayInfo # equals

      BeanUtils.isDirty

    7. 具体代码

      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;
          }
      
    8. 从上面可以非常容易的看到

      • 是这里创建了一个IdentityHashMap,然后set到了threadlocal中
      • 和之前的分析如出一辙
    9. 至此完全定位问题是,是BeanUtils的锅

  4. 复盘和总结

    1. 其实在3的时候,非常怀疑过beanutils的问题,但是没有太仔细注意,现在一看,和上面的分析一模一样
    2. 分析内存泄露,思路
      • 对比fgc前后的histo,确认那些对象实例数一直在增加,而且明显偏大
      • 分析代码,如果直接定位问题,最好
      • 如果不能直接定位,需要确认是谁持有该对象引用,那么需要dump堆内存快照
      • 但是不能在线上dump,需要在开发服务器复现(内存泄露通常比较容易复现),然后在开发服务器dump
      • dump出来后,利用mat工具分析泄露,List Objects With incoming references,找到引用
      • 下一步是需要确认为什么这里会引用,可以分析代码,解决问题,最好
      • 如果不能,那么可以使用debug的方式,在上一步引用相关对象的代码出加断点,确认线程堆栈
    3. 也解释了为什么只有嘉年华活动有泄露,因为只有它调用了beanutils生成的basexx的equals方法,其他都没有调用
    4. beanutils是当初clone对象的一个解决方案,用来回滚和diff,增量更新,后来该方案废弃,因为会随着对象的复杂度提高而导致clone成本高,但是遗留了一大部分生成的代码,而这次的bug也是因为调用了废弃的生成代码的方法。所以下一个版本一定将所有生成的废弃代码清理一遍
  5. 后续解决办法

    • 发现了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的泄露问题

  6. 关于内存问题,在正式上线之前,一定要可根据同时在线人数,dau等准确预估整体占用内存,如一个player的实际的占用内存,全局静态数据如排行榜的实际占用内存等。可以通过代码和工具获取。这样能快速确认是否是出现了内存泄露还是真的比较占内存。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容