定位Java內(nèi)存泄漏

翻譯:叩丁狼教育吳嘉俊

經(jīng)驗不足的開發(fā)人員經(jīng)常會認為Java的自動垃圾回收機制會讓他們徹底的擺脫內(nèi)存管理的困擾。這是一個常見的錯覺,即使垃圾收集器盡了最大的努力,即使是最好的程序員,也可能成為內(nèi)存泄漏的犧牲品。容我慢慢道來。

內(nèi)存泄漏出現(xiàn)在當(dāng)對象已經(jīng)不需要了,但是對象仍然被異常的引用。這種泄漏會帶來嚴重后果,隨意舉一例,你的應(yīng)用會持續(xù)的要求更多的資源,而導(dǎo)致對你的服務(wù)器造成不必要的壓力。更糟糕的是,檢測這種溢出是非常困難的:靜態(tài)分析常常難以精確的識別這些冗余的引用,現(xiàn)有的內(nèi)存診斷工具產(chǎn)生的針對獨立對象的細粒度的診斷報告,也難以理解,并且缺乏精度。

換言之,內(nèi)存泄漏要么太難識別,要么使用起來過于專業(yè)。

內(nèi)存的問題可以分為4種類型,這四種類型的錯誤很相似,并且癥狀也有相似點,但是產(chǎn)生的原因和解決的方式完全不一樣。

  • 性能相關(guān): 通常出現(xiàn)在大量的對象創(chuàng)建和刪除,長時間的垃圾回收延遲,大量的操作系統(tǒng)內(nèi)存頁交換等情況。
  • 資源限制: 常常出現(xiàn)在內(nèi)存不足或內(nèi)存碎片太大而無法分配大對象時,常常發(fā)生在native memory,或者heap memory中;
  • Java堆泄漏: 最經(jīng)典的內(nèi)存泄漏場景,出現(xiàn)在Java對象持續(xù)被創(chuàng)建,但是并沒有被及時釋放。常常因為潛在的對象引用導(dǎo)致。
  • Native memory泄漏: 與Java Heap memory外的持續(xù)增長的內(nèi)存利用率相關(guān),例如JNI代碼、驅(qū)動程序或JVM分配所分配的。

在這篇文章中,我主要會聚焦在Java堆泄漏,并給出一種可行的方法去診斷這類內(nèi)存問題,這種方法基于JVM報告,并利用可視化工具在應(yīng)用運行中進行分析。

在介紹如何避免和排查內(nèi)存泄漏之前,你必須先理解為什么內(nèi)存泄漏會發(fā)生。

內(nèi)存泄漏:入門

對于初學(xué)者來說,可以這樣理解,內(nèi)存泄漏你可以理解為一種疾病,而類似Java的OutOfMemoryError(一般簡稱為OOM)看成一種癥狀。但是不是所有的OOM都意味著內(nèi)存泄漏:假如創(chuàng)建了巨大量級的本地變量,也會導(dǎo)致OOM,但這并不是內(nèi)存泄漏。另一方面,并不是所有的內(nèi)存泄漏都會導(dǎo)致OOM異常,特別是在桌面應(yīng)用或者客戶端應(yīng)用中(運行時間不會很長,就會重啟,所以可能出現(xiàn)內(nèi)存泄漏,但是不會表現(xiàn)為OOM)。

為什么內(nèi)存泄漏如此惹人厭惡?拋開其他的不說,內(nèi)存泄漏會讓系統(tǒng)隨著時間的延長,性能直線降低。因為系統(tǒng)的物理內(nèi)存一旦使用耗盡,就會導(dǎo)致物理內(nèi)存交換。最終,應(yīng)用可能耗盡分配的虛擬內(nèi)存,導(dǎo)致OOM產(chǎn)生。

破解 OutOfMemoryError

上面已經(jīng)說到,OOM是一個常見的內(nèi)存泄漏的表現(xiàn)。大部分情況下,該錯誤是由于沒有足夠的空間分配給一個新的對象的時候拋出的,Java會盡力去嘗試,但是垃圾回收器仍然無法清理出足夠的空間,堆也沒法繼續(xù)擴展,錯誤就拋出了,并給你一個stack trace。

要理解OOM,第一步是明白OOM異常真正的意思。好像是句廢話,但實際上,沒有多少人能說出OOM到底有多少種。舉個例子:一個OOM產(chǎn)生,是因為heap memory滿了,還是因為native heap memory滿了?為了幫助你回答類似的問題,我們先來看一下OOM可能出現(xiàn)的幾種錯誤信息:

  • java.lang.OutOfMemoryError: Java heap space
  • java.lang.OutOfMemoryError: PermGen space
  • java.lang.OutOfMemoryError: Requested array size exceeds VM limit
  • java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?
  • java.lang.OutOfMemoryError: <reason> <stack trace> (Native method)

Java heap space

這個錯誤信息不一定意味著就是內(nèi)存泄漏,事實上,這個錯誤類似配置錯誤,是很簡答的問題。

舉個例子,有一個應(yīng)用經(jīng)常出現(xiàn)這種類型的OOM異常,要我來分析這個問題。經(jīng)過檢查,我發(fā)現(xiàn)出問題的原因在于應(yīng)用中有段代碼會實例化一個占用內(nèi)存空間較大的數(shù)組;在這種情況下,這并不是應(yīng)用程序的錯誤,而是在應(yīng)用服務(wù)器配置中,使用了默認的heap memory大小,這太小了。我僅僅只是通過調(diào)整Xms就解決了。

另外一種情況,特別是針對運行了較長時間的應(yīng)用,這個信息可能就意味著應(yīng)用中有可能存在未釋放引用的對象,導(dǎo)致垃圾回收器無法及時回收空間。這是Java語言中最標(biāo)準(zhǔn)的內(nèi)存泄漏(在API中的定義描述為:unintentionally holding object references)。

另一個潛在的導(dǎo)致Java heap space OOM的原因是使用了finalizer。如果一個類有finalize方法,那么這個類型的對象不會在垃圾回收時間收回對應(yīng)的空間,而會等到垃圾回收結(jié)束之后,對象進入finalize隊列排隊,等待finalize執(zhí)行。在Sun公司的JVM實現(xiàn)中,finalizer是由一個守護線程執(zhí)行的。如果這個finalizer線程被終止了,而等待finalize的對象仍然在finalize隊列中,那么這些對象就無法被正?;厥眨@個時候OOM就有可能發(fā)生了。

PermGen space

這個錯誤信息意味著永久代空間被占滿了[注:Java8中已經(jīng)去掉永久代]。永久代空間是用來存儲class對象和method對象的堆空間。如果一個應(yīng)用需要加載大量的類,則需要通過調(diào)整-XX:MaxPermSize參數(shù)來擴大永久代空間大小。

駐留的字符串對象(Intered String Object)也是存儲在永久代中的。由java.lang.String類持有一個string對象的字符串常量池。當(dāng)String的intern方法被調(diào)用,這個方法會首先在字符串常量池中檢查是否有相等的字符串存在,如果是,這個字符串常量池中的string對象會被intern方法返回,如果不存在,這個string會被添加到字符串常量池中。使用專業(yè)術(shù)語來說,java.lang.String.intern方法返回一個字符串的不可變形式。如果一個應(yīng)用中有大量的駐留字符串,你也需要增加永久代空間的大小。

提示:你可以使用jmap -permgen命令輸出針對永久代的統(tǒng)計信息,包含駐留字符串相關(guān)的信息。[注:Java8中沒有這個選項,直接通過jmap -heap pid就能看到intern字符串信息]

Requested array size exceeds VM limit

這個錯誤出現(xiàn)在應(yīng)用(或者應(yīng)用調(diào)用的API)嘗試在堆上分配一個大于堆空間的數(shù)組。舉個例子,如果應(yīng)用嘗試分配一個512MB的數(shù)組,但是最大的heap大小設(shè)置為256MB,于是一個Requested array size exceeds VM limit的OOM錯誤就會被拋出。在大部分情況下,這個問題就是一個配置問題,或者檢查應(yīng)用中是否有嘗試分配巨大的數(shù)組的情況。

Request <size> bytes for <reason>. Out of swap space?

這個錯誤也是OOM的一種。在HotSpot VM中,當(dāng)native heap分配空間失敗,并且native heap空間趨近于衰竭的時候,會拋出這個異常。這個錯誤信息中包含了請求分配失敗的空間大小(size)和失敗的原因(reason)。大部分情況下,原因會顯示出嘗試分配空間失敗的代碼名稱。

如果這個類型的OOM異常拋出,你可能需要使用針對你的操作系統(tǒng)的專門的診斷工具來分析問題。分析這個問題的時候,不僅僅只是去檢查你的程序那么簡單。比如,在下面幾種情況下,你也可以看到這個問題:

  • 操作系統(tǒng)分配的內(nèi)存交換空間不足。
  • 操作系統(tǒng)中的另一個進程占滿了系統(tǒng)所有的內(nèi)存資源。

這個錯誤也有可能是本地內(nèi)存泄漏(native leak)造成的(比如一個應(yīng)用或者代碼庫持續(xù)的要求分配空間,但是在操作系統(tǒng)回收內(nèi)存的時候失敗了)

<reason> <stack trace> (Native method)

如果你看到了這個錯誤信息,并且在當(dāng)前棧頂顯示的是一個native方法,那么意味著這個native方法在申請一個內(nèi)存分配的時候失敗了。這個錯誤和上一個錯誤的區(qū)別在于,內(nèi)存分配錯誤是發(fā)生在JNI或者native方法上,還是Java VM代碼上,如果是前者,拋出的Native method錯誤信息,如果是后者,拋出的是上面那個錯誤。

同樣,如果出現(xiàn)了這種OOM,你需要使用針對你的操作系統(tǒng)的專門的診斷工具來分析問題。

非OOM導(dǎo)致的應(yīng)用崩潰

偶然的,如果出現(xiàn)native heap空間分配失敗,會立刻導(dǎo)致應(yīng)用的崩潰。這種情況出現(xiàn)在native方法的代碼沒有檢查和處理內(nèi)存分配的異常。比如,當(dāng)系統(tǒng)使用malloc 命令請求分配內(nèi)存,但是返回NULL 代表無空間可分配。如果調(diào)用malloc 方法但是沒有檢查和處理這個NULL結(jié)果,這個應(yīng)用會嘗試訪問不存在的內(nèi)存地址,這會立刻導(dǎo)致應(yīng)用崩潰。類似這樣的問題,是非常難以定位和處理的。

在某些情況下,可以借助系統(tǒng)的致命錯誤日志(fatal error log)或者故障快照(crash dump)來分析。如果經(jīng)過分析,確實是因為未處理內(nèi)存分配異常導(dǎo)致的應(yīng)用崩潰,你必須要找到分配失敗的原因。導(dǎo)致分配失敗的原因,和native heap空間分配失敗的原因相似,可能是因為系統(tǒng)設(shè)置的內(nèi)存交換空間不足,也有可能是另一個進程消耗完了系統(tǒng)的內(nèi)存資源。

診斷泄漏

在大多數(shù)情況下,要想正確診斷出內(nèi)存泄漏的原因,需要對應(yīng)用有非常細節(jié)的了解。這個過程可能非常漫長,而且往往是通過迭代完成。

我們診斷內(nèi)存泄漏的策略非常簡潔明了:

  1. 確定征兆
  2. 開啟垃圾回收器日志
  3. 開啟profile
  4. 分析跟蹤信息

1. 確定征兆

如上討論,在大部分時候,Java進程拋出一個OOM異常,這本身就是一個非常名確的信號,內(nèi)存資源已經(jīng)被耗盡。在這種情況下,你必須首先要區(qū)別這是正常的內(nèi)存資源耗盡還是內(nèi)存泄漏。我們需要分析OOM拋出的信息,根據(jù)上面我們討論的信息含義來確定大致的問題可能的方向。

常常的,如果一個java應(yīng)用需要的存儲空間超過運行時堆所有提供的,這往往歸結(jié)于不合理的設(shè)計。舉個例子,如果一個應(yīng)用會創(chuàng)建一個圖片的多個備份,然后放到一個數(shù)組里面,或者加載多個文件放到一個數(shù)組中,在這種情況下,如果一個圖片或者這個文件本身就很大,就很容易造成資源的不足,這是一種常見的資源耗盡的的情況。但是,如果程序是正常的在處理相同的數(shù)據(jù),但是內(nèi)存的消耗卻在穩(wěn)步的增加,這就極有可能是內(nèi)存溢出了。

2. 開啟垃圾回收器日志

確定是否出現(xiàn)內(nèi)存泄漏的一個最快的方式,就是開啟垃圾回收器日志信息。內(nèi)存約束問題通常可以通過-verbose:gc 的輸出模式來快速確定。

在啟動應(yīng)用的時候,使用-verbose:gc參數(shù),允許你在每次開始執(zhí)行GC的時候,生成跟蹤信息。意思是,當(dāng)內(nèi)存被回收的時候,在標(biāo)準(zhǔn)控制臺會輸出摘要信息,給出一個明確的數(shù)據(jù),展示當(dāng)前內(nèi)存的管理情況。

下面是一個典型的使用-verbose:gc參數(shù)的輸出:

image.png

在這個GC跟蹤中,每一個塊按照標(biāo)記的數(shù)字升序排列。要明白這個跟蹤日志的意思,主要要關(guān)注標(biāo)記為連續(xù)分配失效的行(Allocation Failure[AF]),然后關(guān)注釋放的內(nèi)存(釋放的空間大小和釋放的比例),可以看到,每次釋放的大小在減少,但是總的內(nèi)存空間在增長,這就是一個典型的內(nèi)存耗減的信號。

3. 開啟Profiling

不同的JVM提供了不同的方法去生成trace文件來反應(yīng)堆內(nèi)存活動的情況,這些文件中一般都包含了堆中對象的詳細類型和數(shù)量。這種方式就叫做heap profiling,比如jmap -histo[:live]

4. 分析跟蹤文件

這篇文章關(guān)注點在JVM生成的trace上。trace信息可以由不同的內(nèi)存泄漏分析工具生成,生成的展示格式也不一樣,但是在這些數(shù)據(jù)之后的道理是一致的:在堆中找到一組本不應(yīng)該存在的對象,并持續(xù)檢查對象是否是在持續(xù)增長而非釋放。特別關(guān)注的是那些在應(yīng)用中一個定時的時間會觸發(fā)相關(guān)事件,然后創(chuàng)建一定量的瞬時對象的情況。如果這種在內(nèi)存中理應(yīng)少量存在的對象卻出現(xiàn)了較多的實例,那么可能就意味著bug的存在了。

最后,處理內(nèi)存泄漏,需要整體的對代碼進行復(fù)查。熟悉內(nèi)存泄漏的類型對于加快debug是很有幫助的。

GC的是如何工作的?

在我們正式開始分析應(yīng)用的內(nèi)存泄漏問題之前,我們先來看看在JVM中,GC是如何工作的。

JVM使用跟蹤搜集器來完成垃圾的回收。這種回收器標(biāo)記出所有的root對象(即直接被活動線程引用的而對象),并跟隨對象的引用,把路徑上所有的對象標(biāo)記出來,這些對象就是活動對象。

Java基于代際假設(shè)(generational hypothesis assumption)實現(xiàn)了分代垃圾回收器。分代垃圾收集器假設(shè)大部分對象都會在短時間內(nèi)成為垃圾,而經(jīng)過了一定時間依然存活的對象往往擁有較長的壽命。壽命長的對象更容易存活下來,壽命短的對象則會被很快的廢棄。所以,為了縮短垃圾回收的時間,只需要重點掃描新創(chuàng)建的對象。

