Categories
程式開發

分析和解決JAVA 內存洩露的實戰例子


這幾天,一直在為Java的“內存洩露”問題糾結。 Java應用程序佔用的內存在不斷的、有規律的上漲,最終超過了監控閾值。福爾摩斯不得不出手了!

分析內存洩露的一般步驟

如果發現Java應用程序佔用的內存出現了洩露的跡象,那麼我們一般採用下面的步驟分析:

把Java應用程序使用的heap dump下來使用Java heap分析工具,找出內存佔用超出預期(一般是因為數量太多)的嫌疑對象必要時,需要分析嫌疑對象和其他對象的引用關係。查看程序的源代碼,找出嫌疑對像數量過多的原因。

轉儲堆

如果Java應用程序出現了內存洩露,千萬別著急著把應用殺掉,而是要保存現場。如果是互聯網應用,可以把流量切到其他服務器。保存現場的目的就是為了把運行中JVM的heap dump下來。

JDK自帶的jmap工具,可以做這件事情。它的執行方法是:

jmap -dump:format=b,file=heap.bin

format=b的含義是,dump出來的文件時二進制格式。

file-heap.bin的含義是,dump出來的文件名是heap.bin。

就是JVM的進程號。

(在linux下)先執行ps aux | grep java,找到JVM的pid;然後再執行jmap -dump:format=b,file=heap.bin ,得到heap dump文件。

分析堆

將二進制的heap dump文件解析成human-readable的信息,自然是需要專業工具的幫助,這裡推薦Memory Analyzer 。

Memory Analyzer,簡稱MAT,是Eclipse基金會的開源項目,由SAP和IBM捐助。巨頭公司出品的軟件還是很中用的,MAT可以分析包含數億級對象的heap、快速計算每個對象佔用的內存大小、對象之間的引用關係、自動檢測內存洩露的嫌疑對象,功能強大,而且界面友好易用。

MAT的界面基於Eclipse開發,以兩種形式發布:Eclipse插件和Eclipe RCP。 MAT的分析結果以圖片和報表的形式提供,一目了然。總之個人還是非常喜歡這個工具的。下面先貼兩張官方的screenshots:

分析和解決JAVA 內存洩露的實戰例子 1

分析和解決JAVA 內存洩露的實戰例子 2

言歸正傳,我用MAT打開了heap.bin,很容易看出,char[]的數量出其意料的多,佔用90%以上的內存。一般來說,char[]在JVM確實會佔用很多內存,數量也非常多,因為String對像以char[]作為內部存儲。但是這次的char[]太貪婪了,仔細一觀察,發現有數万計的char[],每個都佔用數百K的內存。這個現像說明,Java程序保存了數以萬計的大String對象。結合程序的邏輯,這個是不應該的,肯定在某個地方出了問題。

順藤摸瓜

在可疑的char[]中,任意挑了一個,使用Path To GC Root功能,找到該char[]的引用路徑,發現String對像是被一個HashMap中引用的。這個也是意料中的事情,Java的內存洩露多半是因為對像被遺留在全局的HashMap中得不到釋放。不過,該HashMap被用作一個緩存,設置了緩存條目的閾值,導達到閾值後會自動淘汰。從這個邏輯分析,應該不會出現內存洩露的。雖然緩存中的String對像已經達到數万計,但仍然沒有達到預先設置的閾值(閾值設置地比較大,因為當時預估String對像都比較小)。

但是,另一個問題引起了我的注意:為什麼緩存的String對像如此巨大?內部char[]的長度達數百K。雖然緩存中的String對像數量還沒有達到閾值,但是String對像大小遠遠超出了我們的預期,最終導致內存被大量消耗,形成內存洩露的跡象(準確說應該是內存消耗過多) 。

就這個問題進一步順藤摸瓜,看看String大對像是如何被放到HashMap中的。通過查看程序的源代碼,我發現,確實有String大對象,不過並沒有把String大對象放到HashMap中,而是把String大對象進行split(調用String.split方法),然後將split出來的String小對象放到HashMap中了。

這就奇怪了,放到HashMap中明明是split之後的String小對象,怎麼會佔用那麼大空間呢?難道是String類的split方法有問題?

查看代碼

帶著上述疑問,我查閱了Sun JDK6中String類的代碼,主要是是split方法的實現:

public
String[] split(String regex, int limit) {
return Pattern.compile(regex).split(this, limit);
}

可以看出,Stirng.split方法調用了Pattern.split方法。繼續看Pattern.split方法的代碼:

public
String[] split(CharSequence input, int limit) {
int index = 0;
boolean matchLimited = limit > 0;
ArrayList matchList = new
ArrayList();
Matcher m = matcher(input);
// Add segments before each match found
while(m.find()) {
if (!matchLimited || matchList.size() < limit - 1) { String match = input.subSequence(index, m.start()).toString(); matchList.add(match); index = m.end(); } else if (matchList.size() == limit - 1) { // last one String match = input.subSequence(index, input.length()).toString(); matchList.add(match); index = m.end(); } } // If no match was found, return this if (index == 0) return new String[] {input.toString()}; // Add remaining segment if (!matchLimited || matchList.size() 0 && matchList.get(resultSize-1).equals("")) resultSize--; String[] result = new String[resultSize]; return matchList.subList(0, resultSize).toArray(result); } 注意看第9行:Stirng match = input.subSequence(intdex, m.start()).toString();

這裡的match就是split出來的String小對象,它其實是String大對象subSequence的結果。繼續看String.subSequence的代碼:

public
CharSequence subSequence(int beginIndex, int endIndex) {
return this.substring(beginIndex, endIndex);
}
String.subSequence有调用了String.subString,继续看:

public String
substring(int beginIndex, int endIndex) {
if (beginIndex count) {
throw new StringIndexOutOfBoundsException(endIndex);
}
if (beginIndex > endIndex) {
throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
}
return ((beginIndex == 0) && (endIndex == count)) ? this :
new String(offset + beginIndex, endIndex - beginIndex, value);
}

看第11、12行,我們終於看出眉目,如果subString的內容就是完整的原字符串,那麼返回原String對象;否則,就會創建一個新的String對象,但是這個String對象貌似使用了原String對象的char[]。我們通過String的構造函數確認這一點:

// Package
private constructor which shares value array for speed.
String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}

為了避免內存拷貝、加快速度,Sun JDK直接復用了原String對象的char[],偏移量和長度來標識不同的字符串內容。也就是說,subString出的來String小對象仍然會指向原String大對象的char[],split也是同樣的情況。這就解釋了,為什麼HashMap中String對象的char[]都那麼大。

原因解釋

其實上一節已經分析出了原因,這一節再整理一下:

程序從每個請求中得到一個String大對象,該對象內部char[]的長度達數百K。

程序對String大對像做split,將split得到的String小對象放到HashMap中,用作緩存。

Sun JDK6對String.split方法做了優化,split出來的Stirng對象直接使用原String對象的char[]

HashMap中的每個String對像其實都指向了一個巨大的char[]

HashMap的上限是萬級的,因此被緩存的Sting對象的總大小=萬*百K=G級。

G級的內存被緩存佔用了,大量的內存被浪費,造成內存洩露的跡象。

解決方案

原因找到了,解決方案也就有了。 split是要用的,但是我們不要把split出來的String對象直接放到HashMap中,而是調用一下String的拷貝構造函數String(String original),這個構造函數是安全的,具體可以看​​代碼:

/**
* Initializes a newly created {@code String} object so that it
represents
* the same sequence of characters as the argument; in other words,
the
* newly created string is a copy of the argument string. Unless an
* explicit copy of {@code original} is needed, use of this
constructor is
* unnecessary since Strings are immutable.
*
* @param original
* A {@code String}
*/
public String(String original) {
int size = original.count;
char[] originalValue = original.value;
char[] v;
if (originalValue.length > size) {
// The array representing the String is bigger than the new
// String itself. Perhaps this constructor is being called
// in order to trim the baggage, so make a copy of the array.
int off = original.offset;
v = Arrays.copyOfRange(originalValue, off, off+size);
} else {
// The array representing the String is the same
// size as the String, so no point in making a copy.
v = originalValue;
}
this.offset = 0;
this.count = size;
this.value = v;
}

只是,new String(string)的代碼很怪異,囧。或許,subString和split應該提供一個選項,讓程序員控制是否復用String對象的char[]。

是否Bug

雖然,subString和split的實現造成了現在的問題,但是這能否算String類的bug呢?個人覺得不好說。因為這樣的優化是比較合理的,subString和spit的結果肯定是原字符串的連續子序列。只能說,String不僅僅是一個核心類,它對於JVM來說是與原始類型同等重要的類型。

JDK實現對String做各種可能的優化都是可以理解的。但是優化帶來了憂患,我們程序員足夠了解他們,才能用好他們。

一些補充

有個地方我沒有說清楚。

我的程序是一個Web程序,每次接受請求,就會創建一個大的String對象,然後對該String對象進行split,最後split之後的String對象放到全局緩存中。如果接收了5W個請求,那麼就會有5W個大String對象。這5W個大String對像都被存儲在全局緩存中,因此會造成內存洩漏。我原以為緩存的是5W個小String,結果都是大String。

有同學後續建議用"java.io.StreamTokenizer"來解決本文的問題。確實是終極解決方案,比我上面提到的“new String()”,要好很多很多。

看完三件事❤️

如果你覺得這篇內容對你還蠻有幫助,我想邀請你幫我三個小忙:

點贊,轉發,有你們的『點贊和評論』,才是我創造的動力。關注公眾號『 java爛豬皮 』,不定期分享原創知識。同時可以期待後續文章ing🚀

分析和解決JAVA 內存洩露的實戰例子 3

出處:https://club.perfma.com/article/1815828