1. 介紹
使用內(nèi)建的垃圾收集器(或者是短暫的GC)來(lái)進(jìn)行內(nèi)存自動(dòng)管理是使用Java的核心好處之一,GC機(jī)制在后臺(tái)自動(dòng)進(jìn)行內(nèi)存分配和回收,因此能夠?qū)Υ蟛糠謨?nèi)存泄漏的情況進(jìn)行處理。
盡管GC機(jī)制能夠高效地管理一部分內(nèi)存,但并不意味著它能簡(jiǎn)化內(nèi)存泄漏的處理。GC很智能, 但并不是萬(wàn)能的。就處是一個(gè)細(xì)致的開(kāi)發(fā)者寫(xiě)的應(yīng)用也有可能出現(xiàn)內(nèi)存泄漏。
有些情況下應(yīng)用程序會(huì)產(chǎn)生大量的多余對(duì)象,導(dǎo)致占用了很多內(nèi)存資源,甚至?xí)?dǎo)致應(yīng)用程序的崩潰。
內(nèi)存在Java中是一個(gè)真正的問(wèn)題,在本篇教程中我們可以看到產(chǎn)生內(nèi)存泄漏的場(chǎng)景,怎樣在運(yùn)行時(shí)發(fā)現(xiàn)它們以及怎么在程序中去處理這些問(wèn)題。
2. 什么是內(nèi)存泄漏
內(nèi)存泄漏就是某些場(chǎng)景下有些對(duì)象在堆中不再被用到,但是垃圾收集器并不能回收它們,因此它們沒(méi)有被合理的管理。
內(nèi)存泄漏不僅會(huì)占用內(nèi)存資源而且隨著時(shí)間推移還會(huì)影響程序的性能,如果對(duì)其不采取任何措施,最終會(huì)耗盡系統(tǒng)資源產(chǎn)生 java.lang.OutOfMemoryError異常導(dǎo)致程序終止。
在堆內(nèi)存中有被引用和無(wú)引用兩種類型的對(duì)象,被引用的對(duì)象在程序中會(huì)有有效的引用指向,無(wú)引用對(duì)象則沒(méi)有。
垃圾回收器能夠回收階段性地回收沒(méi)有被引用的對(duì)象,但是并不會(huì)回收那些被引用的資源, 這也是內(nèi)存泄漏出現(xiàn)的根本原因。

內(nèi)存泄漏的表現(xiàn)
- 應(yīng)用在長(zhǎng)期運(yùn)行期間出現(xiàn)嚴(yán)重的性能降級(jí)
- 應(yīng)用中出現(xiàn)OutOfMemoryError堆內(nèi)存錯(cuò)誤
- 自發(fā)或者是莫名其妙的應(yīng)用崩潰
- 應(yīng)用偶爾性出現(xiàn)對(duì)象連接被占滿
接下來(lái)讓我們具體看看這些場(chǎng)景以及如何去應(yīng)對(duì)。
3. Java中內(nèi)存泄漏的類型
在任何應(yīng)用中,內(nèi)存泄漏的出現(xiàn)都有若干的可能。這里我們會(huì)討論經(jīng)常會(huì)出現(xiàn)的場(chǎng)景。
3.1 靜態(tài)字段導(dǎo)致的內(nèi)存泄漏
第一個(gè)常見(jiàn)出現(xiàn)內(nèi)存泄漏的場(chǎng)景就是大量使用靜態(tài)變量。
Java中靜態(tài)字段會(huì)有和運(yùn)行中應(yīng)用程序同樣長(zhǎng)的生命周期(除非是類加載器被垃圾回收器回收)。
下面這段代碼就用了一個(gè)靜態(tài)的List成員變量:
public class StaticTest {
public static List<Double> list = new ArrayList<>();
public void populateList() {
for (int i = 0; i < 10000000; i++) {
list.add(Math.random());
}
Log.info("Debug Point 2");
}
public static void main(String[] args) {
Log.info("Debug Point 1");
new StaticTest().populateList();
Log.info("Debug Point 3");
}
}
現(xiàn)在如果我們?cè)谶@段程序運(yùn)行時(shí)分析堆內(nèi)存的使用情況,我們會(huì)發(fā)現(xiàn)在points 1和points 2之間堆內(nèi)存的使用會(huì)增加。
然后執(zhí)行完方法populateList運(yùn)行到Points 3是使用的內(nèi)存并沒(méi)有被回收,通過(guò)VisualVM可以看到下圖:

然而如果我們?nèi)サ羯隙未a中第2行的static修飾關(guān)鍵字,內(nèi)存使用情況會(huì)出現(xiàn)很大的改觀,通過(guò)VisualVM可以看到:

通過(guò)兩幅圖的對(duì)比我們可以看到代碼的前半部分執(zhí)行情況基本一樣,但是去掉static關(guān)鍵字后當(dāng)程序執(zhí)行完populateList方法,list占用的內(nèi)存由于沒(méi)有任何引用因此全部被垃圾回收器回收了。
因此我們對(duì)使用靜態(tài)變量應(yīng)要非常小心。如果集合或者大對(duì)象被static關(guān)鍵字修飾,那么在應(yīng)用的整個(gè)生命周期中它們都會(huì)被保存在內(nèi)存中,導(dǎo)致占用了其它地方需要用到的寶貴內(nèi)存。
那么如何避免這種情況?
- 減少靜態(tài)變量的使用
- 當(dāng)使用單例時(shí),采用懶加載來(lái)迭代提前加載
3.2 沒(méi)有被關(guān)閉的資源
當(dāng)我們打開(kāi)一個(gè)連接或者是創(chuàng)建一個(gè)流是地,JVM會(huì)給這些資源分配內(nèi)存。例如數(shù)據(jù)庫(kù)的連接,輸入i流和session對(duì)象。
如果忘了關(guān)閉這些資源將會(huì)一直占用系統(tǒng)內(nèi)存,導(dǎo)致GC不能回收這些對(duì)象。甚至在現(xiàn)在異常時(shí)也會(huì)出現(xiàn),因?yàn)槌绦驎?huì)因?yàn)閽伋霎惓V苯犹^(guò)關(guān)閉資源的代碼。
在某些場(chǎng)景下,打開(kāi)的資源連接會(huì)占用著內(nèi)存,如果我們不對(duì)其進(jìn)行處理,會(huì)嚴(yán)重影響性能甚至導(dǎo)致OutOfMemoryError異常。
那么如何避免這種情況:
- 必須使用finally塊來(lái)關(guān)閉資源
- 關(guān)閉資源的代碼塊(即使是finally代碼塊)自身不能拋出任何異常
- 如果使用Java 7 以一的版本,可以使用try-with-resources代碼塊
3.3 對(duì)equals和hashCode進(jìn)行了不恰當(dāng)?shù)膶?shí)現(xiàn)
當(dāng)我們定義一個(gè)新類時(shí),一種常見(jiàn)的問(wèn)題就是不恰當(dāng)?shù)刂貙?xiě)equals和hashCode方法。
對(duì)HashSet和HashMap的許多操作都會(huì)用到這些方法,如果我們不正常地重寫(xiě)了它們,也有可能導(dǎo)致內(nèi)存泄漏。
那么我們以Person類作為示例,并且將其作為HashMap的一個(gè)key:
public class Person {
public String name;
public Person(String name) {
this.name = name;
}
}
接下來(lái)往以Person作為key的Map中插入重復(fù)的Person對(duì)象
注意Map中并不能有重復(fù)的key:
@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
Map<Person, Integer> map = new HashMap<>();
for(int i=0; i<100; i++) {
map.put(new Person("jon"), 1);
}
Assert.assertFalse(map.size() == 1);
}
我們?cè)谶@里將Person作為Map的key,由于Map不允許重復(fù)的key,因此重復(fù)的我們插入重復(fù)的Person不應(yīng)該增加內(nèi)存的占用。
但是因?yàn)槲覀儧](méi)有定義合適的equals方法,這些重復(fù)的對(duì)象累積起來(lái)導(dǎo)致內(nèi)存的增加,因此我們?cè)趦?nèi)存中不只看到一個(gè)對(duì)象。VisualVM的堆內(nèi)存使用情況如下:

然而如果我們對(duì)equals和hashCode方法進(jìn)行了恰當(dāng)?shù)闹貙?xiě),那么在Map中只會(huì)存在一個(gè)Person對(duì)象。
那么接下來(lái)就看看恰當(dāng)?shù)膃quals和hashCode重寫(xiě)應(yīng)該是怎樣的:
public class Person {
public String name;
public Person(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Person)) {
return false;
}
Person person = (Person) o;
return person.name.equals(name);
}
@Override
public int hashCode() {
int result = 17;
result = 31 * result + name.hashCode();
return result;
}
}
在這種情況下,下面的斷言就會(huì)為true:
@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
Map<Person, Integer> map = new HashMap<>();
for(int i=0; i<2; i++) {
map.put(new Person("jon"), 1);
}
Assert.assertTrue(map.size() == 1);
}
當(dāng)對(duì)equals和hashCode代碼進(jìn)行了合適的重寫(xiě)后,同樣的程序執(zhí)行堆內(nèi)存占用情況會(huì)如下:

另外一個(gè)例子就是使用ORM工具如Hibernate,會(huì)用equals和hashCode方法來(lái)分析對(duì)象
并且將其保存在緩存中。
如果沒(méi)有對(duì)這些方法進(jìn)行恰當(dāng)?shù)闹貙?xiě)就很有可能導(dǎo)致內(nèi)存泄漏,因?yàn)镠ibernate就不能區(qū)別這些對(duì)象從而在緩存中保存了重復(fù)的對(duì)象。
那么如何避免這種情況?
- 當(dāng)定義一個(gè)實(shí)體類時(shí),第一件事就是要重寫(xiě)equals的hashCode方法
- 不僅要重寫(xiě),而且要進(jìn)行恰當(dāng)?shù)闹貙?xiě)
更多的信息可以閱讀教程 Generate equals() and hashCode() with Eclipse和 Guide to hashCode() in Java.
3.4 內(nèi)部類引用外部類
在非靜態(tài)類(匿名類)中會(huì)出現(xiàn)這種情況。在初始化時(shí),這些內(nèi)部類總是需要一個(gè)完整類的實(shí)例。
在默認(rèn)情況下,非靜態(tài)內(nèi)部類對(duì)其外部類會(huì)有隱式的引。當(dāng)在應(yīng)用程序中用這些內(nèi)存類時(shí),即使引用外部類的對(duì)象已經(jīng)失效了,但并不會(huì)被垃圾回收。
當(dāng)一個(gè)類引用了有許多大對(duì)象并且有一個(gè)非靜態(tài)內(nèi)部類,就算只是創(chuàng)建一個(gè)內(nèi)部類,內(nèi)存占用情況會(huì)如下:

然而我們只是需要將這個(gè)內(nèi)部類聲明為靜態(tài)的內(nèi)存占用情況就會(huì)如下:

出現(xiàn)這種情況的原因是因?yàn)閮?nèi)部類持有對(duì)外部類的引用, 從而導(dǎo)致垃圾回收器不能回收外部類。在匿名類中同樣會(huì)出現(xiàn)這樣的情況。
那么如何避免這種情況:
- 如果內(nèi)部類不需要用到外部類的成員對(duì)象,考慮將其改為靜態(tài)類
3.5 使用finalize方法
當(dāng)使用finalize方法時(shí)也會(huì)產(chǎn)生內(nèi)存泄漏。任何時(shí)候當(dāng)對(duì)象的finalize方法被調(diào)用時(shí),垃圾回收器并不會(huì)立即回收它而是將其放入到回收隊(duì)列,等到合適時(shí)機(jī)才回收。
除此之外,如果重寫(xiě)的finalize方法并不是最佳導(dǎo)致finalizer隊(duì)列跟不上垃圾回收器的速度,或早或遲會(huì)導(dǎo)致程序出現(xiàn)OutOfMemoryError。
為了演示這種情況,我們可以重寫(xiě)一個(gè)對(duì)象的finalize方法并且在該方法的執(zhí)行需要一定的時(shí)間。當(dāng)大量持有該類的對(duì)象被垃圾回收時(shí),在VisualVM中表現(xiàn)如下:

然而當(dāng)我們?nèi)サ糁貙?xiě)的finalize方法后同樣的程序表現(xiàn)如下:

那么如何避免這種情況?
- 盡量避免重寫(xiě)finalize方法
更多的信息可能閱讀Guide to the finalize Method in Java
3.6 Interned 字符串
在Java7中Java 字符串常量池從永久代移到了堆空間中,但是對(duì)時(shí)使用java6以及更低版本的應(yīng)用來(lái)說(shuō),我們?cè)谑褂么罅孔址畷r(shí)要非常小心。
當(dāng)我們讀取大量的長(zhǎng)字符串并且調(diào)用intern方法,那么這些字符串就會(huì)被放到永久代的字符串常量池,只要程序在運(yùn)行它將會(huì)一直存在。這將占用很多內(nèi)存并且導(dǎo)致內(nèi)存泄漏。
在java1.6中永久代的使用情況通過(guò)VisualVM觀察如下:

相對(duì)這種情況,如果我們只是從一個(gè)文件中讀取字符串而不調(diào)用intern方法,那么永久代的使用情況就是這樣:

那么如何避免這種情況?
- 最簡(jiǎn)單的方法就是將java升級(jí)到7及以上的版本,因?yàn)閷⒆址A砍匾频搅硕褏^(qū)
- 如果使用了大量了字符串,那么就可以通過(guò)增加永久代的大小來(lái)避免OutOfMemoryErrors
-XX:MaxPermSize=512m
3.7 使用本地線程變量
通過(guò)使用ThreadLocal (更多可閱讀 Introduction to ThreadLocal in Java 教程) 本地線程變量可以對(duì)線程進(jìn)行隔離從而達(dá)到線程安全的目的。
當(dāng)使用本地線程變量時(shí),每個(gè)線程在存活期間都會(huì)持有一份對(duì)該變量拷貝的引用并且會(huì)自己維護(hù)這份拷貝,而不是在多線程之間共享。
盡管使用ThreadLocal有如此大的好處,但是卻有很多爭(zhēng)論的,因?yàn)槿绻褂貌划?dāng)很容易導(dǎo)致內(nèi)存泄漏。Joshua Bloch對(duì)ThreadLocal的使用作過(guò)如下評(píng)論:
在許多地方都寫(xiě)到,過(guò)于分散的線程池使用和過(guò)于分散的本地線程變量使用會(huì)導(dǎo)致意想不到對(duì)象存留。但歸罪于本地線程亦是是莫須有的罪名。
ThreadLocal中的內(nèi)存泄漏
當(dāng)線程不再存活時(shí),那么它引用的ThreadLocal對(duì)象也會(huì)被垃圾回收。但是當(dāng)今應(yīng)用服務(wù)器的使用導(dǎo)致ThreadLocal的使用出現(xiàn)了問(wèn)題。
當(dāng)今服務(wù)器應(yīng)用通過(guò)使用線程池來(lái)傳遞請(qǐng)求而不是創(chuàng)建新的線程(比如Apache Tomcat服務(wù)器就是使用Executor框架)。此外,它們使用獨(dú)立的類加載器。
由于線程池采取的是線程復(fù)用的理念,因此它們從不會(huì)被垃圾回收,而是被其它的請(qǐng)求復(fù)用。
這種情況下如果任何一個(gè)類創(chuàng)建了一個(gè)ThreadLocal變量但沒(méi)有顯示移除它,那么這個(gè)對(duì)象的拷貝就會(huì)一直被工作線程持有直到應(yīng)用程序終止,導(dǎo)致對(duì)應(yīng)不能被垃圾回收器回收。
那么如何避免這種情況?
ThreadLocal提供了remove方法,該方法會(huì)移除當(dāng)前線程對(duì)它的拷貝。所以養(yǎng)成當(dāng)ThreadLocal對(duì)象不再使用就清除它的好習(xí)慣。
不要使用 ThreadLocal.set(null) 來(lái)清理值。因?yàn)樗粫?huì)清理這個(gè)合二為一而是將ThreadLocalMap中的kv分別設(shè)置為空
更好的解決辦法可以考慮將ThreadLocal作為一個(gè)資源對(duì)象將釋放代碼放在finally塊中從而保證總是能被回收,即使發(fā)生異常:
try { threadLocal.set(System.nanoTime()); //... further processing } finally { threadLocal.remove(); }
4. 處理內(nèi)存泄漏的其它策略
盡管對(duì)處理內(nèi)存泄漏沒(méi)有能用的方法,但不是有一些方式可以減少這些泄漏。
4.1 啟用分析器
Java分析器就是一些監(jiān)控和診斷應(yīng)用內(nèi)存泄漏的工具。通過(guò)它可以分析我們應(yīng)用程序內(nèi)存的運(yùn)行情況, 比如說(shuō)內(nèi)存的分配。
通過(guò)使用分析器,我們可以對(duì)比不同的情形從而對(duì)資源進(jìn)行最佳的使用。
在本文第3部分中我們使用了Java VisualVM.可以閱讀 Guide to Java Profilers這篇文章來(lái)學(xué)更多的分析器,如Mission Control, JProfiler, YourKit, Java VisualVM, 和the Netbeans Profiler.
4.2 打印詳細(xì)的垃圾回收情況
通過(guò)打印增援回收情況, 我們可以追蹤GC的具體情況。通過(guò)使用如下JVM參數(shù)即可:
-verbose:gc
加上這個(gè)參數(shù)后,我們就可以看到GC的具體情況:

4.3 使用引用對(duì)象來(lái)避免內(nèi)存泄漏
通過(guò)使用java.lang.ref 包中的一些類而不是直接引用對(duì)象,使用不同的引用類型讓它們能更好的被垃圾回收。引用隊(duì)列的設(shè)計(jì)就是讓我們知道我們引用的對(duì)象是否被回收了,更多信息可以閱讀Soft References in Java 。
4.4 Eclipse內(nèi)存泄漏的警告
對(duì)于使用JDK 1.5及以上版本的應(yīng)用, Eclipse在我們程序出現(xiàn)明顯的內(nèi)存泄漏時(shí)會(huì)顯示警告和錯(cuò)誤。因此,當(dāng)使用Eclipse進(jìn)行開(kāi)發(fā)時(shí)我們要多關(guān)注"Problems"標(biāo)簽頁(yè)并且對(duì)內(nèi)存泄漏警告(如果有的話)更加警惕。

4.5 Benchmarking
通過(guò)執(zhí)行benchmark我們可以衡量和分析Java代碼的性能。這種方式我們可以對(duì)同一任務(wù)的不同實(shí)現(xiàn)進(jìn)行對(duì)比, 從而幫助我們選擇更好的方式并且節(jié)約內(nèi)存。
可以閱讀Microbenchmarking with Java 教程獲取關(guān)于benchmarking的更多資料。
5. 總結(jié)
用外行人的話來(lái)說(shuō),我們可以將內(nèi)存泄漏當(dāng)作占用重要內(nèi)存資源從而導(dǎo)致應(yīng)用程序性能下降的一種疾病,像其它疾病一樣,如果沒(méi)有治愈它,那么隨著時(shí)間推移會(huì)導(dǎo)致應(yīng)用以崩潰而失敗。
使用Java時(shí)找到并解決內(nèi)存泄漏需要很高的技巧和豐富的經(jīng)驗(yàn),在很多情況下都會(huì)現(xiàn)在泄漏,因此并沒(méi)有一種通過(guò)的方法來(lái)處理內(nèi)存泄漏。
然而,如果通過(guò)采用分析等手段以使用最佳的方式和執(zhí)行嚴(yán)格的代碼測(cè)試,我們就可以減少應(yīng)用中的內(nèi)存泄漏。
一如既往,本文中產(chǎn)生VisualVM效果圖的代碼在GitHub都可以找到。