今天发现线上一个应用内存占用非常高,但它的cpu使用率却很低
使用 ps
命令,可以看到 进程 19793 占用了4.9G的内存,然而它cpu使用率还不到5%,有问题。
# ps -aux | grep 19793 user 19793 1.6 9.9 23864228 4904664 ? Sl Oct03 268:52 复制代码
我判断这个应用应该是发生了内存泄露,开始进行问题定位和排查。
内存泄露的排查过程一般如下:
工具的使用和介绍这里不赘述了,引用一个博主的 文章
运行命令 jmap -hive 19793
查看对象实例的情况,如图:
这里发现 StandardSession
实例竟然有140万个。 StandardSession
是tomcat的Session的具体实现,难道说Tomcat发生了内存泄露了。
Tomcat 使用 StandardManager
管理服务的Session,而 StandardSession
存储了每个Session对象的数据。
StandardManager
会定期检测每个 Session
实例是否过期,如果过期,则进行回收处理。
这里直接看源码,了解 Tomcat 如何 管理 Session 的
// 具体的检测代码在父类 ManagerBase 中 public StandardManager extends ManagerBase { // ... 忽略不必要的代码 } public abstract class ManagerBase extends LifecycleMBeanBase implements Manager { //Session实例都是保存在这个Map中的,key 值是 sessionId protected Map<String, Session> sessions = new ConcurrentHashMap<>(); // 定时运行函数,Tomcat 有一个守护线程,会定时的遍历运行每个容器的 backgroundProcess 函数, // 一般需要定时执行的代码,都会实现这个函数,让Tomcat统一调用,这样也方便管理 public void backgroundProcess() { count = (count + 1) % processExpiresFrequency; if (count == 0) processExpires(); } public void processExpires() { //记录当前时间 long timeNow = System.currentTimeMillis(); Session sessions[] = findSessions(); int expireHere = 0 ; //遍历所有session,查看是否过期 for (int i = 0; i < sessions.length; i++) { //判断session是否过期,这里可以看出实际判断是否过期的实现在 session 类中 if ( sessions[i]!=null && !sessions[i].isValid() ) { expireHere++; } } long timeEnd = System.currentTimeMillis(); processingTime += ( timeEnd - timeNow );} } 复制代码
这里看看StandardSession的代码
// 看看StandardSession 怎么判断 session 是否过期的 public class StandardSession implements HttpSession, Session, Serializable { //最后活跃时间 protected volatile long lastAccessedTime = creationTime; // 过期时间,-1 为用不过期 protected volatile int maxInactiveInterval = -1; // 记录该实例是否已做过期处理 protected volatile boolean isValid = false; @Override public boolean isValid() { //判断是否已经做过期处理 if (!this.isValid) { return false; } //这里开始判断session是否有过期 if (maxInactiveInterval > 0) { //getIdleTimeInternal 函数是计算最后一次使用时间到当前的间隔 int timeIdle = (int) (getIdleTimeInternal() / 1000L); //如果时间间隔大于过期时间,进行清除处理 //具体的清除就不贴了,简单的说就是执行 manager 的 sessions.remove(obj) 操作,并且做一下其他的处理 if (timeIdle >= maxInactiveInterval) { expire(true); } } return this.isValid; } } 复制代码
通过上述的 manager
和 Session
代码,可以清晰的知道 Session 过期处理逻辑,那么是哪里出现了问题,导致 Session 对象没有被回收。
一般来说对象没有被回收,一定是在某个地方被引用了,这里看看我代码中是怎么用的。实际上我只有在一个拦截器中使用了 session 的操作。
我项目中应用了 session 的代码
// 这是拦截器的一个函数,每个请求进来,必须经过拦截器处理,如果某些方面验证错误,则直接返回错误信息给客户端 public boolean preHandle(HttpServletRequest request, Object handler) throws IOException { // 获取该请求的 Session对象 HttpSession httpSession = request.getSession(); // 获取请求的参数,并操作 httpSession // 这里 setMaxInactiveInterval 表示设置该session的过期时间,1800s String sessionUin = (String) httpSession.getAttribute("uin"); httpSession.setAttribute("uin", uin); httpSession.setMaxInactiveInterval(1800); // 其他处理逻辑 ... return true; } 复制代码
讲道理,我的代码使用是不可能引起内存泄露的,难道我遇到了Tomcat的bug,想想有点兴奋,继续找原因吧。
导出进程的堆栈信息: jmap -dump:format=b,file=tomcatDump 19793
利用 jhat
看看 StandardSession 实例的状态
这里可以看到这个 StandardSession 的 isValid = false
,说明该实例进行过缓存过期处理,
看看它最后一次被访问的时间 lastAccessedTime: 1570329063605
,将时间戳转换一下,时间为 2019-10-06 10:31:03:605
,而当前时间为 2019-10-13
,这早就过期了呀,怎么回事呢。
这好像不太对劲啊,在网上看看有没有其他人遇到过同样的问题。使用谷歌搜索,根本没有发现有这样情况的人。
我都打算另寻他法了,发现还真的有人跟我遇到一样的问题了。但是仔细一看,原来是tomcat6的bug,tomcat的开发人员让他升级到tomcat7就可以了。而项目用的是tomcat9,这个问题早就修复了。
第二天,我还是有点不死心,话说问题没解决怎么能行。
查看项目中实例的数量
> jmap -hive 19793 num #instances #bytes class name ---------------------------------------------- 1: 37494 76896680 [I 2: 25378 20727448 [B 3: 171462 19284664 [C 4: 141175 3388200 java.lang.String 5: 561 2513408 [Ljava.util.concurrent.ConcurrentHashMap$Node; 6: 77525 2480800 java.util.HashMap$Node 7: 38859 2247400 [Ljava.lang.Object; 8: 20021 1761848 java.lang.reflect.Method 9: 14842 1651912 java.lang.Class 10: 51005 1632160 java.util.concurrent.ConcurrentHashMap$Node 11: 18588 1567464 [Ljava.util.HashMap$Node; 12: 29526 1181040 java.util.LinkedHashMap$Entry 13: 13645 764120 java.util.LinkedHashMap 14: 36894 763928 [Ljava.lang.Class; 15: 22800 729600 com.mysql.cj.conf.BooleanProperty 16: 14720 706560 java.util.HashMap 17: 37818 605088 java.lang.Object 18: 18016 432384 java.util.ArrayList 复制代码
纳尼,我的140万个 StandardSession 实例呢,怎么全没了。看看应用的内存占用,还是一样啊,占了差不多5GB的空间,不对劲。
看看堆栈使用情况
> jmap -heap 19793 using thread-local object allocation. Parallel GC with 18 thread(s) Heap Configuration: MinHeapFreeRatio = 0 MaxHeapFreeRatio = 100 //...省略部分不必要的东西 Heap Usage: PS Young Generation Eden Space: capacity = 3287285760 (3135.0MB) used = 53116712 (50.656044006347656MB) free = 3234169048 (3084.3439559936523MB) 1.6158227753220944% used //...省略部分不必要的东西 PS Old Generation capacity = 1083703296 (1033.5MB) used = 62036632 (59.162742614746094MB) free = 1021666664 (974.3372573852539MB) 5.724503397653226% used 复制代码
分析下这些信息:
Eden Space
: 新生代堆的使用情况
capacity used free 使用率为 1.6%
PS Old Generation
: 老年代堆的使用情况
capacity used free 使用率为 5.7%
为什么空闲了这么多内存没有被释放,发生了什么。等等,还有两个重要参数没有讲
Heap Configuration
: 堆的配置信息 MinHeapFreeRatio MaxHeapFreeRatio
到这就知道问题所在了,堆的最大空闲比例为100,表示当堆的使用率为0%时,才会对堆内存做压缩,这永远不会对堆内存进行压缩处理嘛,坑爹呢。
当JVM进行垃圾回收的时候,将不必要的实例清除了,但是由于配置的原因,导致空间不会被压缩,所以该应用一直占用很多空间,而且还越用越大。
解决方法就是在运行的时候在运行的时候加上 -XXMinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=60
。
这里拓展一下项目中两种Java堆的配置。
-Xms 和 -Xmx 相等,JVM一开始就分配最大的堆内存,如此一来就不需要在运行的时候频繁的扩充堆的内存 这个在高吞吐量的项目中是非常实用的。不需要频繁的扩充堆,也不需要频繁的进行垃圾回收处理,可以减少垃圾回收的次数和总时间。 -Xms 和 -Xmx 相等时
,MinHeapFreeRatio 和 MaxHeapFreeRatio 的配置将无效。(这都不需要动态扩展堆大小了,就算配置也用不上)
如果不做处理JVM默认会配置该模式,即 -Xms初始是一个比较小的值,在系统运行时需要更大的堆空间,才会去扩展堆的大小,直到 -Xms 等于 -Xmx
到此,这次的“内存泄露”事件就结束了,其实也不是内存泄露。
一开始问题定位错了,还以为是Tomcat的原因,还特意的去了解Tomcat 的 Session 管理机制和代码实现。还好后来发现了问题所在,没有在错误的方向浪费太多时间,不然把Tomcat的源码翻一遍也找不到具体原因。
补充一点,为什么140万个StandardSession实例已经做过期处理了,但是没有释放呢,这是因为系统内存还较为充足,而且这些实例经过多次 minorGC 都转移到了年老代(项目的Session的有效期为5个小时),如果不进行一次FullGC,是不会整理年老代的数据的。第二天发现实例被清除,这是因为我运行了 jmap -dump 命令,这个会强制的让JVM执行一次FullGC,所以没用的实例都被释放了。