記一次"內存泄露"排查過程

問題的發現

今天發現線上一個應用內存佔用非常高,但它的cpu使用率卻很低

使用ps命令,可以看到 進程 19793 佔用了4.9G的內存,然而它cpu使用率還不到5%,有問題。

# ps -aux | grep 19793
user     19793  1.6  9.9 23864228 4904664 ?    Sl   Oct03 268:52 

我判斷這個應用應該是發生了內存泄露,開始進行問題定位和排查。

內存泄露的排查過程一般如下:

  1. 使用 jmap 查看哪些對象個數非常多,內存佔用多
  2. 分析 dump 文件和堆佔用情況
  3. 定位具體的類和相關代碼的調用過程,一步一步的查找問題所在

工具的使用和介紹這裏不贅述了,引用一個博主的文章

問題定位與排查

1. 使用 jmap 查看堆的使用情況

運行命令 jmap -hive 19793 查看對象實例的情況,如圖:
在這裏插入圖片描述

這裏發現 StandardSession 實例竟然有140萬個。StandardSession 是tomcat的Session的具體實現,難道說Tomcat發生了內存泄露了。

2. 瞭解Tomcat Session的實現和回收原理

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;
   }
}

通過上述的 managerSession 代碼,可以清晰的知道 Session 過期處理邏輯,那麼是哪裏出現了問題,導致 Session 對象沒有被回收。

3. 看看自己的代碼是否有問題

一般來說對象沒有被回收,一定是在某個地方被引用了,這裏看看我代碼中是怎麼用的。實際上我只有在一個攔截器中使用了 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,想想有點興奮,繼續找原因吧。

4. 導出線上進程的堆棧信息,查看StandardSession實例的值

導出進程的堆棧信息:
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,這個問題早就修復了。

5. 再次查看項目的堆棧使用情況

第二天,我還是有點不死心,話說問題沒解決怎麼能行。

查看項目中實例的數量

> 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 :總大小,當前堆的大小爲 3.1GB
    • used : 已使用的空間, 當前已使用 50MB
    • free : 空閒的空間 當前空閒了3.08GB
    • 使用率爲 1.6%
  • PS Old Generation: 老年代堆的使用情況

    • capacity :總大小,當前堆的大小爲 1 GB
    • used : 已使用的空間, 當前已使用 59 MB
    • free : 空閒的空間 當前空閒了 974 MB
    • 使用率爲 5.7%

爲什麼空閒了這麼多內存沒有被釋放,發生了什麼。等等,還有兩個重要參數沒有講

  • Heap Configuration : 堆的配置信息
    • MinHeapFreeRatio : 堆空間最小空閒比例 ,如果堆的空閒比例小於這個值,JVM將進行擴容處理
    • MaxHeapFreeRatio : 堆空間最大空閒比例, 如果堆的空閒比例超過這個值,JVM將壓縮堆空間
6. 問題定位及解決方法

到這就知道問題所在了,堆的最大空閒比例爲100,表示當堆的使用率爲0%時,纔會對堆內存做壓縮,這永遠不會對堆內存進行壓縮處理嘛,坑爹呢。

當JVM進行垃圾回收的時候,將不必要的實例清除了,但是由於配置的原因,導致空間不會被壓縮,所以該應用一直佔用很多空間,而且還越用越大。

解決方法就是在運行的時候在運行的時候加上 -XXMinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=60

7. 一點小拓展

這裏拓展一下項目中兩種Java堆的配置。

  • 穩定的Java堆

-Xms 和 -Xmx 相等,JVM一開始就分配最大的堆內存,如此一來就不需要在運行的時候頻繁的擴充堆的內存

這個在高吞吐量的項目中是非常實用的。不需要頻繁的擴充堆,也不需要頻繁的進行垃圾回收處理,可以減少垃圾回收的次數和總時間。

-Xms 和 -Xmx 相等時,MinHeapFreeRatio 和 MaxHeapFreeRatio 的配置將無效。(這都不需要動態擴展堆大小了,就算配置也用不上)

  • 動盪的Java堆

如果不做處理JVM默認會配置該模式,即 -Xms初始是一個比較小的值,在系統運行時需要更大的堆空間,纔會去擴展堆的大小,直到 -Xms 等於 -Xmx

總結

到此,這次的“內存泄露”事件就結束了,其實也不是內存泄露。

一開始問題定位錯了,還以爲是Tomcat的原因,還特意的去了解Tomcat 的 Session 管理機制和代碼實現。還好後來發現了問題所在,沒有在錯誤的方向浪費太多時間,不然把Tomcat的源碼翻一遍也找不到具體原因。

補充一點,爲什麼140萬個StandardSession實例已經做過期處理了,但是沒有釋放呢,這是因爲系統內存還較爲充足,而且這些實例經過多次 minorGC 都轉移到了年老代(項目的Session的有效期爲5個小時),如果不進行一次FullGC,是不會整理年老代的數據的。第二天發現實例被清除,這是因爲我運行了 jmap -dump 命令,這個會強制的讓JVM執行一次FullGC,所以沒用的實例都被釋放了。

參考文章:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章