tomcat為什么假死了.md
現象
我們生產最近有個服務偶爾會掛掉,接口報錯"connection reset by peer",上服務器curl也是同樣報錯,意思連接被server拒絕了。
通過dump以及日志分析,我們已經知道了問題代碼所在,就是使用easyexcel上傳、解析文件,開發同學沒有做分頁,導致內存溢出。這點在easyexcel文檔也有提到:。

內存溢出后,觸發頻繁的full gc,由于gc很難有效回收內存,所以程序拋出了OutOfMemoryError,原因是:Java heap space。

關于OOM的異常原因,我們也需要知道,有如下幾種:
Java heap space
內存無法分配新的對象。典型的場景是:內存不足,內存泄漏,分配的對象過大。
GC Overhead limit exceeded
gc回收異常,多次發生了98%的時間用于gc,但只回收2%的內存。典型的場景是:內存不足,內存泄漏。
Metaspace
元空間不足。典型的場景是:元空間設置太小,程序異常創建過多的Class。
Direct buffer memory
直接內存不足。典型的場景是:直接內存設置太小,直接內存泄漏。
Unable to create new native thread
無法創建新的線程。典型的場景是:系統ulimit -u設置太小。
Requested array size exceeds VM limi
數組大小太大。典型的場景是:new ClassA[Long.MAX_VALUE]
CodeCache
jit編譯緩存溢出。典型的場景是jit緩存設置過小。
注意,我們上面提到問題的是內存溢出,而不是內存泄漏,兩者有本質的區別。
內存溢出,通常是分配大對象,應用內存不足,通常分配多點空間就可以解決問題,而且所占的內存用完還是可以被回收的。
內存泄漏,則是程序有問題,內存是無法被回收的,分配再多的空間也都會被慢慢消耗完。
打個比方,內存溢出只是人長得不好看,不是壞人,內存泄漏則長得不好看,也是壞人。當然兩者我們都需要對其進行優化。
通過我們的觀察也發現確實如此,經過一段時間后,文件解析數據處理完,內存就被回收了,也沒有full gc了。

但問題來了,此時應用http請求還是繼續報錯的,依然是"connection reset by peer"。這點就不好理解了,應用恢復了,為什么tomcat沒有恢復,tomcat線程此時在做什么?從日志也看不到tomcat相關錯誤,tomcat假死了。
從監控上看,掛掉之前tomcat的請求線程數和連接數沒有什么波動。

我們的兩個核心問題是:
- 什么是"connection reset by peer"?
- tomcat線程此時處于什么狀態?
連接報錯
我們搜索源碼,并沒有拋出"connection reset by peer"的代碼,也就是可能是jvm層面的拋出,或者系統層面的拋出。

既然是跟connection相關,那我們就用netstat命令看下當前進程的連接情況:
netstat -tlnp | grep 8100
netstat -anp | grep 8100

從輸出可以發現,有一個特殊的101,我們用正常的進程看一般都是0。
這個參數叫:backlog,表示連接等待隊列的長度,對應tomcat的acceptCount參數,默認是100。
當連接超過這個值時,就會報"connection reset by peer",我們當前是101,所以新來的請求就被拒絕了。

tomcat線程模型
對于第二個問題,tomcat線程正在干什么。一般我們可以通過jstack pid導出線程堆棧分析,不過當我們的服務運行一段時間,例如好幾天后,執行jstack,jmap都會報錯,似乎是某些信息被系統清除掉,這點我還找到根本原因,如果你知道答案請告知我一下。
幸好有arthas,我們可以通過thread命令,查看線程和其堆棧信息。
thread -n 10 #top 10 cpu
thread 1 #展示線程1的堆棧
thread --all #展示所有線程
thread --all | grep http #展示http線程

我們可以看到,tomcat的10個核心線程還是在的,且處于waitting狀態。
處于waitting狀態是因為它在等任務執行,從堆棧可以看出是阻塞在TaskQueue.take方法,org.apache.tomcat.util.threads.TaskQueue是tomcat中的LinkedBlockingQueue,是生產者-消費者模型,take方法阻塞表示當前隊列是空的,沒有任務需要執行,一旦有任務放入TaskQueue,take方法就會喚醒,進入Runnable狀態。

