什麼?JVM 老年代記憶體不斷上漲竟是因為獲取 ServletContext 姿勢不對

周夢康發表於2021-11-24

前幾日一直在籌備一個比較大的專案,發現一個問題,還好流量不是非常非常大,不然又得提桶跑路了。線上上執行的時候發現當併發過高的情況,會出現老年代記憶體上漲的情況。

定位問題

我找了個臺機器 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

image.png

通過排查程式碼發現,我們專案中,在一個 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/...

相關文章