Java 分析并解決內(nèi)存泄漏的實(shí)例
這幾天,一直在為Java的“內(nèi)存泄露”問題糾結(jié)。Java應(yīng)用程序占用的內(nèi)存在不斷的、有規(guī)律的上漲,最終超過了監(jiān)控閾值。福爾摩 斯不得不出手了!
分析內(nèi)存泄露的一般步驟
如果發(fā)現(xiàn)Java應(yīng)用程序占用的內(nèi)存出現(xiàn)了泄露的跡象,那么我們一般采用下面的步驟分析:
- 把Java應(yīng)用程序使用的heap dump下來
- 使用Java heap分析工具,找出內(nèi)存占用超出預(yù)期(一般是因?yàn)閿?shù)量太多)的嫌疑對(duì)象
- 必要時(shí),需要分析嫌疑對(duì)象和其他對(duì)象的引用關(guān)系。
- 查看程序的源代碼,找出嫌疑對(duì)象數(shù)量過多的原因。
dump heap
如果Java應(yīng)用程序出現(xiàn)了內(nèi)存泄露,千萬別著急著把應(yīng)用殺掉,而是要保存現(xiàn)場(chǎng)。如果是互聯(lián)網(wǎng)應(yīng)用,可以把流量切到其他服務(wù)器。保存現(xiàn)場(chǎng)的目的就是為了把 運(yùn)行中JVM的heap dump下來。
JDK自帶的jmap工具,可以做這件事情。它的執(zhí)行方法是:
jmap -dump:format=b,file=heap.bin <pid>
format=b的含義是,dump出來的文件時(shí)二進(jìn)制格式。
file-heap.bin的含義是,dump出來的文件名是heap.bin。
<pid>就是JVM的進(jìn)程號(hào)。
(在linux下)先執(zhí)行ps aux | grep java,找到JVM的pid;然后再執(zhí)行jmap -dump:format=b,file=heap.bin <pid>,得到heap dump文件。
analyze heap
將二進(jìn)制的heap dump文件解析成human-readable的信息,自然是需要專業(yè)工具的幫助,這里推薦Memory Analyzer 。
Memory Analyzer,簡(jiǎn)稱MAT,是Eclipse基金會(huì)的開源項(xiàng)目,由SAP和IBM捐助。巨頭公司出品的軟件還是很中用的,MAT可以分析包含數(shù)億級(jí)對(duì) 象的heap、快速計(jì)算每個(gè)對(duì)象占用的內(nèi)存大小、對(duì)象之間的引用關(guān)系、自動(dòng)檢測(cè)內(nèi)存泄露的嫌疑對(duì)象,功能強(qiáng)大,而且界面友好易用。
MAT的界面基于Eclipse開發(fā),以兩種形式發(fā)布:Eclipse插件和Eclipe RCP。MAT的分析結(jié)果以圖片和報(bào)表的形式提供,一目了然??傊畟€(gè)人還是非常喜歡這個(gè)工具的。下面先貼兩張官方的screenshots:


言歸正傳,我用MAT打開了heap.bin,很容易看出,char[]的數(shù)量出其意料的多,占用90%以上的內(nèi)存 。一般來說,char[]在JVM確實(shí)會(huì)占用很多內(nèi)存,數(shù)量也非常多,因?yàn)镾tring對(duì)象以char[]作為內(nèi)部存儲(chǔ)。但是這次的char[]太貪婪 了,仔細(xì)一觀察,發(fā)現(xiàn)有數(shù)萬計(jì)的char[],每個(gè)都占用數(shù)百K的內(nèi)存 。這個(gè)現(xiàn)象說明,Java程序保存了數(shù)以萬計(jì)的大String對(duì)象 。結(jié)合程序的邏輯,這個(gè)是不應(yīng)該的,肯定在某個(gè)地方出了問題。
順藤摸瓜
在可疑的char[]中,任意挑了一個(gè),使用Path To GC Root功能,找到該char[]的引用路徑,發(fā)現(xiàn)String對(duì)象是被一個(gè)HashMap中引用的 。這個(gè)也是意料中的事情,Java的內(nèi)存泄露多半是因?yàn)閷?duì)象被遺留在全局的HashMap中得不到釋放。不過,該HashMap被用作一個(gè)緩存,設(shè)置了緩 存條目的閾值,導(dǎo)達(dá)到閾值后會(huì)自動(dòng)淘汰。從這個(gè)邏輯分析,應(yīng)該不會(huì)出現(xiàn)內(nèi)存泄露的。雖然緩存中的String對(duì)象已經(jīng)達(dá)到數(shù)萬計(jì),但仍然沒有達(dá)到預(yù)先設(shè)置 的閾值(閾值設(shè)置地比較大,因?yàn)楫?dāng)時(shí)預(yù)估String對(duì)象都比較?。?。
但是,另一個(gè)問題引起了我的注意:為什么緩存的String對(duì)象如此巨大??jī)?nèi)部char[]的長(zhǎng)度達(dá)數(shù)百K。雖然緩存中的 String對(duì)象數(shù)量還沒有達(dá)到閾值,但是String對(duì)象大小遠(yuǎn)遠(yuǎn)超出了我們的預(yù)期,最終導(dǎo)致內(nèi)存被大量消耗,形成內(nèi)存泄露的跡象(準(zhǔn)確說應(yīng)該是內(nèi)存消 耗過多) 。
就這個(gè)問題進(jìn)一步順藤摸瓜,看看String大對(duì)象是如何被放到HashMap中的。通過查看程序的源代碼,我發(fā)現(xiàn),確實(shí)有String大對(duì)象,不 過并沒有把String大對(duì)象放到HashMap中,而是把String大對(duì)象進(jìn)行split(調(diào)用String.split方法),然后將split出 來的String小對(duì)象放到HashMap中 了。
這就奇怪了,放到HashMap中明明是split之后的String小對(duì)象,怎么會(huì)占用那么大空間呢?難道是String類的split方法有問題?
查看代碼
帶著上述疑問,我查閱了Sun JDK6中String類的代碼,主要是是split方法的實(shí)現(xiàn):
public
String[] split(String regex, int limit) {
return Pattern.compile(regex).split(this, limit);
}
可以看出,Stirng.split方法調(diào)用了Pattern.split方法。繼續(xù)看Pattern.split方法的代碼:
public
String[] split(CharSequence input, int limit) {
int index = 0;
boolean matchLimited = limit > 0;
ArrayList<String> matchList = new
ArrayList<String>();
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() < limit)
matchList.add(input.subSequence(index,
input.length()).toString());
// Construct result
int resultSize = matchList.size();
if (limit == 0)
while (resultSize > 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小對(duì)象,它其實(shí)是String大對(duì)象subSequence的結(jié)果。繼續(xù)看 String.subSequence的代碼:
public
CharSequence subSequence(int beginIndex, int endIndex) {
return this.substring(beginIndex, endIndex);
}
String.subSequence有調(diào)用了String.subString,繼續(xù)看:
public String
substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > 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的內(nèi)容就是完整的原字符串,那么返回原String對(duì)象;否則,就會(huì)創(chuàng)建一個(gè)新的 String對(duì)象,但是這個(gè)String對(duì)象貌似使用了原String對(duì)象的char[]。我們通過String的構(gòu)造函數(shù)確認(rèn)這一點(diǎn):
// 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;
}
為了避免內(nèi)存拷貝、加快速度,Sun JDK直接復(fù)用了原String對(duì)象的char[],偏移量和長(zhǎng)度來標(biāo)識(shí)不同的字符串內(nèi)容。也就是說,subString出的來String小對(duì)象 仍然會(huì)指向原String大對(duì)象的char[],split也是同樣的情況 。這就解釋了,為什么HashMap中String對(duì)象的char[]都那么大。
原因解釋
其實(shí)上一節(jié)已經(jīng)分析出了原因,這一節(jié)再整理一下:
程序從每個(gè)請(qǐng)求中得到一個(gè)String大對(duì)象,該對(duì)象內(nèi)部char[]的長(zhǎng)度達(dá)數(shù)百K。
程序?qū)tring大對(duì)象做split,將split得到的String小對(duì)象放到HashMap中,用作緩存。
Sun JDK6對(duì)String.split方法做了優(yōu)化,split出來的Stirng對(duì)象直接使用原String對(duì)象的char[]
HashMap中的每個(gè)String對(duì)象其實(shí)都指向了一個(gè)巨大的char[]
HashMap的上限是萬級(jí)的,因此被緩存的Sting對(duì)象的總大小=萬*百K=G級(jí)。
G級(jí)的內(nèi)存被緩存占用了,大量的內(nèi)存被浪費(fèi),造成內(nèi)存泄露的跡象。
解決方案
原因找到了,解決方案也就有了。split是要用的,但是我們不要把split出來的String對(duì)象直接放到HashMap中,而是調(diào)用一下 String的拷貝構(gòu)造函數(shù)String(String original),這個(gè)構(gòu)造函數(shù)是安全的,具體可以看代碼:
/**
* 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應(yīng)該提供一個(gè)選項(xiàng),讓程序員控制是否復(fù)用String對(duì)象的 char[]。
是否Bug
雖然,subString和split的實(shí)現(xiàn)造成了現(xiàn)在的問題,但是這能否算String類的bug呢?個(gè)人覺得不好說。因?yàn)檫@樣的優(yōu)化是比較合理 的,subString和spit的結(jié)果肯定是原字符串的連續(xù)子序列。只能說,String不僅僅是一個(gè)核心類,它對(duì)于JVM來說是與原始類型同等重要的 類型。
JDK實(shí)現(xiàn)對(duì)String做各種可能的優(yōu)化都是可以理解的。但是優(yōu)化帶來了憂患,我們程序員足夠了解他們,才能用好他們。
一些補(bǔ)充
有個(gè)地方我沒有說清楚。
我的程序是一個(gè)Web程序,每次接受請(qǐng)求,就會(huì)創(chuàng)建一個(gè)大的String對(duì)象,然后對(duì)該String對(duì)象進(jìn)行split,最后split之后的String對(duì)象放到全局緩存中。如果接收了5W個(gè)請(qǐng)求,那么就會(huì)有5W個(gè)大String對(duì)象。這5W個(gè)大String對(duì)象都被存儲(chǔ)在全局緩存中,因此會(huì)造成內(nèi)存泄漏。我原以為緩存的是5W個(gè)小String,結(jié)果都是大String。
有同學(xué)后續(xù)建議用"java.io.StreamTokenizer"來解決本文的問題。確實(shí)是終極解決方案,比我上面提到的“new String()”,要好很多很多。
以上就是Java 分析并解決內(nèi)存泄漏的實(shí)例的詳細(xì)內(nèi)容,更多關(guān)于Java 內(nèi)存泄漏的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
- Java中的內(nèi)存泄漏
- 排查Java應(yīng)用內(nèi)存泄漏問題的步驟
- Java內(nèi)部類的實(shí)現(xiàn)原理與可能的內(nèi)存泄漏說明
- macOS上使用gperftools定位Java內(nèi)存泄漏問題及解決方案
- Java虛擬機(jī)內(nèi)存溢出與內(nèi)存泄漏
- Android性能優(yōu)化之利用Rxlifecycle解決RxJava內(nèi)存泄漏詳解
- Java中關(guān)于內(nèi)存泄漏出現(xiàn)的原因匯總及如何避免內(nèi)存泄漏(超詳細(xì)版)
- 解析Java的JNI編程中的對(duì)象引用與內(nèi)存泄漏問題
- Java基礎(chǔ)詳解之內(nèi)存泄漏
相關(guān)文章
springboot整合apache ftpserver詳細(xì)教程(推薦)
這篇文章主要介紹了springboot整合apache ftpserver詳細(xì)教程,本文給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-01-01
SpringBoot3整合Druid監(jiān)控功能的項(xiàng)目實(shí)踐
Druid連接池作為一款強(qiáng)大的數(shù)據(jù)庫連接池,提供了豐富的監(jiān)控和管理功能,成為很多Java項(xiàng)目的首選,本文主要介紹了SpringBoot3整合Druid監(jiān)控功能的項(xiàng)目實(shí)踐,感興趣的可以了解一下2024-01-01
springboot中@ConfigurationProperties無效果的解決方法
本文主要介紹了springboot中@ConfigurationProperties無效果,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2024-06-06
使用SpringBoot開發(fā)Restful服務(wù)實(shí)現(xiàn)增刪改查功能
Spring Boot是由Pivotal團(tuán)隊(duì)提供的全新框架,其設(shè)計(jì)目的是用來簡(jiǎn)化新Spring應(yīng)用的初始搭建以及開發(fā)過程。這篇文章主要介紹了基于SpringBoot開發(fā)一個(gè)Restful服務(wù),實(shí)現(xiàn)增刪改查功能,需要的朋友可以參考下2018-01-01
springboot中如何使用openfeign進(jìn)行接口調(diào)用
這篇文章主要介紹了springboot中如何使用openfeign進(jìn)行接口調(diào)用問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-07-07
Java實(shí)現(xiàn)單鏈表反轉(zhuǎn)的多種方法總結(jié)
這篇文章主要給大家介紹了關(guān)于Java實(shí)現(xiàn)單鏈表反轉(zhuǎn)的多種方法,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-04-04

