本期的案例依然是來自實際項目,很尋常的代碼,卻意外遭遇傳說中的Java"內存溢出"。
先來看看發生了什么,代碼邏輯很簡單,在請求的處理過程中:
1. 創建了一個ArrayList,然后往這個list里面放了一些數據,得到了一個size很大的list
List cdrInfoList = new ArrayList();
for(...) {
cdrInfoList.add(cdrInfo);
}
2. 從這個list里面,取出一個size很小的sublist(我們忽略這里的業務邏輯)
cdrSublist = cdrInfoList.subList(fromIndex, toIndex)
3. 這個cdrSublist被作為value保存到一個常駐內存的Map中(同樣我們忽略這里的業務邏輯)
cache.put(key, cdrSublist);
4. 請求處理結果,原有的list和其他數據被拋棄
正常情況下保存到cdrSublist不是太多,其內存消耗應該很小,但是實際上sig的同事們在用JMAP工具檢查SIG的內存時,卻發現這 里的subList()方法生成的RandomAccessSubList占用的內存高達1.6G! 完全不合符常理。
我們來細看subList()和RandomAccessSubList在這里都干了些什么:詳細的代碼實現追蹤過程請見附錄1,我們來看關鍵代碼,類SubList的實現代碼,忽略不相關的內容
class SubList<E> extends AbstractList<E> {
private AbstractList<E> l;
private int offset;
private int size;
SubList(AbstractList<E> list, int fromIndex, int toIndex) {
......
l = list;
offset = fromIndex;
size = toIndex - fromIndex;
}
這里我們可以清楚的看到SubList的實現原理:
1. 保存一個原始list對象的引用
2. 用offset和size來表明當前sublist的在原始list中的范圍
為了讓大家有一個感性的認識,我們用debug模式跑了一下測試代碼,截圖如下:
可以看到生成的sublist對象內有一個名為"l"的屬性,這是一個ArrayList對象,注意它的id和原有的list對象相同(圖中都是id=33)。
這種實現方式主要是考慮運行時性能,可以比較一下普通的sublist實現:
public List<E> subList(int fromIndex, int toIndex) {
List<E> result = ...; // new a empty list
for(int i = fromIndex; i <= toIndex; i++) {
result.add(this.get(i));
}
return result;
}
這種實現需要創建新的list對象,然后添加所需內容,相比之下無論是內存消耗還是運行效率都不如前面SubList直接引用原始 list+記錄偏差量的方式。
但是SubList的這種方式,會有一個極大的隱患:這個SubList的實例中,保存有原有list對象的引用——而且是強引用,這意味著, 只要sublist沒有被jvm回收,那么這個原有list對象就不能gc,這個list中保存的所有對象也不能gc,即使這個list和其包含的對象已經沒有其他任何引用。
這個就是Java世界中“內存泄露"的一個經典實例:某些被期望能被JVM回收的對象,卻因為某個沒有被覺察到的角落中"偷偷的"保留 了一個引用而躲過GC......在SIG的這個例子中,我們本來只想在內存中保留很少很少的一點點數據,被意外的將整個list和它包含的所 有對象都留下來。注意在截圖中,list的size為100000,而sublist只是1而已,這就是我們標題中所說的"冰山一角"。
這里有一段實例代碼,大家可以運行一下,很快就可以看到Java世界中名聲顯赫的OOM:
public class SublistTest {
public static void main(String[] args) {
List<List<Integer>> cache = new ArrayList<List<Integer>>();
try {
while (true) {
List<Integer> list = new ArrayList<Integer>();
for (int j = 0; j < 100000; j++) {
list.add(j);
}
List<Integer> sublist = list.subList(0, 1);
cache.add(sublist);
}
} finally {
System.out.println("cache size = " + cache.size());
}
}
}
在我的測試中,打印結果為"cache size = 121",也就是說我的測試中121個list,每個list里面只放了一個Integer對象,就可以吃 掉所有內存,造成out of memory.
仔細的同學會發現,其實在sublist()方法的javadoc里面,已經對此有明確的說明,“The returned list is backed by this list” ,因此提醒大家在使用某個不熟悉的方法之前最好讀一讀Javadoc:
Returns a view of the portion of this list between fromIndex, inclusive, and toIndex, exclusive. (If fromIndex and toIndex are equal, the returned list is empty.) The returned list is backed by this list, so changes in the returned list are reflected in this list, and vice-versa. The returned list supports all of the optional list operations supported by this list.
同樣的,在java中還有一個非常類似的案例,來自最常見的String類,它的substring()方法和split()方法,大家可以翻開jdk 的源碼看到具體代碼。原理和sublist()方法非常類似,就不重復解釋了。
簡單給出一段代碼,演示一下substring()方法在類似情景下是如何OOM的:
public class SubstringTest {
public static void main(String[] args) {
List<String> cache = new ArrayList<String>();
try {
int i = 1;
while (true) {
String original = buildABigString(i++);
String substring = original.substring(0, 1);
cache.add(substring);
}
} finally {
System.out.println("cache size = " + cache.size());
}
}
private static String buildABigString(int count) {
long thistime = System.currentTimeMillis() + count;
StringBuilder buf = new StringBuilder(1024 * 100);
for(int i = 0; i < 10000; i++) {
buf.append(thistime);
}
return buf.toString();
}
}
這一次,我的測試用只用了994個長度為1的字符串,就"成功"達到了OOM。
最后談一下怎么解決上面的問題,當然前提是我們有需要將得到的小的list或者string長時間存放在內存中:
1. 對于sublist()方法得到的list,貌似沒有太好的辦法,只能用最直接的方式:自己創建新的list,然后將需要的內容添加進去
2. 對于substring()/split()方法得到的string,可以用String類的構造函數new String(String original)來創建一個新的String,這 樣會重新創建底層的char[]并復制需要的內容,不會造成"浪費"。
String類的構造函數new String(String original)是一個非常特別的構造函數,通常沒有必要使用,正如這個函數的javadoc所言 :Unless an explicit copy of original is needed, use of this constructor is unnecessary since Strings are immutable. 除非明確需要原始字符串的拷貝,否則沒有必要使用這個構造函數,因為String是不可變的。
但是對于前面的這種特殊場景(從超大字符串中substring()得到后再放置到常駐內存的結構中),new String(String original)就 可以將我們從這種潛在的內存溢出(或者浪費)中拯救出來。因此,當遇到同時處理大字符串+長時間放置內容在內存中時,請小心。
最后鳴謝Ray Tao同學為本次分享提供素材!
附錄:List.sublist() 代碼實現追蹤
1. ArrayList的代碼,繼承自AbstractList,實現了RandomAccess接口
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
2. AbstractList類的subList()函數的代碼,對于ArrayList,返回RandomAccessSubList的實例
public List<E> subList(int fromIndex, int toIndex) {
return (this instanceof RandomAccess ?
new RandomAccessSubList<E>(this, fromIndex, toIndex) :
new SubList<E>(this, fromIndex, toIndex));
}
3. RandomAccessSubList的代碼,繼承自SubList
class RandomAccessSubList<E> extends SubList<E> implements RandomAccess {
RandomAccessSubList(AbstractList<E> list, int fromIndex, int toIndex) {
super(list, fromIndex, toIndex);
}
public List<E> subList(int fromIndex, int toIndex) {
return new RandomAccessSubList<E>(this, fromIndex, toIndex);
}
}