深度解讀ArrayMap優勢與缺陷
ArrayMap在記憶體使用上較HashMap更有優勢,在Android開發中廣為使用的基礎API,也是大家所推薦的方法, 但你是否想過Google如此重要的基礎類存在缺陷?
一、引言
在移動裝置端記憶體資源很珍貴,HashMap為實現快速查詢帶來了很大記憶體的浪費。為此,2013年5月20日Google工程師Dianne Hackborn在Android系統原始碼中新增ArrayMap類,從Android原始碼中發現有不少提交專門把之前使用HashMap的地方改用ArrayMap,不僅如此,大量的應用開發者中廣為使用。
然後, 你是否研究過這麼廣泛使用的基礎資料結構存在缺陷? 要回答這個問題,需要先從原始碼角度來理解ArrayMap的原理。
ArrayMap是Android專門針對記憶體優化而設計的,用於取代Java API中的HashMap資料結構。為了更進一步優化key是int型別的Map,Android再次提供效率更高的資料結構SparseArray,可避免自動裝箱過程。對於key為其他型別則可使用ArrayMap。HashMap的查詢和插入時間複雜度為O(1)的代價是犧牲大量的記憶體來實現的,而SparseArray和ArrayMap效能略遜於HashMap,但更節省記憶體。
接下來,從原始碼看看ArrayMap,為了全面解讀,文章有點長,請耐心閱讀。
二、源讀ArrayMap
2.1 基本成員變數
public final class ArrayMap<K, V> implements Map<K, V> { private static final boolean CONCURRENT_MODIFICATION_EXCEPTIONS = true; private static final int BASE_SIZE = 4;// 容量增量的最小值 private static final int CACHE_SIZE = 10; // 快取陣列的上限 static Object[] mBaseCache; //用於快取大小為4的ArrayMap static int mBaseCacheSize; static Object[] mTwiceBaseCache; //用於快取大小為8的ArrayMap static int mTwiceBaseCacheSize; final boolean mIdentityHashCode; int[] mHashes;//由key的hashcode所組成的陣列 Object[] mArray;//由key-value對所組成的陣列,是mHashes大小的2倍 int mSize;//成員變數的個數 }
1)ArrayMap物件的資料儲存格式如圖所示:
- mHashes是一個記錄所有key的hashcode值組成的陣列,是從小到大的排序方式;
- mArray是一個記錄著key-value鍵值對所組成的陣列,是mHashes大小的2倍;

