1. 程式人生 > 程式設計 >Java 分析並解決記憶體洩漏的例項

Java 分析並解決記憶體洩漏的例項

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

分析記憶體洩露的一般步驟

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

  1. 把Java應用程式使用的heap dump下來
  2. 使用Java heap分析工具,找出記憶體佔用超出預期(一般是因為數量太多)的嫌疑物件
  3. 必要時,需要分析嫌疑物件和其他物件的引用關係。
  4. 檢視程式的原始碼,找出嫌疑物件數量過多的原因。

dump heap

如果Java應用程式出現了記憶體洩露,千萬彆著急著把應用殺掉,而是要儲存現場。如果是網際網路應用,可以把流量切到其他伺服器。儲存現場的目的就是為了把 執行中JVM的heap dump下來。

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

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

format=b的含義是,dump出來的檔案時二進位制格式。
file-heap.bin的含義是,dump出來的檔名是heap.bin。
<pid>就是JVM的程序號。
(在linux下)先執行ps aux | grep java,找到JVM的pid;然後再執行jmap -dump:format=b,file=heap.bin <pid>,得到heap dump檔案。

analyze heap

將二進位制的heap dump檔案解析成human-readable的資訊,自然是需要專業工具的幫助,這裡推薦Memory Analyzer 。

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

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

Java 分析並解決記憶體洩漏的例項

Java 分析並解決記憶體洩漏的例項

言歸正傳,我用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<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小物件,它其實是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 < 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的內容就是完整的原字串,那麼返回原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 分析並解決記憶體洩漏的例項的詳細內容,更多關於Java 記憶體洩漏的資料請關注我們其它相關文章!