前言:本以為(OutOfMemoryError)OOM問題會離我們很遠,但在一次生產上線灰度的過程中就出現了Java.Lang.OutOfMemoryError:Java heap space異常,通過對線上日志的查看,最終定位到ArrayList#addAll方法中,出現這個問題的原因是:由於歷史原因有個接口的響應時間經常超時,所以筆者對其進行了優化,之前使用的是ArrayList#add方法,筆者通過一系列修改后將add方法修改為了addAll方法,導致內存溢出。但具體是怎樣產生的呢,下面對其詳細分析。
ArrayList的內部原理
談起ArrayList想必大家在日常中經常使用,用於存儲一系列的元素。由於筆者在使用過程中出現了OOM異常,這里有必要對其內部原理進行簡單的分析:
#1.ArrayList底層采用數組來存儲數據,查找速度快,畢竟直接使用數組下標進行數據的查找。這里有一點特別重要其內部的數據存儲結構為數組。
#2.數組:數組是一種線性表數據結構,它是一組連續的內存空間。注意:一組連續的內存空間,這就意味着在申請數組時如果不能滿足連續的內存空間,哪怕是內存足夠也會導致OOM問題。
#3.ArrayList的默認容量為10,超過10時,會進行擴容:int newCapacity = oldCapacity + (oldCapacity >> 1);相當於擴大為原來的1.5倍。其擴容函數如下:
1 private void grow(int minCapacity) { 2 // overflow-conscious code 3 // 獲得當前ArrayList的大小 4 int oldCapacity = elementData.length; 5 // 進行擴容,擴大為原來的1.5倍,那為什么不直接*1.5呢,因為位操作速度更快 6 int newCapacity = oldCapacity + (oldCapacity >> 1); 7 // minCapacity參數為擴容前確認的數組大小參數,將在下面進行分析 8 // 如果新容量比minCapacity小,說明容量不夠,則使用minCapacity 9 if (newCapacity - minCapacity < 0) 10 newCapacity = minCapacity; 11 // 如果newCapacity大於最大ArrayList承受的最大值,則計算最大值 12 if (newCapacity - MAX_ARRAY_SIZE > 0) 13 newCapacity = hugeCapacity(minCapacity); 14 // minCapacity is usually close to size, so this is a win: 15 // 進行擴容 16 elementData = Arrays.copyOf(elementData, newCapacity); 17 }
分析:上述擴容函數涉及到幾個變量minCapacity、MAX_ARRAY_SIZE,下面將對其進行解釋。
關於minCapacity變量通過ArrayList#addAll函數進行分析(add函數其實一樣):
1 public boolean addAll(Collection<? extends E> c) { 2 Object[] a = c.toArray(); 3 // 獲取要插入集合的長度 4 int numNew = a.length; 5 // 確認容量大小,擴容也就是在該函數中進行操作 6 ensureCapacityInternal(size + numNew); // Increments modCount 7 // 將要插入的數據拷貝至數組尾部 8 System.arraycopy(a, 0, elementData, size, numNew); 9 size += numNew; 10 return numNew != 0; 11 }
1 private void ensureCapacityInternal(int minCapacity) { 2 ensureExplicitCapacity(calculateCapacity(elementData, minCapacity)); 3 } 4 5 private void ensureExplicitCapacity(int minCapacity) { 6 modCount++; 7 8 // overflow-conscious code 9 // 所需容量大於當前數組容量,則進行擴容 10 if (minCapacity - elementData.length > 0) 11 grow(minCapacity); 12 }
分析:
#1.ArrayList的擴容入口就是ensureCapacityInternal函數,其入參為當前ArrayList存儲容量與要處理集合容量的和。
#2.然后通過calculateCapacity函數進行容量確認:
1 private static int calculateCapacity(Object[] elementData, int minCapacity) { 2 // 如果當前數組為空,則從默認值(10)與minCapacity(當前ArrayList容量+要插入集合容量之和)中取最大值 3 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { 4 return Math.max(DEFAULT_CAPACITY, minCapacity); 5 } 6 // 否則直接返回minCapacity 7 return minCapacity; 8 }
#3.在ensureExplicitCapacity函數中進行具體擴容,也就是調用grow函數。
在grow函數中有一個變量需要注意一下MAX_ARRAY_SIZE:
注釋已講的非常清楚:嘗試去分配最大容量的數組內存也許會造成OOM異常。
還有這里為什么要用Integer.MAX_VALUE-8呢,因為數組在虛擬機中存儲時需要8字節來存儲其自身的大小。
#4.ArrayList的擴容是通過Array.copyOf函數進行的:
1 public static <T> T[] copyOf(T[] original, int newLength) { 2 // original需要被拷貝的原數據集合 3 // newLength新的數組長度 4 return (T[]) copyOf(original, newLength, original.getClass()); 5 } 6 public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) { 7 @SuppressWarnings("unchecked") 8 // 申請內存空間,如果這里沒有連續的內存空間,則會拋出OOM異常 9 T[] copy = ((Object)newType == (Object)Object[].class) 10 ? (T[]) new Object[newLength] 11 : (T[]) Array.newInstance(newType.getComponentType(), newLength); 12 // 將原數組拷貝到新空間中 13 System.arraycopy(original, 0, copy, 0, 14 Math.min(original.length, newLength)); 15 return copy; 16 }
分析:
關鍵在上述代碼第8行中,申請新的內存空間,由於是數組,需要連續的內存空間,如果當前無連續的內存空間,哪怕內存足夠也會拋出OOM異常。
通過對ArrayList的源碼分析,就可以得出出現OOM原因的關鍵點了。這里貼上當時灰度環境JVM的堆內存走勢圖:
從以上JVM監控圖可以清楚的看到堆內存從0直接飆到了2G,在2G后出現了OOM異常,並且此時JVM進行了垃圾回收,幸好沒有把當前節點拖崩,萬幸!!!
在同樣的數據量下為什么用add未拋OOM異常,而用addAll確拋了OOM異常呢
在同樣數據量的情況下,之前的代碼使用了ArrayList#add方法未出現問題,而使用ArrayList#addAll方法卻拋出了OOM異常呢,通過源碼進行比較:
ArrayList#add:
ArrayList#addAll
通過對源碼進行比較可知,ArrayList#add方法每次容量確定:size+1,而ArrayList#addAll每次是size+numNew(要插入的容量)。在ArrayList#add方法插入數據進行擴容時,每次都是擴容器為其1.5倍,並且擴容並不是那么頻繁,需要達到臨界點,而ArrayList#addAll不確定,需要依賴numNew大小。
在使用ArrayList#addAll方法時,如果插入集合的過大,而且該方法處於循環中,就會導致擴容非常的頻繁,在JVM來不及進行垃圾回收的情況下,就會導致OOM異常。
最終的解決方法:在初始化ArrayList的時候,盡量知道所需存儲元素的容量或者避免其頻繁擴容,就有很大的機會避免OOM異常,筆者的解決方法就是如此。在通過其他途徑得知了每次的ArrayList大小,最終解決了這個問題,由於是公司代碼,這里就不貼具體代碼了,其實在灰度時也把我嚇了一跳。
總結
本文來源於筆者在生產環境中遇到的問題(線上數據量太大,在QA環境中並未出現該問題),通過對ArrayList源碼的分析,最終找到問題出現的核心點,通過及時的修改,再次上線后該問題得到解決,因此特別記錄下該問題,並以此為戒。
#1.在使用ArrayList的時候,盡量對其進行容量大小的初始化,避免其頻繁擴容,造成OOM異常,線上出現該問題真的很恐怖。
#2.出現問題也不要過於驚慌,及時發現問題,並解決,也許你會有不小的收獲。
#3.本次問題幸好出現在灰度環境,並未全量,這是不幸中的萬幸,下次一定注意、注意、注意!!!
by Shawn Chen,2019.07.14日,下午。