這點就很奇怪了,前面說連接隊列都滿了,但tomcat任務隊列確是空的,執行線程都處于等待任務狀態,一邊滿載一邊空閑。
要搞清楚這個問題,需要我們對tomcat線程模型有所了解。tomcat支持幾種IO模型,BIO、NIO、AIO(NIO2)、APR,我們可以通過server.tomcat.protocol參數進行設置,默認用的是NIO,NIO是一種同步非阻塞IO。
NIO的核心目的是可以用少量線程處理大量連接,在linux用select/poll/epoll實現。
NIO在很多中間件都有應用,kafka,redis,rocketmq,gateway等等,可以說涉及到網絡處理的都離不開NIO。
AIO是真正的異步IO,但Linux對其支持不夠完善,且NIO已經足夠高效,所以NIO用得最多。
reactor模型
如何更好的實現NIO是個問題,就好像我們實現某個功能要用到設計模式一樣,reactor就是NIO一種實現模式。doug lee在總結了3種模型:
單reactor單線程
整個過程由一個線程完成,包括創建連接,讀寫數據,業務處理。redis 6.0以前的版本就是這種模式,實現起來簡單,沒有線程切換,加鎖的開銷。缺點是單線程不能發揮多核cpu的優勢,如果有一個業務處理阻塞了,那么整個服務都會阻塞。

單reactor多線程
接收連接(accept)和IO讀寫還是由一個線程完成,但業務處理會提交給業務線程池,業務處理不會阻塞整個服務。

多reactor多線程
接收連接由一個main reactor處理,建立連接后將其注冊到sub reactor上,每個sub reactor都是單reactor多線程模式。

tomcat的實現
tomcat的NIO由3種線程實現,分別是:Acceptor線程、Poller線程、請求處理線程。
對于請求處理線程池我們比較熟悉,常用的兩個參數:
server.tomcat.minSpareThreads = 10
server.tomcat.maxThreads = 200
對應到reactor模型,可以看成它是一種多reactor多線程模型,Acceptor線程負責建立連接,然后將建立好的連接注冊到Poller,由Poller進行讀寫。Poller讀寫后創建請求,將其交給請求處理線程池。
Acceptor和Poller線程對應的run方法都是一個死循環,源源不斷的接收連接、讀寫連接。
從reactor模型上看,在多核cpu下,多reactor多線程模型可以獲得更高的效率,但tomcat10以下默認只能有一個Acceptor和一個Poller線程。

源碼分析
源碼位置:org.apache.tomcat.util.net.NioEndpoint#startInternal,開啟Acceptor線程和Poller線程:


源碼位置:org.apache.tomcat.util.net.Acceptor#run,while循環,建立連接:

源碼位置:org.apache.tomcat.util.net.NioEndpoint.Poller#run,while循環,讀取數據:

源碼位置:org.apache.tomcat.util.net.AbstractEndpoint#processSocket,將封裝好的請求交給請求線程池處理:

關于tomcat線程池有一個點要注意,它和jdk不一樣的是,它是先開啟核心線程,當任務超過核心線程數,就繼續開啟至最大線程數,如果還超過才進入等待隊列。
水落石出
通過上面的分析,讓我們回到問題出現時的這張圖

可以看到,Acceptor和Poller線程消失了!
這樣我們現象就很好解釋了,Acceptor沒有拿新的連接來處理了,此時連接在系統層面積壓,tomcat請求處理線程空閑。
我們重啟后再執行一下thread命令,正常的是:

從Acceptor源碼上看,它捕獲了異常,但對于OOM,選擇重新拋出,Acceptor線程就中斷了,可見OOM對于tomcat來說是個致命異常,一旦程序有此類報錯,需要優化,否則可能導致整個服務異常。
且Acceptor和Poller線程拋出這個位置在打印日志之前,所以也看不到錯誤日志,這點似乎不太好,但最新的tomcat版本也是保持如此。


如果要獲得這個日志,我們也可以通過Thread的全局異常來捕獲:
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
if (t.getName().equals("http-nio-8100-Acceptor")) {
log.error("tomcat Acceptor error", t);
}
});
總結
OOM異常對于tomcat服務來說是致命的,發現即需要處理。
對于內存泄漏來說,留有時間給我們dump內存分析。但對于內存溢出來說,由于其會回收,可能在某個時間OOM,順便把tomcat打掛了,然后就回收了,此時我們去dump也未必有用,所以-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath 參數是很有必要需要加上的。
更多分享,歡迎關注我的github:
