聊聊7種內(nèi)存泄露場景和13種解決方案
前言
Java通過垃圾回收機(jī)制,可以自動的管理內(nèi)存,這對開發(fā)人員來說是多么美好的事啊。但垃圾回收器并不是萬能的,它能夠處理大部分場景下的內(nèi)存清理、內(nèi)存泄露以及內(nèi)存優(yōu)化。但它也并不是萬能的。
不然,我們在實(shí)踐的過程中也不會出現(xiàn)那么多因內(nèi)存泄露導(dǎo)致的生產(chǎn)事件了。但很多內(nèi)存泄露時間也是因?yàn)殚_發(fā)人員使用不當(dāng)導(dǎo)致的。
本篇文章我們就來聊聊內(nèi)存泄露的原因是什么,如何識別內(nèi)存泄露,以及如果在應(yīng)用程序中進(jìn)行處理。
什么是內(nèi)存泄露
什么是內(nèi)存泄露,通俗的來說就是堆中的一些對象已經(jīng)不會再被使用了,但垃圾收集器卻無法將它們從內(nèi)存中清除。
內(nèi)存泄漏很嚴(yán)重的問題,因?yàn)樗鼤枞麅?nèi)存資源并隨著時間的推移降低系統(tǒng)性能。如果不進(jìn)行有效的處理,最終的結(jié)果將會使應(yīng)用程序耗盡內(nèi)存資源,無法正常服務(wù),導(dǎo)致程序崩潰,拋出java.lang.OutOfMemoryError異常。
堆內(nèi)存中通常有兩種類型的對象:被引用的對象和未被引用的對象。被引用的對象是應(yīng)用程序中仍然具有活躍的引用,而未被引用的對象則沒有任何活躍的引用。
垃圾收集器會回收那些未被引用的對象,但不會回收那些還在被引用的對象。這也是內(nèi)存泄露發(fā)生的源頭。
內(nèi)存泄露往往有以下表象:
- 當(dāng)應(yīng)用程序長時間連續(xù)運(yùn)行時,性能嚴(yán)重下降;
- 拋出OutOfMemoryError異常;
- 程序莫名其妙的自動崩潰;
- 應(yīng)用程序耗盡鏈接對象。
當(dāng)然,如果打印GC日志,有些場景下還會看到頻繁執(zhí)行full GC等狀況。下面就具體分析一下這些場景和處理方案。
Java中內(nèi)存泄露分類
在任何一個應(yīng)用程序中,發(fā)生內(nèi)存泄露往往由很多原因構(gòu)成。下面我們就聊聊最常見的一些內(nèi)存泄露場景。
靜態(tài)屬性導(dǎo)致內(nèi)存泄露
會導(dǎo)致內(nèi)存泄露的一種情況就是大量使用static靜態(tài)變量。在Java中,靜態(tài)屬性的生命周期通常伴隨著應(yīng)用整個生命周期(除非ClassLoader符合垃圾回收的條件)。
下面來看一個具體的會導(dǎo)致內(nèi)存泄露的實(shí)例:
- 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");
- }
- }
如果監(jiān)控內(nèi)存堆內(nèi)存的變化,會發(fā)現(xiàn)在打印Point1和Point2之間,堆內(nèi)存會有一個明顯的增長趨勢圖。
但當(dāng)執(zhí)行完populateList方法之后,對堆內(nèi)存并沒有被垃圾回收器進(jìn)行回收。
上圖為VisualVM監(jiān)控顯示的信息,關(guān)于VisualVM的使用這里就不再贅述了,可參考文章《沒有監(jiān)控過JVM內(nèi)存的職場生涯,是不完美的》。
但針對上述程序,如果將定義list的變量前的static關(guān)鍵字去掉,再次執(zhí)行程序,會發(fā)現(xiàn)內(nèi)存發(fā)生了具體的變化。VisualVM監(jiān)控信息如下圖:
對比兩個圖可以看出,程序執(zhí)行的前半部分內(nèi)存使用情況都一樣,但當(dāng)執(zhí)行完populateList方法之后,后者不再有引用指向?qū)?yīng)的數(shù)據(jù),垃圾回收器便進(jìn)行了回收操作。
因此,我們要十分留意static的變量,如果集合或大量的對象定義為static的,它們會停留在整個應(yīng)用程序的生命周期當(dāng)中。而它們所占用的內(nèi)存空間,本可以用于其他地方。
那么如何優(yōu)化呢?第一,進(jìn)來減少靜態(tài)變量;第二,如果使用單例,盡量采用懶加載。
未關(guān)閉的資源
無論什么時候當(dāng)我們創(chuàng)建一個連接或打開一個流,JVM都會分配內(nèi)存給這些資源。比如,數(shù)據(jù)庫鏈接、輸入流和session對象。
忘記關(guān)閉這些資源,會阻塞內(nèi)存,從而導(dǎo)致GC無法進(jìn)行清理。特別是當(dāng)程序發(fā)生異常時,沒有在finally中進(jìn)行資源關(guān)閉的情況。
這些未正常關(guān)閉的連接,如果不進(jìn)行處理,輕則影響程序性能,重則導(dǎo)致OutOfMemoryError異常發(fā)生。
如果進(jìn)行處理呢?第一,始終記得在finally中進(jìn)行資源的關(guān)閉;第二,關(guān)閉連接的自身代碼不能發(fā)生異常;第三,Java7以上版本可使用try-with-resources代碼方式進(jìn)行資源關(guān)閉。
不當(dāng)?shù)膃quals方法和hashCode方法實(shí)現(xiàn)
當(dāng)我們定義個新的類時,往往需要重寫equals方法和hashCode方法。在HashSet和HashMap中的很多操作都用到了這兩個方法。如果重寫不得當(dāng),會造成內(nèi)存泄露的問題。
下面來看一個具體的實(shí)例:
- public class Person {
- public String name;
- public Person(String name) {
- this.name = name;
- }
- }
現(xiàn)在將重復(fù)的Person對象插入到Map當(dāng)中。我們知道Map的key是不能重復(fù)的。
- @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);
- }
上述代碼中將Person對象作為key,存入Map當(dāng)中。理論上當(dāng)重復(fù)的key存入Map時,會進(jìn)行對象的覆蓋,不會導(dǎo)致內(nèi)存的增長。
但由于上述代碼的Person類并沒有重寫equals方法,因此在執(zhí)行put操作時,Map會認(rèn)為每次創(chuàng)建的對象都是新的對象,從而導(dǎo)致內(nèi)存不斷的增長。
VisualVM中顯示信息如下圖:
當(dāng)重寫equals方法和hashCode方法之后,Map當(dāng)中便只會存儲一個對象了。方法的實(shí)現(xiàn)如下:
- 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;
- }
- }
經(jīng)過上述修改之后,Assert中判斷Map的size便會返回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);
- }
重寫equals方法和hashCode方法之后,堆內(nèi)存的變化如下圖:
另外的例子就是當(dāng)使用ORM框架,如Hibernate時,會使用equals方法和hashCode方法進(jìn)行對象的的分析和緩存操作。
如果不重寫這些方法,則發(fā)生內(nèi)存泄漏的可能性非常高,因?yàn)镠ibernate將無法比較對象(每次都是新對象),然后不停的更新緩存。
如何進(jìn)行處理?第一,如果創(chuàng)建一個實(shí)體類,總是重寫equals方法和hashCode方法;第二,不僅要覆蓋默認(rèn)的方法實(shí)現(xiàn),而且還要考慮最優(yōu)的實(shí)現(xiàn)方式;
外部類引用內(nèi)部類
這種情況發(fā)生在非靜態(tài)內(nèi)部類(匿名類)中,在類初始化時,內(nèi)部類總是需要外部類的一個實(shí)例。
每個非靜態(tài)內(nèi)部類默認(rèn)都持有外部類的隱式引用。如果在應(yīng)用程序中使用該內(nèi)部類的對象,即使外部類使用完畢,也不會對其進(jìn)行垃圾回收。
假設(shè)一個類,其中包含大量笨重對象的引用,并且具有一個非靜態(tài)內(nèi)部類。當(dāng)我們創(chuàng)建內(nèi)部類的對象時,內(nèi)存模型如下所示:
如果將內(nèi)部類聲明為static的,那么內(nèi)存曲線則像從寫equals和hashCode方法之后的圖一樣,是一條平穩(wěn)的直線。
此種情況,之所以發(fā)生內(nèi)存泄露,是因?yàn)閮?nèi)部類對象隱含的持有外部類的引用,從而導(dǎo)致外部類成為垃圾對象時卻無法被正常回收。使用匿名類的時候也會發(fā)生類似的情況。
如何避免此種情況?如果內(nèi)部類不需要訪問外部類的成員信息,可以考慮將其轉(zhuǎn)換為靜態(tài)內(nèi)部類。
finalize()方法
使用finalize()方法會存在潛在的內(nèi)存泄露問題,每當(dāng)一個類的finalize()方法被重寫時,該類的對象就不會被GC立即回收。GC會將它們放入隊(duì)列進(jìn)行最終確定,在以后的某個時間點(diǎn)進(jìn)行回收。
如果finalize()方法重寫的不合理或finalizer隊(duì)列無法跟上Java垃圾回收器的速度,那么遲早,應(yīng)用程序會出現(xiàn)OutOfMemoryError異常。
假設(shè)某個類重寫了finalize()方法,并且重寫的方法在執(zhí)行時需要一些時間。如果存在大量該對象,垃圾回收時,在VisualVM中的曲線如下:
如果去掉重寫的finalize()方法,同樣的程序,展示的曲線如下:
如果避免此種情況發(fā)生呢?始終避免使用finalizer。
String的intern方法
字符串常量池在Java7中從PermGen移動到了堆空間。在Java6及以前版本,我們使用字符串時要多加小心。
如果讀取了一個大字符串對象,并且調(diào)用其intern方法,intern()會將String放在JVM的內(nèi)存池中(PermGen),而JVM的內(nèi)存池是不會被GC的。同樣會造成程序性能降低和內(nèi)存溢出問題。
JDK1.6中PermGen中存儲大對象示例:
如何避免此種情況發(fā)生?第一,最簡單的方式是更新JDK版到7及以上;第二,如果無法避免,則可調(diào)整PermGen大小,避免OutOfMemoryErrors溢出。
PermGen相關(guān)配置:
- -XX:MaxPermSize=512m
使用ThreadLocal
ThreadLocal提供了線程本地變量,它可以保證訪問到的變量屬于當(dāng)前線程,每個線程都保存有一個變量副本,每個線程的變量都不同。ThreadLocal相當(dāng)于提供了一種線程隔離,將變量與線程相綁定,從而實(shí)現(xiàn)線程安全的特性。
ThreadLocal的實(shí)現(xiàn)中,每個Thread維護(hù)一個ThreadLocalMap映射表,key是ThreadLocal實(shí)例本身,value是真正需要存儲的Object。
ThreadLocalMap使用ThreadLocal的弱引用作為key,如果一個ThreadLocal沒有外部強(qiáng)引用來引用它,那么系統(tǒng)GC時,這個ThreadLocal勢必會被回收,這樣一來,ThreadLocalMap中就會出現(xiàn)key為null的Entry,就沒有辦法訪問這些key為null的Entry的value。
如果當(dāng)前線程遲遲不結(jié)束的話,這些key為null的Entry的value就會一直存在一條強(qiáng)引用鏈:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永遠(yuǎn)無法回收,造成內(nèi)存泄漏。
如何解決此問題?
第一,使用ThreadLocal提供的remove方法,可對當(dāng)前線程中的value值進(jìn)行移除;
第二,不要使用ThreadLocal.set(null) 的方式清除value,它實(shí)際上并沒有清除值,而是查找與當(dāng)前線程關(guān)聯(lián)的Map并將鍵值對分別設(shè)置為當(dāng)前線程和null。
第三,最好將ThreadLocal視為需要在finally塊中關(guān)閉的資源,以確保即使在發(fā)生異常的情況下也始終關(guān)閉該資源。
- try {
- threadLocal.set(System.nanoTime());
- //... further processing
- } finally {
- threadLocal.remove();
- }
處理內(nèi)存泄漏的其他策略
盡管在處理內(nèi)存泄漏時沒有萬能的解決方案,但是有一些方法可以使內(nèi)存泄漏最小化。
啟用分析
我們可通過一些工具,用來對應(yīng)用應(yīng)用程序的內(nèi)存使用情況等進(jìn)行監(jiān)控和診斷,從而找到最佳的利用系統(tǒng)資源的方案。
類似的工具有前面我們提到的VisualVM,還有Mission Control,JProfiler,YourKit,Java VisualVM和Netbeans Profiler等。
顯示垃圾回收詳情
通過啟用垃圾收集詳情日志,可以對GC的詳細(xì)進(jìn)行跟蹤。通過以下命令進(jìn)行啟動:
- -verbose:gc
通過添加此參數(shù),我們可以看到GC內(nèi)部發(fā)生的情況的詳細(xì)信息:
使用引用對象避免內(nèi)存泄漏
在Java中,我們還可以使用java.lang.ref包內(nèi)置引用對象來處理內(nèi)存泄漏。使用java.lang.ref包,而不是直接引用對象,我們對對象使用特殊的引用,從而確保它們可以輕松地被垃圾回收。
IDE警告
無論是Eclipse還是IDEA,如果安裝對應(yīng)的插件(比如阿里巴巴開發(fā)手冊插件等),當(dāng)寫代碼中出現(xiàn)內(nèi)存泄露風(fēng)險代碼時,IDE會進(jìn)行警告提醒,從而從源頭上避免內(nèi)存泄露的代碼出現(xiàn)在生產(chǎn)環(huán)境。
基準(zhǔn)測試
通過執(zhí)行基準(zhǔn)測試來衡量和分析Java代碼的性能,從而選擇更合理的解決方案。
Code Review
這也是最古老,最有效的方式之一,通過經(jīng)驗(yàn)豐富的開發(fā)人員對代碼的Review或多人進(jìn)行Review,從而達(dá)到查漏補(bǔ)缺的效果,排除一些常見的內(nèi)存泄露問題。
小結(jié)
本文介紹了內(nèi)存泄露的原因以及常見的7種內(nèi)存泄露場景,針對每種內(nèi)存泄露的場景都提供了解決方案。另外,還為大家提供了6種額外的通用性解決策略。
但針對內(nèi)存泄露來說,這還是九牛一毛,不同的代碼,不同的場景都會出現(xiàn)一些未知的內(nèi)存泄露問題,同時也沒有萬能的解決方案。這就需要我們了解內(nèi)存泄露的根本原因,同時掌握一些基本的分析方法和策略,以便靈活應(yīng)對。







































