問題發現:
在我們運行的一個項目上線運營后發現運行兩天左右就會報內存溢出,只有重啟tomcat才能恢復服務,異常信息如下:
java.lang.OutOfMemoryError: GC overhead limit exceeded
java.lang.OutOfMemoryError: Java heap space
原因分析:
在此之前必須先介紹一下關於jvm的內存控制,JVM即java虛擬機,它運行時候占用一定的內存,其大小是有限定的,如果程序在運行時jvm占用的內存大於某個限度,則會產生內存溢出,也就是“java.lang.outofmemoryerror”。如果jvm內存的沒有限度,並且有無限大的內存,那jvm就永遠不會出現內存溢出了。很明顯無限的內存是不現實的,但是一般情況下我們程序運行過程所需要的內存應該是一個基礎固定的值,如果僅是因為我們的項目所需內存超過了jvm設置內存值導致內存溢出,那么我們可以通過增大jvm的參數設置來解決內存溢出的問題。詳細處理可參考java jvm的如下參數設置:-Xms -Xmx -Xmn -Xss
-Xms: 設置JVM初始內存,此值可以設置與-Xmx相同,以避免每次垃圾回收完成后JVM重新分配內存。
-Xmx:設置JVM最大可用內存。
-Xmn:設置年輕代大小,整個堆大小=年輕代大小+年老代大小+持久代大小.持久代一般固定大小為64m,所以增大年輕代后,將會減小年老代大小.此值對系統性能影響較大,Sun官方推薦配置為整個堆的3/8.
-Xss:設置每個線程的堆棧大小.在相同物理內存下,減小這個值能生成更多的線程.但是操作系統對一個進程內的線程數還是有限制的,不能無限生成。
在jvm參數調試過程中,發現分配最大內存數超過1G后,仍然會產生內存溢出的現象,而估計其正常分配使用的內存應該不會超過1G,那么由此可以基本斷定其存在內存泄露現象,也就是一些原來分配的不再使用的內存不能被java的垃圾回歸所回收,導致不斷占用原分配的內存而不釋放,導致不斷申請更多的內存直到超過內存設置而導致內存溢出。
內存泄露的基本原理:
在C++語言程序中,使用new操作符創建的對象,在使用完畢后應該通過delete操作符顯示地釋放,否則,這些對象將占用堆空間,永遠沒有辦法得到回收,從而引起內存空間的泄漏。如下的簡單代碼就可以引起內存的泄漏:
void function(){ |
在function()方法執行完畢后,vec數組已經是不可達對象,在C++語言中,這樣的對象永遠也得不到釋放,稱這種現象為內存泄漏。
而Java是通過垃圾收集器(Garbage Collection,GC)自動管理內存的回收,程序員不需要通過調用函數來釋放內存,但它只能回收無用並且不再被其它對象引用的那些對象所占用的空間。在下面的代碼中,循環申請Object對象,並將所申請的對象放入一個Vector中,如果僅僅釋放對象本身,但是因為Vector仍然引用該對象,所以這個對象對GC來說是不可回收的。因此,如果對象加入到Vector后,還必須從Vector中刪除,最簡單的方法就是將Vector對象設置為null。
Vector v = new Vector(10); for (int i = 1; i < 100; i++){ Object o = new Object(); v.add(o); o = null; }//此時,所有的Object對象都沒有被釋放,因為變量v引用這些對象。 |
實際上無用,而還被引用的對象,GC就無能為力了(事實上GC認為它還有用),這一點是導致內存泄漏最重要的原因。
而我們的項目可能是存在着內存泄露問題而導致內存溢出。
解決過程:
如何查找引起內存泄漏的原因呢?一般有兩種思路:第一種,安排有經驗的編程人員對代碼進行走查和分析,找出內存泄漏發生的位置;第二種,就是利用一些內存檢查分析工具來分析,找出內存泄露的具體位置可以快速解決。
軟件測試的理論告訴我們,系統中永遠存在一些沒有暴露出來的問題,而且,系統的穩定性問題也不僅僅只是內存泄漏的問題,代碼走查是提高系統的整體代碼質量乃至解決潛在問題的有效手段。但在此僅總結一下本次問題解決所應用的有關內存檢查和分析命令以及工具的相關介紹。
第一階段 通過jdk的GC輸出進行測試
可以在 JAVA_OPTS增加以下參數打開jdk的GC輸出日志:
-verbose:gc -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
打開輸出日志,jdk會在每一次的垃圾回收時打印相關日志,參考格式如下:
[GC [<collector>: <starting occupancy1> -> <ending occupancy1>, <pause time1> secs] <starting occupancy3> -> <ending occupancy3>, <pause time3> secs]
<collector>GC收集器的名稱
<starting occupancy1> 新生代在GC前占用的內存
<ending occupancy1> 新生代在GC后占用的內存
<pause time1> 新生代局部收集時jvm暫停處理的時間
<starting occupancy3> JVM Heap 在GC前占用的內存
<ending occupancy3> JVM Heap 在GC后占用的內存
<pause time3> GC過程中jvm暫停處理的總時間
例:[GC [PSYoungGen: 131072K->10667K(152896K)] 137699K->17295K(1551040K), 0.0210980 secs] [Times: user=0.06 sys=0.01, real=0.02 secs]
注意短時間內關注GC的內存回收日志是沒有什么作用的,重點需要關注的是Full級別的新生代和年老代的內存情況。
如下表中,發現年老代內存是不斷增長的,基本可以確定是年老代內存泄露,有許多長時間未使用的分配內存得不到回收。
[PSYoungGen: 6735K->0K(152896K)] [PSOldGen: 000000K->6627K(1398144K)] 6735K->6627K(1551040K) [PSPermGen: 17676K->17676K(262144K)], 0.1521720 secs] [Times: user=0.15 sys=0.01, real=0.16 secs] |
[PSYoungGen: 4754K->0K(153600K)] [PSOldGen: 201914K->86024K(1398144K)] 206668K->86024K(1551744K) [PSPermGen: 33065K->33065K(262144K)], 0.5517640 secs] [Times: user=0.56 sys=0.00, real=0.55 secs] |
[PSYoungGen: 5495K->0K(164096K)] [PSOldGen: 166076K->99609K(1398144K)] 171571K->99609K(1562240K) [PSPermGen: 33680K->33680K(262144K)], 0.5221250 secs] [Times: user=0.52 sys=0.00, real=0.52 secs] |
[PSYoungGen: 2584K->0K(164736K)] [PSOldGen: 194684K->76213K(1398144K)] 197268K->76213K(1562880K) [PSPermGen: 34027K->33173K(262144K)], 0.6575180 secs] [Times: user=0.66 sys=0.00, real=0.66 secs] |
[PSYoungGen: 928K->0K(161728K)] [PSOldGen: 144867K->100205K(1398144K)] 145795K->100205K(1559872K) [PSPermGen: 34264K->34264K(262144K)], 0.5328980 secs] [Times: user=0.53 sys=0.00, real=0.53 secs] |
[PSYoungGen: 1656K->0K(161984K)] [PSOldGen: 100205K->100602K(1398144K)] 101861K->100602K(1560128K) [PSPermGen: 34317K->34317K(262144K)], 0.4965370 secs] [Times: user=0.50 sys=0.00, real=0.50 secs] |
泄露的結果是確定的,但是內存泄露點到底在哪根據日志也無法確切的查看清楚,通過在測試機器上的測試也很難找到哪塊的業務操作才引起內存的增長。結果未能解決內存泄露問題。
第二階段 通過jmap命令
jmap命令可以獲得運行中的jvm的堆的快照,從而可以離線分析堆,以檢查內存泄漏,檢查一些嚴重影響性能的大對象的創建,檢查系統中什么對象最多,各種對象所占內存的大小等等
命令格式
jmap [options] pid -dump:[live,]format=b,file=<filename>
-dump堆到文件,live指明是活着的對象,file指定文件名,pid 是java進程id
通過這個命令可以查看系統內存情況,但受限於所展示的內存通過能夠保存到幾十兆以上的的文件內容,手工分析查看實在是太過辛苦,所以也未能解決。
第三階段 通過Eclipse Memory Analyzer 分析工具來分析
Eclipse Memory Analyzer是一種快速的,功能豐富的Java堆分析工具,以下簡稱MAT,可以幫助查找內存泄露,並減少內存消耗。 這個工具可以對由堆轉儲產生的數以億計的對象進行分析,一旦堆轉儲被解析,可以在打開他的一瞬間,立即得到保留大小的單一對象,提取記錄詳細的信息,查看為什么這些對象對象資料沒有被釋放掉。使用這些功能的報告,可以對這些對象進行跟蹤,找到內存泄露嫌疑人,也可以得到系統的性能指數,幫助優化系統。下面就來介紹一下如何使用該工具進行分析。
前面我們已經介紹了,可以通過jmap命令獲得運行中的jvm的堆快照,那么想利於該工具進行分析第一步仍然是獲得堆轉儲文件。具體命令如下:
jmap -dump:format=b,file=jmap.hprof 32460
注意,32460為java進程pid值,另外本分析工具支持的文件擴展名為hprof,所以將輸出文件名定為hprof,有了這個文件我們就可以通過本工具來分析他。
啟動Eclipse Memory Analyzer,是不是和eclipse開發平台非常類似?其實他還可做為eclipse的插件進行集成,在這就不詳細介紹了,有興趣的可以自己研究一下。
選擇菜單File-Open HeadDump然后選擇我們生成的堆轉儲文件
打開后他提示是否自動生成泄露檢測報告,我們選擇后,點finish完成
通過圖中,MAT給出了關於本次檢測有兩處占用內存較多的疑似泄露,下面是詳細說明
先從26%的嫌疑人suspect2看起,報告很直觀的給出系統中有13個關於數據庫鏈接的引用對象占用了59.83%的內存資源,我們還可以點擊details查看一下詳細情況。
可以看到這13個數據庫鏈接引用其實就是數據庫鏈接池的數據庫鏈接對象。而數據庫鏈接對象本身僅占用100K左右內存,因此不可能達到26M的內存占用量,所以基本可以斷定是數據庫鏈接占用了原查詢過程中的一些結果集等對象的引用,造成內存泄露問題。
分析數據庫鏈接池對象的相關代碼,未發現數據庫鏈接對象對其它對象的引用占用情況,后來通過一篇文檔資料中介紹如下:
在MySQL jdbc 5.1.6里,默認情況下,如果一個Connection永遠不掉用close,即使你每一個Statement, ResultSet都調用了close,仍然會有內存泄漏,換句話說,Statement的close沒有把自己的資源釋放干凈,Statement會在對應的Connection里有緩存
在我們的項目中采用了數據庫鏈接池的技術,我們的數據庫鏈接應用完成后不是馬上調用close方法關閉掉,而是返回給了數據庫鏈接池管理。所以鏈接池中的活動connettion對象中實際上持有了Statement的引用,造成內存泄露。
解決此問題方法就是對connetion對象進行聲明,不對statement對象做緩存,具體代碼如下:
java.sql.Connection conn = DriverManager.getConnection(strConnString);
//下面這句很重要,具體作用就是讓Statement每次close的時候通知Connection把緩存的Statement對象釋放掉,這樣就釋放干凈了
com.mysql.jdbc.Connection connMysql = (com.mysql.jdbc.Connection)conn;//強制轉換
connMysql.setDontTrackOpenResources(true);//這個接口是mysql特有的public函數
最終泄露的主要問題解決,另外補充一點一些輕量級別的泄露是需要長期的積累等其占用較大的資源的時候才能體現出來,所以監測與檢測需要時間慢慢積累逐步解決。