問題的發現
今天發現線上一個應用內存佔用非常高,但它的cpu使用率卻很低
使用ps
命令,可以看到 進程 19793 佔用了4.9G的內存,然而它cpu使用率還不到5%,有問題。
# ps -aux | grep 19793
user 19793 1.6 9.9 23864228 4904664 ? Sl Oct03 268:52
我判斷這個應用應該是發生了內存泄露,開始進行問題定位和排查。
內存泄露的排查過程一般如下:
- 使用 jmap 查看哪些對象個數非常多,內存佔用多
- 分析 dump 文件和堆佔用情況
- 定位具體的類和相關代碼的調用過程,一步一步的查找問題所在
工具的使用和介紹這裏不贅述了,引用一個博主的文章
問題定位與排查
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;
}
}
通過上述的 manager
和 Session
代碼,可以清晰的知道 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.1GBused
: 已使用的空間, 當前已使用 50MBfree
: 空閒的空間 當前空閒了3.08GB使用率爲 1.6%
-
PS Old Generation
: 老年代堆的使用情況capacity
:總大小,當前堆的大小爲 1 GBused
: 已使用的空間, 當前已使用 59 MBfree
: 空閒的空間 當前空閒了 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,所以沒用的實例都被釋放了。
參考文章: