值得閱讀的內存洩露分析總結和tomcat調優

寫在最前面,運行環境:tomcat8,jdk1.8,windows server 2008內存16G,軟件LoadRunner11,MAT和JProfile9.1。

問題描述:前段時間遇到一個很奇怪的問題,開發的WEB應用,經常會毫無症狀的宕掉,然後抓了線程棧看下,發現之前寫的數據庫鏈接池出現了阻塞的問題,後面分析代碼發現同步鎖那個地方有一些問題,出現異常可能導致鎖不釋放,造成堵塞,然後其他線程全block住了,然後應用卡住了,最後就掛了。後面換成了開源的DBPC連接池,獲取數據庫鏈接卡住的問題就解決了。但是又發現了一個新問題,用LoadRunner做壓力測試時發現tomcat佔用的內存持續上升,壓了一段時間停了再繼續壓,tomcat佔用內存不會釋放,繼續往上漲,第一反應就是懷疑存在內存洩露,於是繼續往下研究。

由於之前毫無分析內存洩露的經驗,對JVM的內存分配和回收機制也不算了解,純小白一個,所以只能看《深入理解Java虛擬機》和從網上查各種資料。

Part1.JVM內存組成介紹

這裡先介紹一下JVM的內存組成,如下圖所示:

值得閱讀的內存洩露分析總結和tomcat調優

JVM 將內存區域劃分為 MethodArea(Non-Heap)(方法區),Heap(堆),Program Counter Register(程序計數器), VM

Stack(虛擬機棧,也有翻譯成JAVA 方法棧的),Native Method Stack (本地方法棧),其中Method Area和Heap是線程共享的,VMStack,Native

Method Stack 和Program Counter Register是非線程共享的。

那我們的程序是怎麼在這些內存上運行的呢,概括地說來,JVM初始運行的時候都會分配好Method Area(方法區)Heap(堆),而JVM 每遇到一個線程(當前情景下WEB應用前臺的一個數據請求發送到後臺對應就是啟動了一個線程),就為其分配一個Program Counter Register(程序計數器),VM

Stack(虛擬機棧)和Native Method Stack (本地方法棧),當線程終止時,三者(虛擬機棧,本地方法棧和程序計數器)所佔用的內存空間也會被釋放掉。非線程共享的那三個區域的生命週期與所屬線程相同,而線程共享的區域與JAVA程序運行的生命週期相同,所以這也是系統垃圾回收的場所只發生在線程共享的區域(實際上對大部分虛擬機來說知發生在Heap上)的原因。

1.程序計數器

2.VM Strack

先來了解下JAVA指令的構成:

JAVA指令由 操作碼 (方法本身)和 操作數 (方法內部變量) 組成,其實底層都是體系結構和組成原理裡面學的東西。

1)方法本身是指令的操作碼部分,保存在Stack中;

2)方法內部變量(局部變量)作為指令的操作數部分,跟在指令的操作碼之後,保存在Stack中(實際上是簡單類型(int,byte,short 等)保存在Stack中,對象類型在Stack中保存地址(相當於指針裡面的地址),在Heap 中保存值);

虛擬機棧也叫棧內存,是在線程創建時創建,它的生命期是跟隨線程的生命期,線程結束棧內存也就釋放,對於棧來說不存在垃圾回收問題,只要線程一結束,該棧就 Over,所以不存在垃圾回收。也有一些資料翻譯成JAVA方法棧,大概是因為它所描述的是java方法執行的內存模型,每個方法執行的同時創建幀棧(Strack Frame)用於存儲局部變量表(包含了對應的方法參數和局部變量),操作棧(Operand Stack,記錄出棧、入棧的操作),動態鏈接、方法出口等信息,每個方法被調用直到執行完畢的過程,對應這幀棧在虛擬機棧的入棧和出棧的過程。

局部變量表存放了編譯期可知的各種基本數據類型(boolean、byte、char、short、int、float、long、double)、對象的引用(reference類型,不等同於對象本身,根據不同的虛擬機實現,可能是一個指向對象起始地址的引用指針,也可能是一個代表對象的句柄或者其他與對象相關的位置)和 returnAdress類型(指向下一條字節碼指令的地址)。局部變量表所需的內存空間在編譯期間完成分配,在方法在運行之前,該局部變量表所需要的內存空間是固定的,運行期間也不會改變。

棧幀是一個內存區塊,是一個數據集,是一個有關方法(Method)和運行期數據的數據集,當一個方法 A 被調用時就產生了一個棧幀 F1,並被壓入到棧中,A 方法又調用了 B 方法,於是產生棧幀 F2 也被壓入棧,執行完畢後,先彈出 F2棧幀,再彈出 F1 棧幀,遵循“先進後出”原則。如下圖所示:

值得閱讀的內存洩露分析總結和tomcat調優

3.Heap

Heap(堆)是JVM的內存數據區。Heap 的管理很複雜,是被所有線程共享的內存區域,在JVM啟動時候創建,專門用來保存對象的實例。在Heap 中分配一定的內存來保存對象實例,實際上也只是保存對象實例的屬性值,屬性的類型和對象本身的類型標記等,並不保存對象的方法(以幀棧的形式保存在Stack中),在Heap 中分配一定的內存保存對象實例。而對象實例在Heap 中分配好以後,需要在Stack中保存一個4字節的Heap 內存地址,用來定位該對象實例在Heap 中的位置,便於找到該對象實例,是垃圾回收的主要場所。java堆處於物理不連續的內存空間中,只要邏輯上連續即可。下面我們還會著重介紹一下這塊區域。

4.Method Area

Object Class Data(加載類的類定義數據) 是存儲在方法區的。除此之外,常量、靜態變量、JIT(即時編譯器)編譯後的代碼也都在方法區。正因為方法區所存儲的數據與堆有一種類比關係,所以它還被稱為 Non-Heap。方法區也可以是內存不連續的區域組成的,並且可設置為固定大小,也可以設置為可擴展的,這點與堆一樣。

垃圾回收在這個區域會比較少出現,這個區域內存回收的目的主要針對常量池的回收和類的卸載。

5.運行時常量池(Runtime Constant Pool

方法區內部有一個非常重要的區域,叫做運行時常量池(Runtime Constant Pool,簡稱 RCP)。在字節碼文件(Class文件)中,除了有類的版本、字段、方法、接口等先關信息描述外,還有常量池(Constant Pool Table)信息,用於存儲編譯器產生的字面量和符號引用。這部分內容在類被加載後,都會存儲到方法區中的RCP。值得注意的是,運行時產生的新常量也可以被放入常量池中,比如 String 類中的 intern() 方法產生的常量。

常量池就是這個類型用到的常量的一個有序集合。包括直接常量(基本類型,String)和對其他類型、方法、字段的符號引用.例如:

類和接口的全限定名;

字段的名稱和描述符;

方法和名稱和描述符。

池中的數據和數組一樣通過索引訪問。由於常量池包含了一個類型所有的對其他類型、方法、字段的符號引用,所以常量池在Java的動態鏈接中起了核心作用。

6.NativeMethod Stack

與VM Strack相似,VM Strack為JVM提供執行JAVA方法的服務,Native

Method Stack則為JVM提供使用native 方法的服務。

7.直接內存區

直接內存區並不是 JVM 管理的內存區域的一部分,而是其之外的。該區域也會在 Java 開發中使用到,並且存在導致內存溢出的隱患。如果你對 NIO 有所瞭解,可能會知道 NIO 是可以使用 Native Methods 來使用直接內存區的。

Part2.Heap(堆)和CMS垃圾回收算法

下面我們要詳細分析一下Heap,Heap(堆)又可以細分成三部分,Old Gen(老年堆),Eden Space(年輕堆也叫伊甸園),Survivor Space(S0+S1)。我們可以通過配置參數控制Heap的大小,具體設置在後面調優會講。當程序運行時,大多數情況new的一些對象,最開始都會存放Par Eden Space,然後多次回收(Young GC)之後仍然存活的對象就會挪到CMS Old Gen(老年堆)。需要注意的是除此之外,大的數組對象且對象中無外部引用的對象,和通過啟動參數設置的-XX:PretenureSizeThreshold=1024(字節)

,超過這個大小的對象都會直接分配到CMS Old Gen(老年堆)。下面我們要講的垃圾回收算法就是發生在這個地方。在我們應用環境中,由於我們配置了CMS GC(併發GC)的回收方法,所以對Eden Space使用的GC算法默認就是ParNew(並行GC)。這裡供Par Eden Space和Old Gen選擇的GC算法有很多種,可以根據自己的環境選擇,一般多核CPU都會選擇CMS(併發GC),這樣更高效。

CMS執行過程可以分成:初始標記,併發標記,併發預處理,重標記,併發清理,重置六個階段,這裡需要注意的是初始標記和重標記兩個階段是需要Stop-the-world,其他階段都是和程序其他進程併發執行的,System.gc()調用的Full GC的整個過程都是Stop-the-world,這也是為什麼說CMS是對系統影響最小的垃圾回收方法。

初始標記:該階段進行可達性分析,標記GC ROOT可以直接關聯的對象。注意這裡是直接關聯,間接關聯的將在第二階段進行標記。那麼什麼可以作為GC ROOT呢,一般是:①虛擬機棧中的引用對象。②方法區中類靜態屬性引用的對象③方法區中常量引用對象④本地方法棧中JNI引用對象。

併發標記階段:該階段進行GC ROOT

Tracing(大家可以把這個想象成由一個Root構成的樹,樹上除了Root節點,存在引用關係的其他節點到Root都有可達路徑。),在第一階段被暫停的線程全部恢復執行,然後從上一階段mark的對象出發,對所有可達的對象進行標記。

併發預處理:這一步就是CMS算法的精髓所在,因為CMS是以獲取最短的停頓時間為目的的GC算法。在mark和remark兩個階段都需要Stop-the-world,所以併發預處理的目的就是提前做一些remark做的事情,減短remark階段的耗時。這一階段,將標記從Eden Space晉升的對象、從Eden Space分配到Old Gen的對象,以及在併發標記階段被修改的對象。怎麼確定一個對象是否存活,即通過追蹤GC ROOT Tracing有可達路徑的對象就是活著的。舉個例子吧,就比如說一個在Old Gen中存在對象B,在併發標記階段沒被標記成alive,眼看就要小命不保了,就在這個時候程序進程又New了一個對象A,此時A對象又引用了Old Gen中的B對象(因為併發標記階段並不是Stop-the-world,所以程序進程和標記進程是併發執行的)。那麼這個對象B就不應該被回收掉,因為被A撈了一把,手牽手進入了GC ROOT Trace。這個B在併發預處理階段就會被標記成alive。

重標記:這個階段也是要Stop-the-world的,重新掃描堆中的對象,再次進行可達性分析,標記alive的對象。

併發清理:重新激活用戶線程,然後清理哪些dead Objects(不存在引用的對象)。

重置:CMS清楚內部狀態,準備下一次回收。

為了更好地說明CMS回收的過程,這裡貼一段實際場景中的GC日誌:

-----------------------------------------初始標記(Stop-the-world)---------------------------------------------

135140.215: [GC (CMS Initial Mark) [1CMS-initial-mark: 195002K(3375104K)]207465K(3989504K), 0.0053961 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

-----------------------------------------併發標記---------------------------------------------

135140.221: [CMS-concurrent-mark-start]135140.287: [CMS-concurrent-mark:0.066/0.066 secs] [Times: user=0.50 sys=0.00, real=0.07 secs]

-----------------------------------------併發預處理---------------------------------------------

135140.287: [CMS-concurrent-preclean-start]135140.295: [CMS-concurrent-preclean:0.009/0.009 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]

-----------------------------------------重標記(Stop-the-world)---------------------------------------------

135140.298: [GC (CMS Final Remark) [YG occupancy: 13058 K (614400 K)]//這裡最前面的135140.298是JVM運行時間,單位是S。YG就是Young Gen(Eden Space),前面的數字是佔用大小,括號裡是總大小

135140.298: [Rescan (parallel) , 0.0071866

secs]//這裡要對Young Gen重新掃描

135140.305: [weak refs processing,0.1143667 secs]135140.420: [class unloading, 0.1829570secs]135140.603: [scrub symbol table, 0.0194112secs]135140.622: [scrub string table, 0.0019222secs][1 CMS-remark: 195002K(3375104K)] 208060K(3989504K), 0.4727087 secs][Times: user=0.47 sys=0.00, real=0.47 secs] //這裡195002K(3375104K)表示的是Old Gen的使用情況,208060K(3989504K)是整個Heap的使用情況

-----------------------------------------併發清理---------------------------------------------

135140.771: [CMS-concurrent-sweep-start]135140.845: [CMS-concurrent-sweep:0.073/0.073 secs] [Times: user=0.09 sys=0.03, real=0.07 secs]

-----------------------------------------重置---------------------------------------------

135140.850: [CMS-concurrent-reset-start]135140.856: [CMS-concurrent-reset:0.006/0.006 secs] [Times: user=0.01 sys=0.02, real=0.01 secs] 。

關於CMS算法的優缺點,還有具體實現的的一些細節,這裡就不做過多敘述了,有興趣的可以自行查閱資料。

Part3.MAT分析工具和Jprofile分析工具

一頓操作猛如虎,基本瞭解了JVM的內存的組成和垃圾回收相關的基礎信息。然後就要來分析一下WEB應用到底問題出在哪了。工欲善其事,必先利其器,然後我就下載了MAT和Jprofile。

MAT是專門用來分析內存dump文件的工具,需要有Eclipse才能跑。先用jmap指令可以抓到dump文件,具體指令格式如下:

jmap-dump:format=b,file=output pid

pid就是你的java進程id,需要注意的是使用這個方法抓dump時,tomcat要用startup.bat去啟動,如果以服務的方式啟動,這個指令會報錯,可能是權限問題。這裡有個要注意的,抓Heap dump時會先進行垃圾回收,再生成dump文件。

直接用MAT打開Dump文件,然後工具還會幫你生成一份分析報告,告訴你可能存在內存洩露的地方。廢話不多說,上圖,下圖是對內存洩露的分析報告,點進去可以看到詳情信息

值得閱讀的內存洩露分析總結和tomcat調優

MAT最重要的功能就是可以分析Heap裡的Object、類和引用關係。

下圖列出了Heap中所有的對象,這裡是以Class的方式展示的,然後這裡末尾兩列需要關注一下,Shallow Heap是自身在Heap裡面佔用的大小,Retained Heap是引用的對象總工佔用的大小,單位都是字節。因為一開始就是懷疑代碼存在內存洩露,擔心代碼裡面定義的靜態變量佔用內存太多。剛開始看到String類型佔用了大部分堆內存,然後進去可以看到String類裡面由哪些對象引用佔用了。

值得閱讀的內存洩露分析總結和tomcat調優

下圖是查看String類的所有引用對象,然後點最後一個Retained Heap可以按從大到小排列,可以看到,最大的那幾個果然是我自己代碼裡面定義的靜態變量,圖中圈紅的地方就是類的堆棧地址,因為這個靜態緩存的類是單例實現的,所以在這裡出現這個類名的地方後面跟的堆棧地址都是一樣的。

值得閱讀的內存洩露分析總結和tomcat調優

MAT還支持查詢,根據類名,查詢某個地址上的對象,下圖就是通過地址查找某個對象。

值得閱讀的內存洩露分析總結和tomcat調優

通過一些分析我發現之前擔心靜態變量太多導致內存佔用太多其實是多慮了,因為緩存的那幾個靜態變量都只存了一份,並且他們的Retained Heap也不大,對整個內存沒多大影響。雖然對內存洩露分析沒什麼進展但是還是有一些發現,我看到了很多在代碼中手動調用logger.info()打印的日誌信息都存在堆中。這些logger.info當初都是為了調試或者分析問題時添加的,後面也就放著沒刪,有一些打印輸出的內容還挺大,這些後面都會佔用Heap。

MAT還有個很重要的騷操作就是可以添加兩個Dump來對比,現在回想我覺得這個其實就可以確定是否存在內存洩露。抓Dump時要注意的是抓兩個Dump中間最好要有一定的時間間隔,這個時間間隔中最好應用要經過一定的壓力測試。下圖就是兩個Dump的對比圖,第二個Dump就是我用LoadRunner壓了一晚上之後再抓的。這裡#0就是第二個Dump,#1就是第一個Dump。可以發現,一個晚上的壓力測試之後,在GC之後生成的Heap Dump文件裡面Shallow Heap的大小變化不是很大,Object的數量變化也不是很大,說明了GC對Heap裡面對象回收狀況差不多,如果存在內存洩露,存在不能被回收的對象,那第二個Dump文件裡面應該會出現比第一個Dump文件大很多的類和Object,可是經過這麼長時間的壓力測試,並沒有出現這種扎眼球的對象和類,所以基本可以斷定應用不存在內存洩露的狀況。

值得閱讀的內存洩露分析總結和tomcat調優

用MAT分析後感覺還是有點不確定,然後又用Jprofile實時監控了一下JVM狀態,不得不說這個工具真的很強大,可以監控本地的JVM進程,也可以監控遠程的JVM進程,監控內容從內存,對象,GC到線程,CPU,數據庫連接狀態覆蓋面很廣。

具體的使用有興趣的可以去下載下來玩玩,下圖是內存監控的視圖,這裡支持對整個Heap的監控,也可以分開監控Eden Space和Old Gen。從這個回收視圖可以看出對Eden Space的回收基本每次都可以很徹底(主要看波谷有多低),如果存在內存洩露的情況,不會每次回收都能觸及波谷,而且波谷會慢慢升高,因為內存洩露會導致一些對象無法被回收,而且隨著軟件運行時間和壓力增大,洩露的對象會慢慢積累,所以GC完之後藍色顯示Used size是不可能達到最低點幾乎為0的大小。

值得閱讀的內存洩露分析總結和tomcat調優

下面這張圖是對GC狀態的監控

值得閱讀的內存洩露分析總結和tomcat調優

分析到這裡基本可以確定應用不存在內存洩露的情況。然後用這個軟件也有一些其他發現。它有個線程分析視圖可以抓取到線程的狀態,主要是查看壓力測試下線程阻塞的狀態,我發現很多線程都block在寫日誌文件的地方,各進程間對日誌文件的寫操作肯定是互斥的,一次只允許一個進程對日誌文件進行寫操作,同一時間如果後臺有幾百個進程同時需要對日誌文件進行寫操作,這時就進入了阻塞狀態,如下圖所示:

值得閱讀的內存洩露分析總結和tomcat調優

從之前的MAT分析Heap中對象中也發現很多打印的日誌數據都存在Heap中,到現在看到這麼多線程阻塞在log4j的地方,控制好日誌的輸出對高併發的WEB應用影響還是挺大的。

除了視圖還可以監控數據庫連接的情況,事務的完成時間,連接池的狀況,連接串的狀況,還可以根據一些篩選條件進行篩選,功能十分強大。

最後再放一張總的監控視圖:

值得閱讀的內存洩露分析總結和tomcat調優

由於這個兩個軟件都是臨時下載初次使用,可能還有很多強大的功能沒嘗試,以後還可以繼續研究研究。

雖然對內存洩露的研究有了明確的結果,可是tomcat佔用內存持續升高得不到釋放的問題還是沒有答案。現在通過監控軟件可以明確看到Heap的內存使用回收很正常,可是通過任務管理器監控看到的Tomcat佔用內存卻只升不減。

Tomcat是Java寫的,運行在JVM之上,所以tomcat的使用的堆內存大小是不可能超過JVM定義的堆大小。所以Jprofile監控的Heap使用情況和從任務管理器看到Tomcat使用內存肯定不完全一樣,除了堆內存肯定還有之外的內存。那麼還有什麼內存呢?上面介紹內存組成時有個直接內存,當發現還有直接內存這個東西時,感覺發生了新大陸一樣,隱隱約約感覺問題的關鍵就在這裡。

Part4.直接內存和NIO

又是一頓查資料瞭解直接內存和NIO相關的內容。根據官方文檔的描述:

A byte buffer is either direct ornon-direct. Given a direct byte buffer, the Java virtual machine will make abest effort to perform native I/O operations directly upon it. That is, it willattempt to avoid copying the buffer's content to (or from) an intermediatebuffer before (or after) each invocation of one of the underlying operatingsystem's native I/O operations.

byte byffer可以是兩種類型,一種是基於直接內存(也就是非堆內存);另一種是非直接內存(也就是堆內存)。

對於直接內存來說,JVM將會在IO操作上具有更高的性能,因為它直接作用於本地系統的IO操作。而非直接內存,也就是堆內存中的數據,如果要作IO操作,會先複製到直接內存,再利用本地IO處理。

從數據流的角度,非直接內存是下面這樣的數據鏈:

本地IO-->直接內存-->非直接內存-->直接內存-->本地IO

而直接內存是:

本地IO-->直接內存-->本地IO

很明顯,再做IO處理時,比如網絡發送大量數據時,直接內存會具有更高的效率。

NIO(New IO)是基於基於通道(Channel)和緩衝區(Buffer)進行操作,數據總是從通道讀取到緩衝區,或者從緩衝區寫到通道中。具體信息有興趣可以自行上網查。

這個時候突然看到一個帖子裡的回覆說Tomcat8默認通信方式就是採用NIO方式,這個時候感覺看到希望之光了,立馬就去看哪裡用到了了NIO。後來在tomcat-coyote.jar中找到了,這個coyote是用來處理Tomcat底層的socket,並將http請求、響應等字節流層面的東西,包裝成Request和Response兩個類,供容器使用。所以意味著每一個前端請求都會經過這個處理了。下圖源碼中被圈中的代碼就是分配直接內存的代碼。

值得閱讀的內存洩露分析總結和tomcat調優

我們再點進去可以看到在jdk的源碼中分配直接內存有個reserveMemory的函數,在每次分配直接內存的時候都會執行這個清理函數,然後再點進去有個大發現,在這個清理內存函數里面居然有手動調用gc的代碼。

值得閱讀的內存洩露分析總結和tomcat調優

值得閱讀的內存洩露分析總結和tomcat調優

這個時候靈光一閃突然回想起我們的tomcat的JVM配置參數裡面好像有一個是忽略代碼裡面調用system.gc()的配置參數。因為這個tomcat容器是被其他單位下發的已經做過優化的,所以裡面有一堆的配置參數。其中被我標綠的這個-XX:DisableExplicitGC的作用就是不響應代碼裡面手動調用system.gc(),看下圖:

值得閱讀的內存洩露分析總結和tomcat調優

上面我們可以看到一堆的配置,裡面配置含義我們後面再講。這個時候我以為我已經找到了問題的本質,由於direct Memory在堆外,所以對young gen 的gc過程中是不會回收的。JVM只會在old gen GC(full GC/major GC或者concurrent GC都算)的時候才會對old gen中的對象做reference processing,而在young GC時只會對young gen裡的對象做reference processing。也就是說,做full GC的話會對old gen做reference processing,進而能觸發Cleaner對已死的DirectByteBuffer對象做清理工作。而如果很長一段時間裡沒做過GC或者只做了young GC的話則不會在old gen觸發Cleaner的工作,那麼就可能讓本來已經死了的、但已經晉升到old gen的DirectByteBuffer關聯的direct Memory得不到及時釋放,這麼分析看來這裡就是問題的根本了。。。

那我們直接把那個參數刪掉不就好了嗎,我想了想自己的代碼裡面也沒寫過system.gc所以應該影響不大,然後就果斷去掉了那個參數順手也設置了個直接內存大小,配置參數是-XX:MaxDirectMemorySize。這個直接內存不設置時,默認大小是最大堆大小,看下圖源碼。

值得閱讀的內存洩露分析總結和tomcat調優

這麼修改完之後又開始做壓力測試,這次看到內存很穩定,測試二十多個小時,內存基本增長到兩個多G就沒再漲了,心情相當開心。然後我再去檢查GC日誌發現果然出現了很多system.gc的日誌,這個之前都是沒見過的,看下圖:

值得閱讀的內存洩露分析總結和tomcat調優

本以為這次分析到這就結束了,可是沒想到後面還有新發現,把我本以為下了定論的答案又推翻了。。。有點自己打自己臉的感覺‍♂️

Part5.Tomca配置和調優

在分析GC日誌的時候,我看到壓力測試中GC日誌中Full gc的次數有點頻繁,而且這種Full gc是Stop-the-world的,很影響應用的響應時間,從GC日誌中可以看到基本一次Full GC耗時要一秒多,頻率高的話很影響性能。然後我又開始搜資料,搜資料的過程中發現Tomcat8默認用NIO是指在linux

服務器下,而我是在Windows的服務器上跑的。。。然後我發現Tomcat支持三種接收請求的模式,分別是:BIO,NIO,APR,其中NIO就是我們上面提到的在linux服務器上默認的模式。網上有人對這三種模式分別作了性能測試,發現APR模式是三種模式裡面性能最好的,這種方式是從操作系統級別解決異步IO問題,也是Tomcat運行高併發應用的首選。但是開啟比較麻煩,需要一些額外的jar包,有興趣的也可以自行查資料瞭解一下。我上面也提到了我這裡用的Tomcat是經過優化的,然後我打開server.xml看了一眼,結果兩眼一黑,我用的Tomcat疑似採用的就是APR這種模式,因為我看到Server.xml中包含這麼一句配置:

值得閱讀的內存洩露分析總結和tomcat調優

然後為了確定我這個猜測我又去看了啟動日誌,在啟動日誌中看到了:

值得閱讀的內存洩露分析總結和tomcat調優

這不赤裸裸的告訴我們開啟了APR的模式嘛。。。這就意味著上面根據Tomcat8默認NIO模式用到了直接內存,得出的關於我的應用部署的Tomcat為什麼佔用內存持續上升的結論是不成立的!雖然Tomcat確實有NIO的模式,NIO也確實會用到直接內存,分配直接內存時確實會手動調用system.gc(),然後tomcat裡面配置-XX:DisableExplicitGC確實會影響內存分配導致直接內存堆積,可是和我這並沒什麼關係啊。。此時心裡萬馬奔騰,但我冷靜一想當我去掉-XX:DisableExplicitGC時,GC日誌裡面出現了很多Full GC的日誌,那不是因為分配直接內存引起的還有誰再調用呢。沒辦法,只能一直手動抓線程棧來分析,用下面的指令就可以把當前線程棧輸出到一個txt文檔中。

jstack-l pid>C:\Users\Administrator\Desktop\log\ThreadStack.txt

這個pid就是你的java線程id。果然抓了幾次就被我抓到了現場,線程棧中果然有個線程在執行gc操作,看下圖:

值得閱讀的內存洩露分析總結和tomcat調優

這次感覺自己應該是找到了問題本質了,光看這個也看不出什麼然後上網搜了下,發現也是Tomcat配置引起的,真相只有一個就是下面這個配置:

值得閱讀的內存洩露分析總結和tomcat調優

這個看名字就知道是用來檢測是否存在內存洩露的,後面看到Tomcat管理頁面自帶一個Find Leaks的功能,不知道是不是和這個有關係。然後我進這個類看了下就有了新發現,在這個類裡面我看到GC日誌裡面執行system.gc的那個類名!看下圖:

值得閱讀的內存洩露分析總結和tomcat調優

這裡這個gcDaemonProtection的參數在這個類的上面已經定義了,默認是true。這就意味著如果不手動修改配置文件,肯定會進這個判斷。裡面用反射調用了sun.misc.GC的requestLatency方法。我點進這個sun.misc.GC類裡面,看到了GC日誌裡面那個run的地方,看下圖:

值得閱讀的內存洩露分析總結和tomcat調優

看名字這是一個守護進程,裡面調用了system.gc(),這下可以肯定的是GC日誌裡頻繁出現的Full GC操作就是這裡引起的(後面我又抓了很多次線程棧發現調用GC的只有這一個類)。到這裡我終於可以確定為什麼GC日誌裡面那麼多Full GC了,都是因為Tomcat配置裡面加載這個內存檢測的Class導致的。那有什麼辦法可以避免這個呢,後來網上查了下,有這麼幾種方法:

① 直接去掉這個配置

② 將上面那個默認配置true的參數改成false,將Server.xml裡面的對應那條配置中增加下面的一段:

gcDaemonProtection=”false”

③ 增加-XX:+ExplicitGCInvokesConcurrent配置,這個參數不會像DisableExplicitGC一樣強行忽略手動調用system.gc,而是在遇到調用system.gc時調用CMS垃圾回收方法。因為上面提過CMS是停頓最短的GC方法,這樣就可以避免由full

GC帶來的長GC pause引起的性能問題。

經過測試我是採用的第三個方法,到最後內存增長的問題得到了解決。最後我們看一下GC日誌中CMS的耗時,看下圖:

值得閱讀的內存洩露分析總結和tomcat調優

這裡被我用紅線劃得就是mark和remark的兩個階段,因為這有這兩個階段是Stop-the-world的,可以看到耗時和Full GC比起來要短很多。

最後Tomcat的JVM配置參數被我修改為下圖:

值得閱讀的內存洩露分析總結和tomcat調優

這裡面有幾個比較重要好用的我大概說一下:

-Xloggc:gc.log -XX:+PrintGCDetails,這個參數會設置打印GC日誌,這次問題,靠GC日誌分析出了很多有用的東西。

-XX:+UseConcMarkSweepGC ,選用CMS作為垃圾回收方法

-XX:+ ExplicitGCInvokesConcurrent,用這個替換了DisableExplicitGC,每次遇到system.gc時調用一次CMS回收,並不是直接Stop-the-world。

-XX:+UseCMSCompactAtFullCollection,在每次CMS收集器在完成垃圾回收之後做一次內存碎片整理。

Tomcat的線程池也是可以自己配置的,包括可接受的連接數之類的,這裡我就不展開說了,碼字也不輕鬆…

其他還有很多有興趣的可以自行了解。

總結

這次遇到這個奇怪的問題到解決查了很多資料也收穫了很多新知識,特別是發現Full GC並不是直接內存引起而是因為另一個配置導致的時候,有種柳暗花明又一村的感覺。其實真正需要做的改動只是增加了兩個配置參數,刪除了一個配置參數,但需要了解的東西卻十分龐雜,性能調優涉及的東西太多了,這次很多東西這次只是淺淺的接觸瞭解了一下,還需要繼續努力。這次解決完問題感覺很有必要寫下來,一個是擔心以後忘了,還有就是順便鍛鍊一下自己的總結能力。有時候你會發現,你很抗拒的事情在等你完成之後回頭看也不過如此。最後,與君共勉。


分享到:


相關文章: