Web会话用户数据未释放导致的内存泄露

一、问题描述

周四,Web服务通过spring session实现分布式会话的第三天,生产上该服务不能接收任何http请求了,并没有崩溃。

在事发第一时间,由于发现问题延时,并没有获取到第一时间的异常信息。由于是单机部署,整体服务不可用了,运维快速拉起另一个备用服务,恢复可用(由于一些原因服务并没有分布式)。

结合以往经验,服务不能接受请求,但是并没有崩溃,一般是接受服务的线程池空闲线程不够用了。

Dump线程栈,导出问题发生前一小时日志,分析。

问题一

首先分析的是jstack产生的线程栈,发现有一条线程 redisMessageListenerContainer-98234,编号已经到了9w多,说明新建过9w多条线程,潜意识认为这里有问题。这个线程是spring session用来监听redis 中用户会话过期用的。

另外在应用日志中发现,getSession方法获取会话时,竟然有10多秒的执行时间。

这两方面,把我的关注点放到了spring session上。

问题二

然后发现大多数tomcat线程,都在等待log4j的全局锁:

"tomcatThreadPool-481" daemon prio=10 tid=0x00002b1fb4172800 nid=0x1d25 waiting for monitor entry [0x00002b201909e000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at org.apache.log4j.Category.callAppenders(Category.java:204)
    - waiting to lock <0x00000000dacef370> (a org.apache.log4j.spi.RootLogger)

全局锁会导致性能的降低,但绝不是导致服务不接受请求的原因,因为获得日志锁打印完日志的线程,总会释放,然后线程空闲,可用来处理请求。

一次错误的尝试

认为是问题一导致的原因, redisMessageListenerContainer-98234大编号的原因,是没有指定监听reids的线程池,而使用了默认机制,默认机制没有使用线程池。

修改指定线程池后,投产。

又挂了

两天之后,周六中午,这个web服务又不能接受请求了。运维拉起了备用服务,问题服务保持现状未处理。

二、原因分析

周一,仔细想了下上周四解决问题的方案,其实并没有找到问题的真正原因。

猜测
Web服务的表现是,在浏览器输入地址,一直处于等待中,tomcat处理不了请求了,很大可能是线程池没有空闲线程了;请求处理慢,加上一定的并发量,会导致空闲线程都有任务,可能会导致新的请求无法分配到空闲线程而被拒绝。

于是带着这个猜测,查看问题出现前一小时的日志,发现大多tomcat数线程都处理得很快。个位数的tomcat线程,在getSession获取会话耗时10多秒,原因未知。

而且线程方法栈中,大多数线程处于Blocked状态,等待log4j1的全局锁。毫无疑问全局锁造成性能降低,但是单次的线程快照并不能证明线程在这个Blocked状态停留了多久,不具备参考性。

JVM heap
既然单一的从jstack的线程方法栈中分析不出原因,尝试分析jvm的内存信息。

由于周六挂掉的那台服务并没有关闭,尝试去分析它的信息,但是毕竟已经停了两天了,jvm信息可能不具备参考性了。

使用 jmap -heap pid命令,输出java 堆概况,如下:

Heap Configuration:
   MinHeapFreeRatio = 40
   MaxHeapFreeRatio = 70
   MaxHeapSize      = 536870912 (512.0MB)
   NewSize          = 178913280 (170.625MB)
   MaxNewSize       = 178913280 (170.625MB)
   OldSize          = 357892096 (341.3125MB)
   NewRatio         = 2
   SurvivorRatio    = 8
   PermSize         = 134217728 (128.0MB)
   MaxPermSize      = 268435456 (256.0MB)
   G1HeapRegionSize = 0 (0.0MB)

Heap Usage:
New Generation (Eden + 1 Survivor Space):
   capacity = 161021952 (153.5625MB)
   used     = 161021944 (153.56249237060547MB)
   free     = 8 (7.62939453125E-6MB)
   99.9999950317333% used
Eden Space:
   capacity = 143130624 (136.5MB)
   used     = 143130624 (136.5MB)
   free     = 0 (0.0MB)
   100.0% used
From Space:
   capacity = 17891328 (17.0625MB)
   used     = 17891320 (17.06249237060547MB)
   free     = 8 (7.62939453125E-6MB)
   99.99995528559981% used
To Space:
   capacity = 17891328 (17.0625MB)
   used     = 0 (0.0MB)
   free     = 17891328 (17.0625MB)
   0.0% used
concurrent mark-sweep generation:
   capacity = 357957632 (341.375MB)
   used     = 357957592 (341.37496185302734MB)
   free     = 40 (3.814697265625E-5MB)
   99.99998882549318% used
Perm Generation:
   capacity = 134217728 (128.0MB)
   used     = 53057360 (50.59944152832031MB)
   free     = 81160368 (77.40055847167969MB)
   39.530813694000244% used

天呐,两天过去了,java heap的新生代还是处于爆满状态,得不到回收。此时的线程栈信息中,tomcat业务线程都处于runnable状态。浏览器尝试访问服务,发现还是不能访问。此时虽然线程池有空闲线程了,但是请求从外部到tomcat分配线程中间,或多或少总会需要一些内存,不可分配,所以请求还是接收不到。

OOM
既然jvm heap不够用了,那么,应用日志里应该输出OOM异常,使用关键字搜索日志,果然发现了一行OOM日志:

[2019-03-02 11:32:29 000378][springSessionRedisTaskExecutor-1][(MNG)(org.springframework.data.redis.listener.RedisMessageListenerContainer:652)(handleSubscriptionException)][ERROR][]SubscriptionTask aborted with exception:
java.lang.OutOfMemoryError: Java heap space

大对象

OOM产生之后,接下来寻找占用内存最大的对象了。

使用jdk自带的jvisualvm工具,装入dump文件:

jvisualvm

前三名是基本类型的对象,通常情况下,java类都是有基本类型的字段的,这个工具不能很好的定位大对象。

换成JProfier工具:

image.png

可以很清楚的看到第一名是SessionRegistryImpl类,它是spring security的会话持有类,显示jvm中只有一个该实例,但是占用321M(分配给虚拟机最大内存512M)。用这个工具,甚至可以分析大对象的成分:

image.png

这时需要依靠代码了,查看这个类的代码:

public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> {

    //~ Instance fields ================================================================================================

    protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);

    /** <principal:Object,SessionIdSet> */
    private final ConcurrentMap<Object,Set<String>> principals = new ConcurrentHashMap<Object,Set<String>>();
    /** <sessionId:Object,SessionInformation> */
    private final Map<String, SessionInformation> sessionIds = new ConcurrentHashMap<String, SessionInformation>();
    
    ...
}