基于這個假設(shè),Java把不同的對象放到不同的分代空間中進行管理。下面是一個模擬圖。

image.png
  • 新生代 - 這是對象開始的地方。它有兩個子代:
    • Eden Space:對象從這里開始。大部分的對象在Eden空間中創(chuàng)建并銷毀。在這里面,GC執(zhí)行小回收(Minor GC),來優(yōu)化垃圾回收。當(dāng)執(zhí)行小回收的時候,如果對象仍然需要(即存活),那么這些對象會被移動到任何一個survivors空間(S0或者S1)。
    • Survivor Space (S0 and S1):從Eden Space存活下的對象會移動到這里。有兩個Survivor Space,在一個給定時間,只會有一個處于激活狀態(tài)(除非發(fā)生嚴重的內(nèi)存泄漏)。一個被設(shè)置為空,一個是存活的,每一次GC周期會執(zhí)行輪換。
  • 年老代(Tenured Generation):也叫做old generation (old space), 這個區(qū)域存放存活較久的對象(從survivor空間移動過來,已經(jīng)存活了足夠久的時間)。當(dāng)這個空間被填滿,會觸發(fā)一次完整GC(full GC),完整GC會小號較多的性能。如果這個空間持續(xù)的增長,JVM就會拋出一個OOM-Java heap space。
  • 永久代(Permanent Generation):靠近老年代的第三個代,永久代是一個特殊的空間,在這里面存儲的是虛擬機需要使用的類的描述數(shù)據(jù)。比如,對象的類描述信息和方法描述信息,都是存在永久代中。

Java針對不同的代空間采用了足夠聰明的垃圾回收方法。在新生代中,使用了跟蹤,拷貝回收算法,叫做并行GC(Parallel New Collector)。所謂Copying算法就是掃描出存活的對象,并復(fù)制到一塊新的完全未使用的空間中。新生代采用空閑指針的方式來控制GC觸發(fā),指針保持最后一個分配的對象在新生代區(qū)間的位置,當(dāng)有新的對象要分配內(nèi)存時,用于檢查空間是否足夠,不夠就觸發(fā)GC。

想要對JVM各個代空間有更細致的理解,請訪問:Memory Management in the Java HotSpot? Virtual Machine

檢測內(nèi)存溢出

為了找到內(nèi)存泄漏并排除錯誤,你需要使用合適的內(nèi)存泄漏檢查工具。是時候介紹Java VisualVM。

使用Java VisualVM遠程profiling堆信息

VisualVM提供了可視化界面,能夠提供對基于java的運行著的應(yīng)用的數(shù)據(jù)進行細節(jié)的數(shù)據(jù)展示。

使用VisualVM,你可以監(jiān)控本地的應(yīng)用,也可以監(jiān)控遠端服務(wù)器上的應(yīng)用。你可以獲取JVM實例的相關(guān)數(shù)據(jù),并且將數(shù)據(jù)保存到本地系統(tǒng)。

為了獲取Java VisualVM的所有功能,請運行在JavaSE6以上。

連接遠端JVM

在生產(chǎn)環(huán)境,直接連接到遠程服務(wù)器是不安全的,也不方便,遠程連接并profile是一個不錯的選擇。

首先,我們需要授權(quán)我們的JVM能夠連接上遠端的目標(biāo)機器。創(chuàng)建一個文件叫做jstatd.all.policy,加入以下內(nèi)容:

grant codebase "file:${java.home}/../lib/tools.jar" {

   permission java.security.AllPermission;

};

一旦這個文件創(chuàng)建好,我們需要使用jstatd-Virtual Machine jstat Daemon工具連接遠端VM,使用以下命令:

jstatd -p <PORT_NUMBER> -J-Djava.security.policy=<PATH_TO_POLICY_FILE>

比如:

jstatd -p 1234 -J-Djava.security.policy=D:\jstatd.all.policy