其中mSize記錄著該ArrayMap物件中有多少對資料,執行put()或者append()操作,則mSize會加1,執行remove(),則mSize會減1。mSize往往小於mHashes.length,如果mSize大於或等於mHashes.length,則說明mHashes和mArray需要擴容。
2)ArrayMap類有兩個非常重要的靜態成員變數mBaseCache和mTwiceBaseCacheSize,用於ArrayMap所在程序的全域性快取功能:
- mBaseCache:用於快取大小為4的ArrayMap,mBaseCacheSize記錄著當前已快取的數量,超過10個則不再快取;
- mTwiceBaseCacheSize:用於快取大小為8的ArrayMap,mTwiceBaseCacheSize記錄著當前已快取的數量,超過10個則不再快取。
為了減少頻繁地建立和回收Map物件,ArrayMap採用了兩個大小為10的快取佇列來分別儲存大小為4和8的Map物件。為了節省記憶體有更加保守的記憶體擴張以及記憶體收縮策略。 接下來分別說說快取機制和擴容機制。
2.2 快取機制
ArrayMap是專為Android優化而設計的Map物件,使用場景比較高頻,很多場景可能起初都是資料很少,為了減少頻繁地建立和回收,特意設計了兩個快取池,分別快取大小為4和8的ArrayMap物件。要理解快取機制,那就需要看看記憶體分配(allocArrays)和記憶體釋放(freeArrays)。
2.2.1 freeArrays
private static void freeArrays(final int[] hashes, final Object[] array, final int size) { if (hashes.length == (BASE_SIZE*2)) {//當釋放的是大小為8的物件 synchronized (ArrayMap.class) { // 當大小為8的快取池的數量小於10個,則將其放入快取池 if (mTwiceBaseCacheSize < CACHE_SIZE) { array[0] = mTwiceBaseCache;//array[0]指向原來的快取池 array[1] = hashes; for (int i=(size<<1)-1; i>=2; i--) { array[i] = null;//清空其他資料 } mTwiceBaseCache = array; //mTwiceBaseCache指向新加入快取池的array mTwiceBaseCacheSize++; } } } else if (hashes.length == BASE_SIZE) {//當釋放的是大小為4的物件,原理同上 synchronized (ArrayMap.class) { if (mBaseCacheSize < CACHE_SIZE) { array[0] = mBaseCache; array[1] = hashes; for (int i=(size<<1)-1; i>=2; i--) { array[i] = null; } mBaseCache = array; mBaseCacheSize++; } } } }
最初mTwiceBaseCache和mBaseCache快取池中都沒有資料,在freeArrays釋放記憶體時,如果同時滿足釋放的array大小等於4或者8,且相對應的緩衝池個數未達上限,則會把該arrya加入到快取池中。加入的方式是將陣列array的第0個元素指向原有的快取池,第1個元素指向hashes陣列的地址,第2個元素以後的資料全部置為null。再把快取池的頭部指向最新的array的位置,並將該快取池大小執行加1操作。具體如下所示。

cache_add
freeArrays()觸發時機:
- 當執行removeAt()移除最後一個元素的情況
- 當執行clear()清理的情況
- 當執行ensureCapacity()在當前容量小於預期容量的情況下, 先執行allocArrays,再執行freeArrays
- 當執行put()在容量滿的情況下, 先執行allocArrays, 再執行freeArrays
2.2.2 allocArrays
private void allocArrays(final int size) { if (size == (BASE_SIZE*2)) {//當分配大小為8的物件,先檢視快取池 synchronized (ArrayMap.class) { if (mTwiceBaseCache != null) { // 當快取池不為空時 final Object[] array = mTwiceBaseCache; mArray = array;//從快取池中取出mArray mTwiceBaseCache = (Object[])array[0]; //將快取池指向上一條快取地址 mHashes = (int[])array[1];//從快取中mHashes array[0] = array[1] = null; mTwiceBaseCacheSize--;//快取池大小減1 return; } } } else if (size == BASE_SIZE) { //當分配大小為4的物件,原理同上 synchronized (ArrayMap.class) { if (mBaseCache != null) { final Object[] array = mBaseCache; mArray = array; mBaseCache = (Object[])array[0]; mHashes = (int[])array[1]; array[0] = array[1] = null; mBaseCacheSize--; return; } } } // 分配大小除了4和8之外的情況,則直接建立新的陣列 mHashes = new int[size]; mArray = new Object[size<<1]; }
當allocArrays分配記憶體時,如果所需要分配的大小等於4或者8,且相對應的緩衝池不為空,則會從相應快取池中取出快取的mArray和mHashes。從快取池取出快取的方式是將當前快取池賦值給mArray,將快取池指向上一條快取地址,將快取池的第1個元素賦值為mHashes,再把mArray的第0和第1個位置的資料置為null,並將該快取池大小執行減1操作,具體如下所示。

cache_delete
allocArrays觸發時機:
- 當執行ArrayMap的建構函式的情況
- 當執行removeAt()在滿足容量收緊機制的情況
- 當執行ensureCapacity()在當前容量小於預期容量的情況下, 先執行allocArrays,再執行freeArrays
- 當執行put()在容量滿的情況下, 先執行allocArrays, 再執行freeArrays
這裡需要注意的是隻有大小為4或者8的記憶體分配才有可能從快取池取資料,因為freeArrays過程放入快取池的大小隻有4或8,對於其他大小的記憶體分配則需要建立新的陣列。 優化小技巧,對於分配資料不超過8的物件的情況下,一定要建立4或者8大小,否則浪費了快取機制。比如ArrayMap[7]就是不友好的寫法,建議寫成ArrayMap[8]。
2.3 擴容機制
2.3.1 容量擴張
public V put(K key, V value) { ... final int osize = mSize; if (osize >= mHashes.length) { //當mSize大於或等於mHashes陣列長度時需要擴容 final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1)) : (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE); allocArrays(n);//分配更大的記憶體【小節2.2.2】 } ... }
當mSize大於或等於mHashes陣列長度時則擴容,完成擴容後需要將老的陣列拷貝到新分配的陣列,並釋放老的記憶體。
- 當map個數滿足條件 osize<4時,則擴容後的大小為4;
- 當map個數滿足條件 4<= osize < 8時,則擴容後的大小為8;
- 當map個數滿足條件 osize>=8時,則擴容後的大小為原來的1.5倍;
可見ArrayMap大小在不斷增加的過程,size的取值一般情況依次會是4,8,12,18,27,40,60,…
2.3.2 容量收緊
public V removeAt(int index) { final int osize = mSize; final int nsize; if (osize > 1) {//當mSize大於1的情況,需要根據情況來決定是否要收緊 nsize = osize - 1; if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) { final int n = osize > (BASE_SIZE*2) ? (osize + (osize>>1)) : (BASE_SIZE*2); allocArrays(n); // 分配更小的記憶體【小節2.2.2】 } } }
當陣列記憶體的大小大於8,且已儲存資料的個數mSize小於陣列空間大小的1/3的情況下,需要收緊資料的內容容量,分配新的陣列,老的記憶體靠虛擬機器自動回收。
- 如果mSize<=8,則設定新大小為8;
- 如果mSize> 8,則設定新大小為mSize的1.5倍。
也就是說在資料較大的情況下,當記憶體使用量不足1/3的情況下,記憶體陣列會收緊50%。
2.4 基本成員方法
2.4.1 構造方法
public ArrayMap() { this(0, false); } //指定初始容量大小 public ArrayMap(int capacity) { this(capacity, false); } /** {@hide} */ 這是一個隱藏方法 public ArrayMap(int capacity, boolean identityHashCode) { mIdentityHashCode = identityHashCode; if (capacity < 0) { mHashes = EMPTY_IMMUTABLE_INTS; mArray = EmptyArray.OBJECT; } else if (capacity == 0) { mHashes = EmptyArray.INT; mArray = EmptyArray.OBJECT; } else { allocArrays(capacity); // 分配記憶體【小節2.2.2】 } mSize = 0;//初始值為0 } public ArrayMap(ArrayMap<K, V> map) { this(); if (map != null) { putAll(map); } } public void putAll(Map<? extends K, ? extends V> map) { //確保map的大小至少為mSize + map.size(),如果預設已滿足條件則不用擴容 ensureCapacity(mSize + map.size()); for (Map.Entry<? extends K, ? extends V> entry : map.entrySet()) { put(entry.getKey(), entry.getValue()); } }
針對構造方法,如果指定大小則會去分配相應大小的記憶體,如果沒有指定預設為0,當需要新增資料的時候再擴容。
2.4.2 put()
public V put(K key, V value) { final int osize = mSize; //osize記錄當前map大小 final int hash; int index; if (key == null) { hash = 0; index = indexOfNull(); } else { //預設mIdentityHashCode=false hash = mIdentityHashCode ? System.identityHashCode(key) : key.hashCode(); //採用二分查詢法,從mHashes陣列中查詢值等於hash的key index = indexOf(key, hash); } //當index大於零,則代表的是從資料mHashes中找到相同的key,執行的操作等價於修改相應位置的value if (index >= 0) { index = (index<<1) + 1;//index的2倍+1所對應的元素存在相應value的位置 final V old = (V)mArray[index]; mArray[index] = value; return old; } //當index<0,則代表是插入新元素 index = ~index; if (osize >= mHashes.length) { //當mSize大於或等於mHashes陣列長度時,需要擴容【小節2.3.1】 final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1)) : (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE); final int[] ohashes = mHashes; final Object[] oarray = mArray; allocArrays(n);//分配更大的記憶體【小節2.2.2】 //由於ArrayMap並非執行緒安全的類,不允許並行,如果擴容過程其他執行緒調整mSize則丟擲異常 if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) { throw new ConcurrentModificationException(); } if (mHashes.length > 0) { //將原來老的陣列拷貝到新分配的陣列 System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length); System.arraycopy(oarray, 0, mArray, 0, oarray.length); } freeArrays(ohashes, oarray, osize); //釋放原來老的記憶體【小節2.2.2】 } //當需要插入的位置不在陣列末尾時,需要將index位置後的資料通過拷貝往後移動一位 if (index < osize) { System.arraycopy(mHashes, index, mHashes, index + 1, osize - index); System.arraycopy(mArray, index << 1, mArray, (index + 1) << 1, (mSize - index) << 1); } if (CONCURRENT_MODIFICATION_EXCEPTIONS) { if (osize != mSize || index >= mHashes.length) { throw new ConcurrentModificationException(); } } //將hash、key、value新增相應陣列的位置,資料個數mSize加1 mHashes[index] = hash; mArray[index<<1] = key; mArray[(index<<1)+1] = value; mSize++; return null; }
put()設計巧妙地將修改已有資料對(key-value) 和插入新的資料對合二為一個方法,主要是依賴indexOf()過程中採用的二分查詢法, 當找到相應key時則返回正值,但找不到key則返回負值,按位取反所對應的值代表的是需要插入的位置index。
put()在插入時,如果當前陣列內容已填充滿時,則會先進行擴容,再通過System.arraycopy來進行資料拷貝,最後在相應位置寫入資料。
static int binarySearch(int[] array, int size, int value) { int lo = 0; int hi = size - 1; while (lo <= hi) { final int mid = (lo + hi) >>> 1; final int midVal = array[mid]; if (midVal < value) { lo = mid + 1; } else if (midVal > value) { hi = mid - 1; } else { return mid;// value已找到 } } return ~lo;// value找不到 }
2.4.3 append()
public void append(K key, V value) { int index = mSize; final int hash = key == null ? 0 : (mIdentityHashCode ? System.identityHashCode(key) : key.hashCode()); //使用append前必須保證mHashes的容量足夠大,否則丟擲異常 if (index >= mHashes.length) { throw new IllegalStateException("Array is full"); } //當資料需要插入到陣列的中間,則呼叫put來完成 if (index > 0 && mHashes[index-1] > hash) { put(key, value); // 【小節2.4.1】 return; } //否則,資料直接新增到隊尾 mSize = index+1; mHashes[index] = hash; index <<= 1; mArray[index] = key; mArray[index+1] = value; }
append()過程跟put()很相似,append的差異在於該方法不會去做擴容的操作,是一個輕量級的插入方法。 那麼什麼場景適合使用append()方法呢?答應就是對於明確知道肯定會插入隊尾的情況下使用append()效能更好,因為put()上來先做binarySearchHashes()二分查詢,時間複雜度為O(logN),而append()的時間複雜度為O(1)。
2.4.4 remove()
public V remove(Object key) { final int index = indexOfKey(key); //通過二分查詢key的index if (index >= 0) { return removeAt(index); //移除相應位置的資料 } return null; } public V removeAt(int index) { final Object old = mArray[(index << 1) + 1]; final int osize = mSize; final int nsize; if (osize <= 1) {//當被移除的是ArrayMap的最後一個元素,則釋放該記憶體 freeArrays(mHashes, mArray, osize); mHashes = EmptyArray.INT; mArray = EmptyArray.OBJECT; nsize = 0; } else { nsize = osize - 1; //根據情況來收緊容量 【小節2.3.2】 if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) { final int n = osize > (BASE_SIZE*2) ? (osize + (osize>>1)) : (BASE_SIZE*2); final int[] ohashes = mHashes; final Object[] oarray = mArray; allocArrays(n); //分配一個更下容量的內容 //禁止併發 if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) { throw new ConcurrentModificationException(); } if (index > 0) { System.arraycopy(ohashes, 0, mHashes, 0, index); System.arraycopy(oarray, 0, mArray, 0, index << 1); } if (index < nsize) { System.arraycopy(ohashes, index + 1, mHashes, index, nsize - index); System.arraycopy(oarray, (index + 1) << 1, mArray, index << 1, (nsize - index) << 1); } } else { if (index < nsize) { //當被移除的元素不是陣列最末尾的元素時,則需要將後面的陣列往前移動 System.arraycopy(mHashes, index + 1, mHashes, index, nsize - index); System.arraycopy(mArray, (index + 1) << 1, mArray, index << 1, (nsize - index) << 1); } //再將最後一個位置設定為null mArray[nsize << 1] = null; mArray[(nsize << 1) + 1] = null; } } if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) { throw new ConcurrentModificationException(); } mSize = nsize; //大小減1 return (V)old; }
remove()過程:通過二分查詢key的index,再根據index來選擇移除動作;當被移除的是ArrayMap的最後一個元素,則釋放該記憶體,否則只做移除操作,這時會根據容量收緊原則來決定是否要收緊,當需要收緊時會建立一個更小記憶體的容量。
2.4.5 clear()
public void clear() { if (mSize > 0) { //當容量中元素不為空的情況 才會執行記憶體回收操作 final int[] ohashes = mHashes; final Object[] oarray = mArray; final int osize = mSize; mHashes = EmptyArray.INT; mArray = EmptyArray.OBJECT; mSize = 0; freeArrays(ohashes, oarray, osize); //【小節2.2.1】 } if (CONCURRENT_MODIFICATION_EXCEPTIONS && mSize > 0) { throw new ConcurrentModificationException(); } }
clear()清理操作會執行freeArrays()方法來回收記憶體,而類似的方法erase()則只會清空陣列內的資料,並不會回收記憶體。
三、ArrayMap缺陷分析
3.1 異常現象
有了前面的基礎,接下來看看ArrayMap的缺陷。事實上ArrayMap不恰當使用有概率導致系統重啟,對於不少應用在使用ArrayMap過程出現丟擲如下異常,以下是Gityuan通過利用缺陷模擬場景後,然後在單執行緒裡面首次執行如下語句則丟擲異常。
ArrayMap map = new ArrayMap(4);
這只是一條基本的物件例項化操作,居然也能報出如下異常,是不是很神奇?這是低概率問題,本地難以復現,之所以能模擬出來,是因為先把這個缺陷研究明白了,再做的模擬驗證過程。
FATAL EXCEPTION: Thread-20 Process: com.gityuan.arraymapdemo, PID: 29003 java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Object[] at com.gityuan.arraymapdemo.application.ArrayMap.allocArrays(ArrayMap.java:178) at com.gityuan.arraymapdemo.application.ArrayMap.<init>(ArrayMap.java:255) at com.gityuan.arraymapdemo.application.ArrayMap.<init>(ArrayMap.java:238) at com.gityuan.arraymapdemo.application.MainActivity$4.run(MainActivity.java:240)
先來看看異常呼叫棧所對應的程式碼如下:
private void allocArrays(final int size) { if (size == (BASE_SIZE*2)) { ... } else if (size == BASE_SIZE) { synchronized (ArrayMap.class) {//加鎖 if (mBaseCache != null) { final Object[] array = mBaseCache; mArray = array; mBaseCache = (Object[])array[0]; //丟擲異常 mHashes = (int[])array[1]; array[0] = array[1] = null; mBaseCacheSize--; return; } } } ... }
3.2 深入分析
從[小節2.2.1]freeArrays()可知,每一次放入快取池mBaseCache時,一定會把array[0]指向Object[]型別的緩衝頭。 並且mBaseCache的所有操作,都通過synchronized加鎖ArrayMap.class保護,不可能會有修改其他執行緒併發修改mBaseCache。 雖然mBaseCache會加鎖保護,但mArray並沒有加鎖保護。如果有機會把mBaseCache的引用傳遞出去,在其他地方修改的話是有可能出現問題的。
從異常呼叫棧來看說明從快取池中取出這條快取的第0號元素被破壞,由於ArrayMap是非執行緒安全的,除了靜態變數mBaseCache和mTwiceBaseCache加類鎖保護,其他成員變數並沒有保護。可能修改array[0]的地方put、append、removeAt、erase等方法,此處省去分析過程,最終有兩種情況:
- 場景一:這條快取資料array在放入快取池(freeArrays)後,被修改;
- 場景二:剛從快取池取出來(allocArrays)的同時,資料立刻被其他地方修改。
場景一:
//執行緒A public V removeAt(int index) { ... final int osize = mSize; if (osize <= 1) { freeArrays(mHashes, mArray, osize); //進入方法體 mHashes = EmptyArray.INT; mArray = EmptyArray.OBJECT; } ... } private static void freeArrays(final int[] hashes, final Object[] array, final int size) { if (hashes.length == (BASE_SIZE*2)) { ... } else if (hashes.length == BASE_SIZE) { synchronized (ArrayMap.class) { if (mBaseCacheSize < CACHE_SIZE) { array[0] = mBaseCache;// CODE 1:此處array就是mArray array[1] = hashes; for (int i=(size<<1)-1; i>=2; i--) { array[i] = null; } mBaseCache = array; mBaseCacheSize++; } } }
//執行緒B public V put(K key, V value) { ... mHashes[index] = hash; mArray[index<<1] = key;// CODE 2: 當index=0的情況,修改array[0] mArray[(index<<1)+1] = value; mSize++; return null; }
//執行緒C ArrayMap map = new ArrayMap(4); //CODE 3: 躺槍
有三個執行緒,執行流程如下:
- 首先執行緒A執行到剛執行完freeArrays的CODE 1處程式碼;
- 然後執行緒B開始執行put()的CODE 2處程式碼,再次修改array[0]為String字串;那麼此時快取池中有了一條髒資料,執行緒A和B的工作已完成;
- 這時執行緒C開始執行CODE 3,則會躺槍,直接丟擲ClassCastException異常。
如果你的隊友在某處完成上述步驟1和2,自己還安然執行完成,該你的程式碼上場執行,需要使用ArrayMap的時候,剛例項化操作就掛了,這時你連誰挖的坑估計都找不到。一般來說,一個APP往往由很多人協作開發,難以保證每個人都水平一致,即便你能保證隊友,那引入的第三方JAR出問題呢。
當我正在修復該問題時,查閱最新原始碼,發現Google工程師Suprabh Shukla在2018.5.14提交修復方案,合入Android 9.0的程式碼。方案的思路是利用區域性變數儲存mArray,再斬斷對外的引用。修復程式碼如下:
public V removeAt(int index) { final int osize = mSize; if (osize <= 1) { final int[] ohashes = mHashes; final Object[] oarray = mArray;//利用區域性變數oarray先儲存mArray mHashes = EmptyArray.INT; mArray = EmptyArray.OBJECT;//再將mArray引用置空 freeArrays(ohashes, oarray, osize); nsize = 0; } else {
除了removeAt(),其他呼叫freeArrays()的地方都會在呼叫之前先修改mArray內容引用,從而不會干擾快取回收的操作。
場景二:
//執行緒A private void allocArrays(final int size) { if (size == (BASE_SIZE*2)) { ... } else if (size == BASE_SIZE) { synchronized (ArrayMap.class) { if (mBaseCache != null) { final Object[] array = mBaseCache; mArray = array;// CODE 1:將array引用暴露出去 mBaseCache = (Object[])array[0];//CODE 3 mHashes = (int[])array[1]; array[0] = array[1] = null; mBaseCacheSize--; return; } } } mHashes = new int[size]; mArray = new Object[size<<1]; }
//執行緒B public V put(K key, V value) { ... mHashes[index] = hash; mArray[index<<1] = key;// CODE 2: 當index=0的情況,修改array[0] mArray[(index<<1)+1] = value; mSize++; return null; }
有兩個執行緒,執行流程如下:
- 首先執行緒A剛執行allocArrays()的CODE1處,將array引用暴露出去;
- 然後執行緒B執行完CODE2處,修改修改array[0];
- 這時著執行緒A執行到CODE3,則會丟擲ClassCastException。
這種情況往往是自己造成的多執行緒問題,丟擲異常的也會在自己的程式碼邏輯裡面,不至於給別人挖坑。 這個修復比較簡單,把上面的CODE1向下移動兩行,先完成CODE3,再執行CODE1。
有了這兩處修復,是不是完全解決問題呢,答案是否定的,依然還是有概率出現異常。
3.3 終極分析
經過大量嘗試與研究,最終找到一種可以把快取鏈變成快取池的場景,這個場景比較複雜,省略N多字,就不說細節。直接上結論,簡單來說就是Double Free,同一個ArrayMap例項被連續兩次freeArrays(),這需要併發碰撞。兩個執行緒都同時執行到CODE 1,這樣兩個執行緒都能把mArray儲存在各種的區域性變數裡,然後就是double free。
public V removeAt(int index) { final int osize = mSize; if (osize <= 1) { final int[] ohashes = mHashes; final Object[] oarray = mArray;//CODE 1 mHashes = EmptyArray.INT; mArray = EmptyArray.OBJECT;//CODE 2 freeArrays(ohashes, oarray, osize); nsize = 0; } else {
即便出現double free,也不一定會出現異常,因為呼叫allocArrays()方法後,會把array[0]=null,這時mBaseCache=null,也就是快取池中的資料清空。 就這樣這種情況,在分析過程被否定過。最終經過反覆推敲,為了滿足各方條件需要,終於製造了案發現場如下:

arrayMap_error
這個場景的條件有兩個:(原因省略N多字)
- 必須要double free的ArrayMap例項(map2)的前面至少存在一個快取(map1);
- 必須在double free的ArrayMap例項(map2)的後面立即存放一個以上其他快取(map3);
省略N多字,
步驟1:由於map2所對應的mArray釋放了兩次導致快取鏈變成了快取環,如下圖:

arraymap_bug1.png
步驟2:通過建立新的ArrayMap從該快取環中的map2和map3這兩條快取,例項如下程式碼
ArrayMap map4 = new ArrayMap(4);//取出map2快取 map4.append("a11", "v11");//修改map2的array[0 ArrayMap map5 = new ArrayMap(4); // 取出map3快取
如果你足夠熟悉前面的記憶體分配與回收過程,就會發現在這種快取環的情況下,還會留下一條髒資料map2在快取池mBaseCache,這就形成了一個巨大的隱形坑位,並且難以復現與定位,如下圖。

arraymap_bug3.png
步驟3:快取池中的坑位已準備就緒,這個坑可能是專案中引入的第三方JAR包,或者是sdk,再或者是你的隊友不小心給你挖的。此時你的程式碼可能僅僅執行ArrayMap的構造方法,那麼就會丟擲如下異常。
ATAL EXCEPTION: Thread-20 Process: com.gityuan.arraymapdemo, PID: 29003 java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Object[] at com.gityuan.arraymapdemo.application.ArrayMap.allocArrays(ArrayMap.java:178) at com.gityuan.arraymapdemo.application.ArrayMap.<init>(ArrayMap.java:255) at com.gityuan.arraymapdemo.application.ArrayMap.<init>(ArrayMap.java:238) at com.gityuan.arraymapdemo.application.MainActivity$4.run(MainActivity.java:240)
當你去查詢API文件資料,只告訴你ArrayMap是非執行緒安全的,不能多執行緒操作,於是你一遍遍地反覆Review著自己寫的程式碼,可以確信沒有併發操作,卻事實能丟擲這樣的異常,關鍵是這樣的問題難以復現,只有這個異常棧作為唯一的資訊,怎麼也沒想到這是其他地方使用不當所造出來的神坑。 既然是由於double free導致的快取池出現環,進而引發的問題,那應該如何修復呢,這裡不講,留給讀者們自行思考。
四、知識延伸
4.1 HashMap
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //預設初始大小為16 static final float DEFAULT_LOAD_FACTOR = 0.75; //預設負載因子 static final int TREEIFY_THRESHOLD = 8;//當連結串列個數超過8,則轉紅黑樹 //用於存放資料的核心陣列,老版本是HashMapEntry, transient Node<K,V>[] table; transient int size; //實際儲存的鍵值對的個數 int threshold;//閾值,等於capacity*loadFactory final float loadFactor = DEFAULT_LOAD_FACTOR; //當前負載因子 transient int modCount;// 用於檢測是否存在併發修改,transient修飾則不會進入序列化 }

image.png
在不考慮雜湊衝突的情況下,在雜湊表中的增減、查詢操作的時間複雜度為的O(1)。HashMap是如何做到這麼優秀的O(1)呢?核心在於雜湊函式能將key直接轉換成雜湊表中的儲存位置,而雜湊表本質是一個數組,在指定下標的情況下查詢陣列成員是一步到位的。
那麼雜湊函式設計的好壞,會影響雜湊衝突的概率,進而影響雜湊表查詢的效能。為了解決雜湊衝突,也就是兩個不同key,經過hash轉換後指向同一個bucket,這時該bucket把相同hash值的key組成一個連結串列,每次插入連結串列的表頭。可見HashMap是由陣列+連結串列組成的,連結串列是為了處理雜湊碰撞而存在的,所以連結串列出現得越少,其效能越好。
想想一種極端情況,所有key都發生碰撞,那麼就HashMap就退化成連結串列,其時間複雜度一下就退化到O(n),這時比ArrayMap的效能還差,從Android sdk26開始,當連結串列長度超過8則轉換為紅黑樹,讓最壞情況的時間複雜度為O(logn)。網上有大量介紹HashMap的資料,其中table是HashMapEntry<K,V>[],那說明是老版本,新版為支援RBTree的功能,已切換到Node類。
HashMap是非執行緒安全的類,併為了避免開發者錯誤地使用,在每次增加、刪除、清空操作的過程會將modCount次數加1。在一些關鍵方法內剛進入的時候記錄當前的mCount次數,執行完核心邏輯後,再檢測mCount是否被其他執行緒修改,一旦被修改則說明有併發操作,則丟擲ConcurrentModificationException異常,這一點的處理比ArrayMap更有全面。
HashMap擴容機制:
- 擴容觸發條件是當發生雜湊衝突,並且當前實際鍵值對個數是否大於或等於閾值threshold,預設為0.75*capacity;
- 擴容操作是針對雜湊表table來分配記憶體空間,每次擴容是至少是當前大小的2倍,擴容的大小一定是2^n,; 另外,擴容後還需要將原來的資料都transfer到新的table,這是耗時操作。
4.2 SparseArray
public class SparseArray<E> implements Cloneable { private static final Object DELETED = new Object(); private boolean mGarbage = false; //標記是否存在待回收的鍵值對 private int[] mKeys; private Object[] mValues; private int mSize; }

SparseArray
SparseArray對應的key只能是int型別,它不會對key進行裝箱操作。它使用了兩個陣列,一個儲存key,一個儲存value。 從記憶體使用上來說,SparseArray不需要儲存key所對應的雜湊值,所以比ArrayMap還能再節省1/3的記憶體。
SparseArray使用二分查詢來找到key對應的插入位置,保證mKeys陣列從小到大的排序。
4.2.1 延遲迴收
public void delete(int key) { int i = ContainerHelpers.binarySearch(mKeys, mSize, key); if (i >= 0) { if (mValues[i] != DELETED) { mValues[i] = DELETED;//標記該資料為DELETE mGarbage = true; // 設定存在GC } } }
當執行delete()或者removeAt()刪除資料的操作,只是將相應位置的資料標記為DELETE,並設定mGarbage=true,而不會直接執行資料拷貝移動的操作。
當執行clear()會清空所有的資料,並設定mGarbage=false;另外有很多時機(比如實際資料大於等於陣列容量)都有可能會主動呼叫gc()方法來清理DELETE資料,程式碼如下:
private void gc() { int n = mSize; int o = 0; int[] keys = mKeys; Object[] values = mValues; for (int i = 0; i < n; i++) { Object val = values[i]; if (val != DELETED) { //將所有沒有標記為DELETE的value移動到佇列的頭部 if (i != o) { keys[o] = keys[i]; values[o] = val; values[i] = null; } o++; } } mGarbage = false; //垃圾整理完成 mSize = o; }
延遲迴收機制的好處在於首先刪除方法效率更高,同時減少陣列資料來回拷貝的次數,比如刪除某個資料後被標記刪除,接著又需要在相同位置插入資料,則不需要任何陣列元素的來回移動操作。可見,對於SparseArray適合頻繁刪除和插入來回執行的場景,效能很好。
4.3 ArraySet
ArraySet也是Android特有的資料結構,用於替代HashSet的,跟ArrayMap出自同一個作者,從原始碼來看ArraySet跟ArrayMap幾乎完全一致,包含快取機制,擴容機制。唯一的不同在於ArrayMap是一個key-value鍵值對的集合,而ArraySet是一個集合,mArray[]儲存所有的value值,而mHashes[]儲存相應value所對應的hash值。

ArraySet
當然ArraySet也有ArrayMap一樣原理的缺陷,這一點Google應該發現,修復如下:
private void allocArrays(final int size) { if (size == (BASE_SIZE * 2)) { ... } else if (size == BASE_SIZE) { synchronized (ArraySet.class) { if (sBaseCache != null) { final Object[] array = sBaseCache; try { mArray = array; sBaseCache = (Object[]) array[0]; mHashes = (int[]) array[1]; array[0] = array[1] = null; sBaseCacheSize--; return; } catch (ClassCastException e) { } // 從下面這段日誌,可以看出谷歌工程師也發現了存在這個問題 // Whoops!Someone trampled the array (probably due to not protecting // their access with a lock).Our cache is corrupt; report and give up. sBaseCache = null; sBaseCacheSize = 0; } } } mHashes = new int[size]; mArray = new Object[size]; }
對於ClassCastException異常,這個有可能不是當前ArraySet使用不到導致的,也無法追溯,所以谷歌直接catch住這個異常,然後把緩衝池清空,再建立陣列。這樣可以解決問題,但這樣有什麼不足嗎? 這樣的不足在於當發生異常時會讓快取機制失效。
五、總結
從以下幾個角度總結一下:
- 資料結構
- ArrayMap和SparseArray採用的都是兩個陣列,Android專門針對記憶體優化而設計的
- HashMap採用的是資料+連結串列+紅黑樹
- 記憶體優化
- ArrayMap比HashMap更節省記憶體,綜合性能方面在資料量不大的情況下,推薦使用ArrayMap;
- Hash需要建立一個額外物件來儲存每一個放入map的entry,且容量的利用率比ArrayMap低,整體更消耗記憶體
- SparseArray比ArrayMap節省1/3的記憶體,但SparseArray只能用於key為int型別的Map,所以int型別的Map資料推薦使用SparseArray;
- 效能方面:
- ArrayMap查詢時間複雜度O(logN);ArrayMap增加、刪除操作需要移動成員,速度相比較慢,對於個數小於1000的情況下,效能基本沒有明顯差異
- HashMap查詢、修改的時間複雜度為O(1);
- SparseArray適合頻繁刪除和插入來回執行的場景,效能比較好
- 快取機制
- ArrayMap針對容量為4和8的物件進行快取,可避免頻繁建立物件而分配記憶體與GC操作,這兩個快取池大小的上限為10個,防止快取池無限增大;
- HashMap沒有快取機制
- SparseArray有延遲迴收機制,提供刪除效率,同時減少陣列成員來回拷貝的次數
- 擴容機制
- ArrayMap是在容量滿的時機觸發容量擴大至原來的1.5倍,在容量不足1/3時觸發記憶體收縮至原來的0.5倍,更節省的記憶體擴容機制
- HashMap是在容量的0.75倍時觸發容量擴大至原來的2倍,且沒有記憶體收縮機制。HashMap擴容過程有hash重建,相對耗時。所以能大致知道資料量,可指定建立指定容量的物件,能減少效能浪費。
- 併發問題
- ArrayMap是非執行緒安全的類,大量方法中通過對mSize判斷是否發生併發,來決定丟擲異常。但沒有覆蓋到所有併發場景,比如大小沒有改變而成員內容改變的情況就沒有覆蓋
- HashMap是在每次增加、刪除、清空操作的過程將modCount加1,在關鍵方法內進入時記錄當前mCount,執行完核心邏輯後,再檢測mCount是否被其他執行緒修改,來決定丟擲異常。這一點的處理比ArrayMap更有全面。
ConcurrentModificationException這種異常機制只是為了提醒開發者不要多執行緒併發操作,這裡強調一下千萬不要併發操作ArrayMap和HashMap。 本文還重點介紹了ArrayMap的缺陷,這個缺陷是由於在開發者沒有遵循非執行緒安全來不可併發操作的原則,從而引入了髒快取導致其他人掉坑的問題。從另外類ArraySet來看,Google是知道有ClassCastException異常的問題,無法追溯根源,所以谷歌直接catch住這個異常,然後把緩衝池清空,再建立陣列。 這樣也不失為一種合理的解決方案,唯一遺憾的是觸發這種情況時會讓快取失效,由於這個清楚是非常概率,絕大多數場景快取還是有效的。
最後說一點,ArrayMap這個缺陷是極低概率的,並且先有人沒有做好ArrayMap的併發引入的坑才會出現這個問題。只要大家都能保證併發安全也就沒有這個缺陷,只有前面講的優勢。
喜歡的話請幫忙轉發一下能讓更多有需要的人看到吧,有些技術上的問題大家可以多探討一下。

image

image