如JProfier工具分析的一样。分析,sessionIds是一个map,key是sessionId,value是用户信息;principals 是当前会话中所有用户信息的集合。

使用JProfier工具,选中sessionIds,分析其成分,如下:

image.png

可以看到,全是用户信息。由于SessionRegistryImpl一直引用到这些用户数据,导致它们不会被gc,越积越多,最后OOM。

持久增长的用户信息

会话有效期间,持久引用用户数据是正常的,因为会话期间确实需要访问用户数据。正常来说,会话过期就不会再持有引用,用户数据得以gc。

spring security中 MyUserDetails是登陆用户信息的直接持有类,利用JProfier,分析该实例的数量:

image.png

数量是6.6w,大小是2646KB;说明在会话有效期1个小时内,有6w多个用户在同时登陆。但是活跃的用户数量并没有这么多,更别说1个小时内同时在线了。

因此怀疑是用户会话过期后,用户数据并没有得到gc,SessionRegistryImpl一直持有用户数据的引用。猜测终究是猜测,需要实际证据来证实。

通过命令 jmap -histo:live pid可以输出当前的大对象信息:

C:\Users\user_name>jmap -histo:live 15728

 num     #instances         #bytes  class name
----------------------------------------------
   1:          1264       17022112  [B
   2:         14917        1346192  [C
   3:          4079         455896  java.lang.Class
   4:          4243         383920  [Ljava.lang.Object;
   5:         14715         353160  java.lang.String
   6:          6862         219584  java.util.concurrent.ConcurrentHashMap$Node
   7:         10742         171872  java.lang.Object
   8:           627          75240  java.net.SocksSocketImpl
   9:          1502          70192  [I
  10:          2068          66176  java.util.HashMap$Node
  11:           627          65208  java.net.TwoStacksPlainSocketImpl
  12:            50          58368  [Ljava.util.concurrent.ConcurrentHashMap$Node;
  13:          1421          56840  java.lang.ref.Finalizer
  14:           614          54032  java.lang.reflect.Method
  15:          1261          40352  java.net.InetAddress$InetAddressHolder
  16:           927          37080  java.util.TreeMap$Entry
  17:           323          32768  [Ljava.util.HashMap$Node;
  18:           625          30000  java.net.SocketInputStream
  19:           625          30000  java.net.SocketOutputStream

写一个shell脚本,循环定时在生产服务器上服务pid的大对象信息,显示用户对象MyUserDetails实例是在持续增长的。

三、解决方案

问题原因是会话用户数据不断增长,过期不释放。

接下来就是分析代码了。不再赘述,修改代码,使会话过期后释放SessionRegistryImpl实例引用的用户信息即可。

四、问题总结

企业级的java应用中,性能相关的问题,不可能只通过单一的手段就能定位原因的。

问题出现时,错误地单一依赖jstack输出的线程转储堆栈,得出了错误的结论;其实线程转储堆栈只是jvm线程集合的快照,单此快照,提供的信息不全面。尚需结合其他信息,多次快照,而且Full GC时,会stop the world 导致其他线程挂起。

解决性能问题的步骤应该是:

  1. 第一时间查看jvm 内存信息,jstack线程转储堆栈,jmap dump jvm内存,gc等信息;因为随着时间的推移,线程,jvm内存,gc信息会变化,错过了问题黄金时间,就得不到正确的信息。当然,可以通过jvm的相关参数,自动输出一些jvm相关信息;如在oom时输出dump,定期输出gc信息等。
  2. 获取问题出现前一段时间的日志。
  3. 结合日志以及jvm信息定位问题。先看日志有没有抛出明显异常,再看jvm内存,是否有大对象之类的,最后看jstack输出的信息,分析jvm,jstack务必要结合代码!!!
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,830评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,992评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,875评论 0 331
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,837评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,734评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,091评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,550评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,217评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,368评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,298评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,350评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,027评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,623评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,706评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,940评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,349评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,936评论 2 341

推荐阅读更多精彩内容

  • http://www.cnblogs.com/angeldevil/p/3801189.html值得一看 Clas...
    snail_knight阅读 1,403评论 1 0
  • Java 应用性能优化是一个老生常谈的话题,典型的性能问题如页面响应慢、接口超时,服务器负载高、并发数低,数据库频...
    Rick617阅读 7,311评论 1 9
  • 作者:一字马胡 转载标志 【2017-11-12】 更新日志 日期更新内容备注 2017-11-12新建文章初版 ...
    beneke阅读 2,184评论 0 7
  • 在一个方法内部定义的变量都存储在栈中,当这个函数运行结束后,其对应的栈就会被回收,此时,在其方法体中定义的变量将不...
    Y了个J阅读 4,410评论 1 14
  • 今天又是礼拜五了,最近忙着研究点营生估计还要十天半月的,所以基本顾不上倩倩,每天还是跟往常一样早早起来陪她洗漱,吃...
    苗嘉倩妈妈阅读 202评论 0 6