當(dāng)jstatd在目標(biāo)VM上運行之后,我們就可以連接上目標(biāo)機器,開啟遠端profile,排查內(nèi)存泄漏問題。

連接遠端服務(wù)

在本地打開一個命令窗口,并輸入jvisualvm開啟VisualVM工具。

下一步,我們需要在VisualVM中添加一個遠程主機,前提是我們已經(jīng)在遠程主機上允許從另一臺機器遠程連接。我們啟動Java VisualVM工具,并連接這臺服務(wù)器,一旦連接成功,我們就能看到遠端運行的那臺JVM了。

image.png

要開啟內(nèi)存profiler,我們需要在左邊的面板中雙擊目標(biāo)服務(wù)器。

到這里,我們已經(jīng)為我們的內(nèi)存分析做好了基本的準(zhǔn)備。

內(nèi)存泄漏

在Java中要模擬一個內(nèi)存泄漏有多重方法,最簡單的方式是我們定義一個類,但不要覆蓋equals()和hashcode()方法,并將這個類作為一個HashMap的key。HashMap要求作為key的類需要實現(xiàn)equals()和hashcode()方法。沒有這兩個方法,就無法產(chǎn)生一個正確的key。如果沒有定義equals()和hashcode()方法,當(dāng)我們將相同的key重復(fù)的添加到HashMap中,我們本意是執(zhí)行替換操作,但實際上不會,因為無法正確對比key值,HashMap會持續(xù)的增長,直到拋出一個OOM。

下面是定義的MemLeak類:

package com.post.memory.leak;
import java.util.Map;

public class MemLeak {

    public final String key;
    public MemLeak(String key) {
        this.key =key;
    }

    public static void main(String args[]) {
        try {
            Map map = System.getProperties();
            for(;;) { // line 14
                map.put(new MemLeak("key"), "value");
            }
        } catch(Exception e) {
            e.printStackTrace();
        }
    }
}

注意,內(nèi)存泄漏不是出現(xiàn)在14行的無限循環(huán);無限循環(huán)可能會導(dǎo)致資源的耗竭,但是不會出現(xiàn)內(nèi)存溢出。如果我們正確提供了equals()和hashcode()方法,就算使用無限循環(huán),map中也只可能出現(xiàn)一個元素。

可以訪問:http://stackoverflow.com/questions/6470651/creating-a-memory-leak-with-java 找到更多的內(nèi)存泄漏模擬方法。

使用Java VisualVM

使用Java VisualVM,我們可以監(jiān)控Java 堆信息,并且識別出是否存在內(nèi)存泄漏。

下面是初始化完成之后,我們的MemLeak的Java 堆信息示意圖:

image.png

差不多30秒之后,老年代基本上被占滿,注意,即使是執(zhí)行了一次Full GC,老年代仍然在持續(xù)增長,這就是明顯的內(nèi)存泄漏的標(biāo)志。

為了排查這次內(nèi)存溢出的原因,一種方式就是使用Java VisualVM生成heap dump信息。在圖中我們可以看到,heap中50%的對象都是Hashtable%Entry,第二行指向了我們的MemLeak類。那么可以得出結(jié)論,這次的內(nèi)存泄漏在于MemLeak類作為了hash table的key。

image.png

最后,可以注意到,當(dāng)拋出OOM的時候,新生代和老年代全部都滿了。

image.png

小節(jié)

內(nèi)存泄漏算JAVA應(yīng)用中最難解決的一個問題了,癥狀變化多端,并且難以重現(xiàn)。這篇文章中,我們一步一步的介紹了內(nèi)存泄漏的癥狀,產(chǎn)生的原因,如何定位和識別。最后,注意識別OOM的錯誤信息,仔細的分析錯誤棧,不是所有的內(nèi)存溢出問題,都如我們這篇文章中舉得MemLeak例子這么明顯。

原文:https://www.toptal.com/java/hunting-memory-leaks-in-java

想獲取更多技術(shù)視頻,請前往叩丁狼官網(wǎng):http://www.wolfcode.cn/openClassWeb_listDetail.html

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容