前幾日一直在籌備一個比較大的專案,發現一個問題,還好流量不是非常非常大,不然又得提桶跑路了。線上上執行的時候發現當併發過高的情況,會出現老年代記憶體上漲的情況。
定位問題
我找了個臺機器 dump 了堆記憶體了,交給了我組攻堅小隊長 LAY
,使用類似於 MAT
一樣的記憶體分析工具,發現記憶體洩露的最大可能性是ConcurrentHashMap
,進一步在支配樹
中發現,ConcurrentHashMap
存的都是org.apache.catalina.session.StandardSession
,基本定位是 tomcat 的 session 機制導致的。
但是我們專案是不依賴 tomcat 的 session 機制的,所有的會話保持都通過 ticket 去和賬號中心互動,在我們系統不儲存。怎麼還是會用到 tomcat 的預設 session 物件呢?十萬個問號在頭腦裡顯現。
檢視 org.apache.catalina.session.ManagerBase
程式碼,可以看到 session
是一個 ConcurrentHashMap
:
protected Map<String, Session> sessions = new<ConcurrentHashMap>();
可以用 arthas 看下線上的情況
watch org.apache.catalina.session.ManagerBase findSession '{params,returnObj,throwExp,target.sessions.size()}' -n 1 -x 3
通過排查程式碼發現,我們專案中,在一個 filter 裡面寫了一段如下程式碼
ServletContext sc = httpServletRequest.getSession().getServletContext()
搜程式碼是一個簡單方案,但是不是 100% 有用,如果搜不到程式碼,最好是通過 arthas stack
檢視呼叫生成 session 的呼叫棧
好傢伙,為什麼獲取ServletContext
要從 session 繞一圈呢?直接就能拿啊 httpServletRequest.getServletContext()
。
Demo 驗證
然後通過一個小實驗可以發現當程式碼裡不顯性的呼叫 session 則不會儲存 session 的。
@RestController
@Slf4j
public class ApiController {
@Autowired
HttpServletRequest httpServletRequest;
@RequestMapping("/hello")
public String hello() {
ServletContext sc1 = httpServletRequest.getServletContext();
ServletContext sc2 = httpServletRequest.getSession().getServletContext();
if (!sc1.equals(sc2)) {
return "500";
}
return "200";
}
}
$ curl -i http://127.0.0.1:8080/hello
HTTP/1.1 200
Set-Cookie: JSESSIONID=7893E89199F79E2EC1BA0EB25D1DCD47; Path=/; HttpOnly
Content-Type: text/plain;charset=UTF-8
Content-Length: 3
Date: Mon, 25 Oct 2021 05:48:24 GMT
200
可以看到想獲取ServletContext
其實不需要通過session
去取,兩種方式獲取到的是同一個物件。所以httpServletRequest.getSession().getServletContext()
方式大可不必。
@RestController
@Slf4j
public class ApiController {
@Autowired
HttpServletRequest httpServletRequest;
@RequestMapping("/hello")
public String hello() {
ServletContext sc1 = httpServletRequest.getServletContext();
return "200";
}
}
$ curl -i http://127.0.0.1:8080/hello
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 3
Date: Mon, 25 Oct 2021 05:49:09 GMT
200%
現在返回的 header 裡面就沒有了JSESSIONID
了。
解決方案
雖然 tomcat 本身是有過期 session 清理方案的,ContainerBackgroundProcessor 執行緒專門做這件事,可以通過配置
server.tomcat.background-processor-delay = 10
來啟動,預設是不開啟的。不過併發過高的情況,也於事無補,畢竟預設是30分鐘的過期時間。並且我們現在的應用都不依賴 tomcat 預設的 session 所以不要呼叫 getSession 的方法是最好的。
為什麼是老年代
年輕代其實分為兩部分,分別是1個Eden區和2個Survivor區(分別叫from和to),預設比例是8:1:1
,一般情況下,新建立的物件都會被分配到Eden區,(除非一些特別大的物件會直接放到老年代),當Eden沒有足夠的空間的時候,就會觸發jvm發起一次Minor GC,如果物件經過一次Minor GC還存活,並且又能被Survivor空間接受,那麼將被移動到Survivor空間當中,物件在Survivor區中每熬過一次Minor GC,年齡就會增加一歲,當它的年齡增加到一定程度(15歲)時,就會被移到老年代中,當然晉升老年代的年齡可以通過-XX:MaxTenuringThreshold
來設定。
https://zhuanlan.zhihu.com/p/...