ArrayMap是如何提高記憶體的使用效率的?
ArraySet使用陣列儲存資料,提高了記憶體的使用效率,在資料量不超過1000時,相較於HashSet
,效率最多不會降低50%,本節來分析下ArraySet 新增和刪除元素分析,谷歌指出ArrayMap
的設計也是為了更加高效地使用記憶體,在資料量不超過1000時,效率最多不會降低50%。閱讀原碼可以發現,ArrayMap
和ArraySet
在實現上保持了統一,主要的不同是元素的儲存方式。
繼承結構
可以看到,```ArrayMap```的繼承結構比較簡單,只是實現了Map介面。儲存結構
可以回憶一下ArraySet
的儲存結構:一個int型別的陣列mHashes儲存hash值,一個object型別的陣列mArray儲存內容,這兩個陣列的下標一一對應。
ArrayMap
的儲存結構猜想應該和ArraySet
不一樣,因為ArrayMap
不僅僅需要儲存value,還需要儲存key,Google的大神們是怎樣解決這個問題的呢?
Google的大神們還是使用了和ArraySet
一樣的資料結構,在儲存key和value時設計了一個非常巧妙的方法。
新增和刪除
ArraySet
和ArrayMap
在實現上保持了統一,閱讀原碼可以發現,他們擁有同樣的快取結構,刪除和新增元素時會有相同的邏輯流程。大致看下HashMap
的儲存結構
HashMap
的儲存結構,每個連結串列後面的元素的數量沒有達到將連結串列樹化的數目。
HashMap
在儲存k-v鍵值對的時候,首先根據k的hash值找到k-v儲存的連結串列陣列的下標,然後將k-v鍵值對儲存在連結串列的最後。
ArrayMap
使用兩個一維陣列分別儲存k的hash值和k-v鍵值對。新增元素時根據k查詢元素以確認元素是否已經存在,如果已經存在則直接更新,否則新增;刪除元素時查詢元素以確定元素是否存在,如果不存在則直接返回,否則刪除元素。
ArrayMap
ArraySet
相同。但是需要注意的是,
ArrayMap
在新增和刪除元素的過程中,儲存k-v鍵值對
mArray
陣列需要同時修改k和v兩個元素。
元素查詢
經過上面的分析,可能發現了一個問題,ArrayMap
和ArraySet
太相似了。確實是,他們在底層儲存結構,快取結構都是一樣的。新增和刪除元素的時候,需要查詢元素,新增元素時根據k查詢元素以確認元素是否已經存在,如果已經存在則直接更新,否則新增;刪除元素時查詢元素以確定元素是否存在,如果不存在則直接返回,否則刪除元素。ArrayMap
是否和ArraySet
具有相同的查詢過程呢。直接上原始碼:
int indexOf(Object key, int hash) {
final int N = mSize;
// Important fast case: if nothing is in here, nothing to look for.
if (N == 0) {
return ~0;
}
int index = binarySearchHashes(mHashes, N, hash);
// If the hash code wasn't found, then we have no entry for this key.
if (index < 0) {
return index;
}
// If the key at the returned index matches, that's what we want.
if (key.equals(mArray[index<<1])) {
return index;
}
// Search for a matching key after the index.
int end;
for (end = index + 1; end < N && mHashes[end] == hash; end++) {
if (key.equals(mArray[end << 1])) return end;
}
// Search for a matching key before the index.
for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
if (key.equals(mArray[i << 1])) return i;
}
// Key not found -- return negative value indicating where a
// new entry for this key should go. We use the end of the
// hash chain to reduce the number of array entries that will
// need to be copied when inserting.
return ~end;
}
private static int binarySearchHashes(int[] hashes, int N, int hash) {
try {
return ContainerHelpers.binarySearch(hashes, N, hash);
} catch (ArrayIndexOutOfBoundsException e) {
if (CONCURRENT_MODIFICATION_EXCEPTIONS) {
throw new ConcurrentModificationException();
} else {
throw e; // the cache is poisoned at this point, there's not much we can do
}
}
}
複製程式碼
以上為indexOf
函式和binarySearchHashes
函式的實現。通過對比原始碼,可以發現,ArrayMap
和ArraySet
使用了相同的二分查詢邏輯,可以肯定的,和ArraySet
一樣,ArrayMap
在儲存hash值時是有序的。具體的查詢過程的分析可以參考ArraySet 新增和刪除元素分析
不同點
上面的分析容易讓人產生一種感覺ArraySet
和ArrayMap
的實現完全相同。這是一種誤解,ArraySet
和ArrayMap
在實現的邏輯流程是相同的,但在細節處理上還是有不同。新增刪除元素的過程中,不同點主要體現在在新增和刪除元素的過程中,如果有其他操作改變了ArrayMap
儲存的內容的數量,則會丟擲ConcurrentModificationException
,ArrayMap
中能改變儲存容量的是以下三個方法:put
、remove
、clear
可以做一個小實驗 首先,兩個執行緒同時修改ArrayMap
同一個key下的value
ArrayMap<String, String> aMap = new ArrayMap<>();
aMap.put("key", "value");
new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
for (int i = 0 ; ; i++) {
aMap.put("key", "value" + i);
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
for (int i = 0 ; ; i++) {
aMap.put("key", "value" + i);
}
}
}).start();
複製程式碼
執行後可以發現,程式會一直執行,也不會報錯。
接下來看下兩個執行緒同時向ArrayMap
中新增元素
ArrayMap<String, String> aMap = new ArrayMap<>();
new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
for (int i = 0 ; ; i++) {
aMap.put("key" + i, "value" + i);
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
for (int i = 0 ; ; i++) {
aMap.put("key" + i, "value" + i);
}
}
}).start();
複製程式碼
執行程式後,會報如下異常
Exception in thread "Thread-1" java.util.ConcurrentModificationException
at com.rock.collections.array.ArrayMap.put(ArrayMap.java:527)
at com.rock.collections.Client$2.run(Client.java:50)
at java.lang.Thread.run(Thread.java:748)
複製程式碼
(我將ArrayMap
抽出來進行測試,故顯示的包名是我自定義的) 可以發現由於兩個執行緒同時向aMap
中添加了元素,修改了元素的數量,系統丟擲了ConcurrentModificationException
。
跟蹤下新增元素的過程
@Override
public V put(K key, V value) {
final int osize = mSize;
......
index = ~index;
if (osize >= mHashes.length) {
// 陣列擴容
final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
: (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);
......
allocArrays(n);
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
......
}
......
if (CONCURRENT_MODIFICATION_EXCEPTIONS) {
if (osize != mSize || index >= mHashes.length) {
throw new ConcurrentModificationException();
}
}
mHashes[index] = hash;
mArray[index<<1] = key;
mArray[(index<<1)+1] = value;
mSize++;
return null;
}
複製程式碼
原始碼已經很清晰了,CONCURRENT_MODIFICATION_EXCEPTIONS = true
,在新增元素之前,使用osize
記錄mSize
,在擴容之後和最後新增元素之前會對當前元素的數量進行判斷,如果發生了變化則丟擲異常。
再跟蹤下刪除元素的過程
public V removeAt(int index) {
final int osize = mSize;
......
if (osize <= 1) {
......
} else {
nsize = osize - 1;
if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) {
......
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
......
} else {
......
}
}
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
mSize = nsize;
return (V)old;
}
複製程式碼
在縮容或者記錄最終元素的數量之前,如果發現元素的數量被修改過,則丟擲異常。這個地方還有一個要注意的,由於是刪除元素,mSize
最終是要發生變化的,但是原始碼中對比的mSize
發生變化之前的值。
小結
ArrayMap
的設計是為了更加高效地利用記憶體,高效體現在以下幾點
ArrayMap
使用更少的儲存單元儲存元素ArrayMap
使用int
型別的陣列儲存hash,使用Object
型別陣列儲存k-v鍵值對,相較於HashMap
使用Node
儲存節點,ArrayMap
儲存一個元素佔用的記憶體更小。ArrayMap
在擴容時容量變化更小HashMap
在擴容的時候,通常會將容量擴大一倍,而ArrayMap
在擴容的時候,如果元素個數超過8,最多擴大自己的1/2。
雖然有以上有點,但是和ArraySet
一樣,ArrayMap
也存在以下劣勢:
- 儲存大量(超過1000)元素時比較耗時
- 在對元素進行查詢或者確定待插入元素的位置時使用二分查詢,當元素較多時,耗時較長
- 頻繁擴容和縮容,可能會產生大量複製操作
ArrayMap
在擴容和縮容時需要移動元素,且擴容時容量變化比HashMap
小,擴容和縮容的頻率可能更高,元素數量過多時,元素的移動可能會對效能產生影響。
基於以上優缺點,google給出的建議是當元素數量小於1000時,建議使用Array
代替HashMap
,效率降低最多不會超過50%