前言
相信不少同學在面試的時候有被問到關於HashMap的問題,特別是Java/Android程式設計師,HashMap幾乎是必然會被提及的。因為這裡面可以挖掘的點實在是太多了。關於Java的HashMap面經在網上可以說是隨處可見了。自然而然,隨著Flutter的火爆,後面大家也可能在面試中被問到關於Flutter/Dart的HashMap相關問題。與其到時候一問三不知,不如現在就來了解一下Flutter/Dart的HashMap吧。
本文中,關於Dart的HashMap我先列一些有可能在面試中遇到的問題,然後會對照原始碼做一些介紹,最後會給出這些問題的一個粗淺的答案。希望能幫到大家。
HashMap
和LinkedHashMap
有什麼區別?- 這個表示式
final map = Map();
得到的map
是上面兩種Map的那一種? HashMap
底層的資料結構是什麼樣的?HashMap
預設大小是多大?HashMap
如何處理hash衝突?HashMap
何時擴容?如何擴容?LinkedHashMap
底層的資料結構是什麼樣的?LinkedHashMap
如何處理hash衝突?LinkedHashMap
何時擴容?如何擴容?
下面我們就帶著這些問題來看一下原始碼
使用HashMap
和LinkedHashMap
建立一個HashMap
例項:
final map = HashMap();
複製程式碼
建立一個LinkedHashMap
例項
final map = LinkedHashMap();
複製程式碼
建立一個Map
例項
final map = Map();
複製程式碼
這裡你得到的map
其實是一個LinkedHashMap
。其工廠建構函式如下:
@patch
class Map<K, V> {
...
@patch
factory Map() => new LinkedHashMap<K, V>();
...
}
複製程式碼
增/改
map['one'] = 1;
複製程式碼
刪
map.remove('one');
複製程式碼
查
final value = map['one'];
複製程式碼
增,改,查的操作和運算元組一樣,刪除要呼叫remove()
。
迭代器
final it = map.entries.iterator;
while(it.moveNext()) {
print(it.current);
}
複製程式碼
Dart語言一大特點就是特別靈活,這裡只是列舉了一些常見操作,其他不同的寫法大家可以參考API文件。
接下來我們深入原始碼來一探究竟。
HashMap
建構函式
@patch
class HashMap<K, V> {
@patch
factory HashMap(
{bool equals(K key1, K key2)?,
int hashCode(K key)?,
bool isValidKey(potentialKey)?}) {
if (isValidKey == null) {
if (hashCode == null) {
if (equals == null) {
return new _HashMap<K, V>();
}
...
}
複製程式碼
HashMap
建構函式有三個可選入參,這裡我們都不傳,這樣的話返回的就是個_HashMap
例項。有入參的情況下會返回另外兩種_IdentityHashMap
和_CustomHashMap
之一,本文由於篇幅所限就
不再涉及。大家感興趣的話可以直接去看原始碼。
底層結構
var _buckets = List<_HashMapEntry<K, V>?>.filled(_INITIAL_CAPACITY, null);
複製程式碼
這一看就是陣列+連結串列的形式嘛。
初始化容量:
static const int _INITIAL_CAPACITY = 8;
複製程式碼
我們知道Java的HashMap
初始化大小是16,Dart使用的是8. 雖然不同但也還是2的冪。 另外貌似Dart也沒有給使用者提供自定義初始化大小的機會。
查詢操作
V? operator [](Object? key) {
final hashCode = key.hashCode;
final buckets = _buckets;
final index = hashCode & (buckets.length - 1);
var entry = buckets[index];
while (entry != null) {
if (hashCode == entry.hashCode && entry.key == key) {
return entry.value;
}
entry = entry.next;
}
return null;
}
複製程式碼
可見取陣列下標就是直接把key
的hashCode
和陣列長度-1做與操作。
final index = hashCode & (buckets.length - 1);
複製程式碼
然後比較連結串列元素儲存的雜湊值以及key
是否相等,不相等則找下一個連結串列元素,都相等則返回對應值。這裡我們要注意到沒有紅黑樹。所以dart的HashMap
實現其實和jdk1.8之前的實現類似。
賦值操作
void operator []=(K key, V value) {
final hashCode = key.hashCode;
final buckets = _buckets;
final length = buckets.length;
final index = hashCode & (length - 1);
var entry = buckets[index];
while (entry != null) {
if (hashCode == entry.hashCode && entry.key == key) {
entry.value = value;
return;
}
entry = entry.next;
}
_addEntry(buckets, index, length, key, value, hashCode);
}
複製程式碼
過程和取值操作其實差不多,鍵值對存在的情況下就直接賦值,不存在的情況下就呼叫_addEntry()
做新增操作。
void _addEntry(List<_HashMapEntry<K, V>?> buckets, int index, int length,
K key, V value, int hashCode) {
final entry = new _HashMapEntry<K, V>(key, value, hashCode, buckets[index]);
buckets[index] = entry;
final newElements = _elementCount + 1;
_elementCount = newElements;
// If we end up with more than 75% non-empty entries, we
// resize the backing store.
if ((newElements << 2) > ((length << 1) + length)) _resize();
_modificationCount = (_modificationCount + 1) & _MODIFICATION_COUNT_MASK;
}
複製程式碼
這裡注意一下在新建_HashMapEntry
的時候會傳入當前陣列的entry,也就是buckets[index]
。然後把新的entry賦值給buckets[index]
。
buckets[index] = entry;
複製程式碼
這裡我們就能猜到應該用的是頭插法。另外,_modificationCount
是每次有增刪等操作的時候都是自增的,當我們在遍歷HashMap
的過程中如果有此類操作會導致Concurrent modification
異常。這也就是"Fail-Fast"機制
新增操作顯然會涉及到擴容的問題,從上面的註釋我們可以看出,在鍵值對數量超過陣列容量的75%的時候會做擴容,也就是它的負載因子是0.75。這點和Java也是一樣的。這個75%的計算過程為了提高效率使用了位運算和加法來代替除法,等效於e*4>l*3 -> e/l>3/4 -> e/l>75%
擴容操作
void _resize() {
final oldBuckets = _buckets;
final oldLength = oldBuckets.length;
final newLength = oldLength << 1;
final newBuckets = new List<_HashMapEntry<K, V>?>.filled(newLength, null);
for (int i = 0; i < oldLength; i++) {
var entry = oldBuckets[i];
while (entry != null) {
final next = entry.next;
final hashCode = entry.hashCode;
final index = hashCode & (newLength - 1);
entry.next = newBuckets[index];
newBuckets[index] = entry;
entry = next;
}
}
_buckets = newBuckets;
}
複製程式碼
擴容後的新陣列長度是原長度的2倍。
final newLength = oldLength << 1;
複製程式碼
我們知道它的初始長度是8,可見buckets
陣列長度始終會是2的冪。
從把entry從舊陣列轉移到新陣列的過程我們也能看出來,這個轉移的過程使用的也是頭插法。
擴容這裡有一個需要注意的地方就是,當鍵值對數量超過陣列長度的75%時會發生擴容,而不是陣列被佔用超過75%的時候會發生擴容,這一誤區在很多討論Java HashMap
的文章中也出現過。需要大家仔細體會這裡面的不同。
刪除操作
void _removeEntry(_HashMapEntry<K, V> entry,
_HashMapEntry<K, V>? previousInBucket, int bucketIndex) {
if (previousInBucket == null) {
_buckets[bucketIndex] = entry.next;
} else {
previousInBucket.next = entry.next;
}
}
複製程式碼
刪除就是正常的連結串列刪除節點的過程。
遍歷
void forEach(void action(K key, V value)) {
final stamp = _modificationCount;
final buckets = _buckets;
final length = buckets.length;
for (int i = 0; i < length; i++) {
var entry = buckets[i];
while (entry != null) {
action(entry.key, entry.value);
if (stamp != _modificationCount) {
throw new ConcurrentModificationError(this);
}
entry = entry.next;
}
}
}
複製程式碼
遍歷會從陣列的第一個位置開始依次訪問連結串列的每一項。顯然這個遍歷順序是不保證和鍵值對的插入順序一致的。這裡我們也能看到"Fail-Fast"機制發生作用的時候了,如果遍歷過程中使用者對HashMap
做了增刪等操作的話會導致stamp
和_modificationCount
不相等,導致ConcurrentModificationError
異常。
小結
Dart的HashMap
總體而言實現的還是比較簡單的。整體上和jdk1.7版本的HashMap
類似。相信研究過Java實現的同學們會覺得dart的HashMap
比較好理解一些。
LinkedHashMap
從API文件上看,LinkedHashMap
和HashMap
的區別就是在遍歷的時候,LinkedHashMap
會保留鍵值對的插入順序。在jdk中,LinkedHashMap
的實現是將Node
改造為雙向連結串列以保留順序。dart的LinkedHashMap
實現則有所不同。
建構函式
@patch
class LinkedHashMap<K, V> {
@patch
factory LinkedHashMap(
{bool equals(K key1, K key2)?,
int hashCode(K key)?,
bool isValidKey(potentialKey)?}) {
if (isValidKey == null) {
if (hashCode == null) {
if (equals == null) {
return new _InternalLinkedHashMap<K, V>();
}
...
}
複製程式碼
類似的,LinkedHashMap
建構函式有三個可選入參,這裡我們都不傳,這樣的話返回的就是個_InternalLinkedHashMap
例項。有入參的情況下會返回另外兩種_CompactLinkedIdentityHashMap
和_CompactLinkedCustomHashMap
之一,本文也不再涉及。
底層結構
我們直接看_InternalLinkedHashMap
。
_InternalLinkedHashMap
建構函式:
_InternalLinkedHashMap() {
_index = new Uint32List(_HashBase._INITIAL_INDEX_SIZE);
_hashMask = _HashBase._indexSizeToHashMask(_HashBase._INITIAL_INDEX_SIZE);
_data = new List.filled(_HashBase._INITIAL_INDEX_SIZE, null);
_usedData = 0;
_deletedKeys = 0;
}
複製程式碼
可見_InternalLinkedHashMap
底層有兩個陣列,_index
和_data
。_index
陣列以雜湊碼為下標記錄對應鍵值對在_data
陣列中的位置。_data
陣列按插入順序依次儲存key
和value
。
用圖來表示就是下面這個樣子:
兩個陣列的初始化長度都是_INITIAL_INDEX_SIZE
。通過以下程式碼可見其值為16。_data
陣列存放的是鍵值對,那最多的話只能存放8個鍵值對了。也就是說LinkedHashMap
初始容量其實是8。
abstract class _HashBase implements _HashVMBase {
...
static const int _INITIAL_INDEX_BITS = 3;
static const int _INITIAL_INDEX_SIZE = 1 << (_INITIAL_INDEX_BITS + 1);
}
複製程式碼
LinkedHashMap
底層其實是用線性探測法實現的。陣列_index
裡儲存的是叫pair
的一個整數。之所以叫pair
是因為它是由高位和低位兩部分組成的,高位叫hashPattern
, 低位叫entry
。entry
指向_data
陣列對應的鍵值對。從hashcode到真正鍵值對的查詢過程的關鍵點其實就是這個entry
。
同時pair
也用來表示_index
陣列對應位置的狀態。0(_UNUSED_PAIR
)表示當前未使用,1(_DELETED_PAIR
)表示當前位置處於刪除狀態:
abstract class _HashBase implements _HashVMBase {
...
static const int _UNUSED_PAIR = 0;
static const int _DELETED_PAIR = 1;
...
}
複製程式碼
查詢操作
V? operator [](Object? key) {
var v = _getValueOrData(key);
return identical(_data, v) ? null : internal.unsafeCast<V>(v);
}
複製程式碼
查詢最終會呼叫到_getValueOrData
Object? _getValueOrData(Object? key) {
final int size = _index.length;
final int sizeMask = size - 1;
final int maxEntries = size >> 1;
final int fullHash = _hashCode(key);
final int hashPattern = _HashBase._hashPattern(fullHash, _hashMask, size);
int i = _HashBase._firstProbe(fullHash, sizeMask);
int pair = _index[i];
while (pair != _HashBase._UNUSED_PAIR) {
if (pair != _HashBase._DELETED_PAIR) {
final int entry = hashPattern ^ pair;
if (entry < maxEntries) {
final int d = entry << 1;
if (_equals(key, _data[d])) {
return _data[d + 1];
}
}
}
i = _HashBase._nextProbe(i, sizeMask);
pair = _index[i];
}
return _data;
}
複製程式碼
從這個函式中我們就能瞭解到線性探測的過程了。首先通過呼叫_HashBase._firstProbe()
來拿到首個地址:
static int _firstProbe(int fullHash, int sizeMask) {
final int i = fullHash & sizeMask;
// Light, fast shuffle to mitigate bad hashCode (e.g., sequential).
return ((i << 1) + i) & sizeMask;
}
複製程式碼
首次探測就是把hashcode和陣列長度取模,注意還有一步,是把i
乘以3以後再取模。從註釋上看是為了使hashcode分佈更均勻一些。大家可以思考一下其中的原因。
首次探測以後拿到pair
,如果這個pair
是未佔用狀態說明鍵值對不存在,按約定直接返回_data
陣列。如果是刪除狀態就接著做二次探測。如果是正常佔用狀態,就將pair
和hashPattern
做異或,從前面的圖可知,這樣就得到了entry
。檢查entry
未越界的話,將其乘以2就是key
在_data
陣列中的位置了,最後判斷key
相等,則返回_data
的下一個元素,即value
。
二次探測會呼叫_HashBase._nextProbe()
static int _nextProbe(int i, int sizeMask) => (i + 1) & sizeMask;
複製程式碼
原始碼可見就是挨個去試下一個地址。
賦值操作
void operator []=(K key, V value) {
final int size = _index.length;
final int fullHash = _hashCode(key);
final int hashPattern = _HashBase._hashPattern(fullHash, _hashMask, size);
final int d = _findValueOrInsertPoint(key, fullHash, hashPattern, size);
if (d > 0) {
_data[d] = value;
} else {
final int i = -d;
_insert(key, value, hashPattern, i);
}
}
複製程式碼
賦值時會先呼叫_findValueOrInsertPoint()
來尋找已存在的鍵值對,其邏輯和函式_getValueOrData
類似。鍵值對存在則直接返回對應value
在_data
中的位置,然後直接賦值就完了。如果不存在的話,就會返回一個負數,這個負數其實就是經過探測以後在_index
陣列中的可用位置。有了這個位置就可以呼叫_insert()
來做插入操作了。
void _insert(K key, V value, int hashPattern, int i) {
if (_usedData == _data.length) {
_rehash();
this[key] = value;
} else {
assert(1 <= hashPattern && hashPattern < (1 << 32));
final int index = _usedData >> 1;
assert((index & hashPattern) == 0);
_index[i] = hashPattern | index;
_data[_usedData++] = key;
_data[_usedData++] = value;
}
}
複製程式碼
插入之前先判斷_data
陣列是否已佔滿。滿了的話就要呼叫_rehash()
做擴容了。未滿的話就是簡單的賦值操作了,將_data
的下一個空位除以2以後和hashPattern
做或運算,然後放入_index
陣列,再將key
和value
緊挨著放入_data
陣列。
擴容操作
void _rehash() {
if ((_deletedKeys << 2) > _usedData) {
_init(_index.length, _hashMask, _data, _usedData);
} else {
_init(_index.length << 1, _hashMask >> 1, _data, _usedData);
}
}
複製程式碼
擴容之前先看陣列中是否有超過一半的元素是處於刪除狀態,是的話擴容長度和原陣列長度是一樣的,否則新陣列長度為原長的2倍。為什麼這麼操作,我們接著看_ininit()
函式。
void _init(int size, int hashMask, List? oldData, int oldUsed) {
_index = new Uint32List(size);
_hashMask = hashMask;
_data = new List.filled(size, null);
_usedData = 0;
_deletedKeys = 0;
if (oldData != null) {
for (int i = 0; i < oldUsed; i += 2) {
var key = oldData[i];
if (!_HashBase._isDeleted(oldData, key)) {
this[key] = oldData[i + 1];
}
}
}
}
複製程式碼
這裡會按新的長度新建_index
和_data
陣列。接著會做拷貝,如果是已刪除狀態的鍵值對是不會被拷貝的。所以和原陣列一樣長的"擴容"過程其實就是把被刪除的元素真正刪除了。
刪除操作
V? remove(Object? key) {
final int size = _index.length;
final int sizeMask = size - 1;
final int maxEntries = size >> 1;
final int fullHash = _hashCode(key);
final int hashPattern = _HashBase._hashPattern(fullHash, _hashMask, size);
int i = _HashBase._firstProbe(fullHash, sizeMask);
int pair = _index[i];
while (pair != _HashBase._UNUSED_PAIR) {
if (pair != _HashBase._DELETED_PAIR) {
final int entry = hashPattern ^ pair;
if (entry < maxEntries) {
final int d = entry << 1;
if (_equals(key, _data[d])) {
_index[i] = _HashBase._DELETED_PAIR;
_HashBase._setDeletedAt(_data, d);
V value = _data[d + 1];
_HashBase._setDeletedAt(_data, d + 1);
++_deletedKeys;
return value;
}
}
}
i = _HashBase._nextProbe(i, sizeMask);
pair = _index[i];
}
return null;
}
複製程式碼
刪除過程首先也是做線性探測。找到了的話就做兩件事,首先將_index
陣列對應位置置為_HashBase._DELETED_PAIR
。然後將_data
陣列對應位置置為_data
。
遍歷
我們知道,LinkedHashMap
則會保證遍歷順序和插入順序相同。那通過上面介紹我們瞭解到插入的鍵值對都按順序儲存在_data
陣列中了。那麼遍歷的時候只需要遍歷_data
陣列自然就能按插入順序遍歷LinkedHashMap
了。
bool moveNext() {
if (_table._isModifiedSince(_data, _checkSum)) {
throw new ConcurrentModificationError(_table);
}
do {
_offset += _step;
} while (_offset < _len && _HashBase._isDeleted(_data, _data[_offset]));
if (_offset < _len) {
_current = internal.unsafeCast<E>(_data[_offset]);
return true;
} else {
_current = null;
return false;
}
}
複製程式碼
如果是刪除狀態的鍵值對是會被跳過的。
小結
Dart的LinkedHashMap
實現和jdk的是不同的,大家初次接觸的話可能會比較陌生,需要仔細研究一下原始碼來看看具體實現,也是能學到一些東西的。
總結
總體來說Dart的HashMap
和LinkedHashMap
實現還是比較簡單的,並沒有像jdk一樣做一些細緻的優化工作,這可能有待於Dart/Flutter的進一步發展吧。但我們也能看到不論是何種語言,一些基礎的資料結構其設計思想都是相通的。掌握原始碼背後的設計思路就可以舉一反三,不論是哪種新語言,新架構都可以快速掌握,最後,我把最開始的那幾個問題連帶粗淺的答案一起放在下面:
HashMap
和LinkedHashMap
有什麼區別?
從API角度,HashMap
遍歷的時候不保證和插入順序相同,而LinkedHashMap
則會保證遍歷順序和插入順序相同。
這個表示式final map = Map();
得到的map
是上面兩種Map的那一種?
是LinkedHashMap
。
HashMap
底層的資料結構是什麼樣的?
陣列+連結串列。
HashMap
預設大小是多大?
8。
HashMap
如何處理hash衝突?
鏈地址法。
HashMap
何時擴容?如何擴容?
當鍵值對數量超過陣列長度的75%時會發生擴容,而不是陣列被佔用超過75%的時候會發生擴容。擴容後的新陣列長度將會是原陣列長度的2倍,擴容過程採用頭插法。
LinkedHashMap
底層的資料結構是什麼樣的?
兩個陣列:_index
和_data
, _index
陣列以雜湊碼為下標記錄對應鍵值對在_data
陣列中的位置。_data
陣列按插入順序依次儲存key
和value
。
LinkedHashMap
如何處理hash衝突?
線性探測法。
LinkedHashMap
何時擴容?如何擴容?
_data
陣列滿時擴容,擴容之前先看陣列中是否有超過一半的元素是處於刪除狀態,是的話擴容長度和原陣列長度是一樣的,否則新陣列長度為原長的2倍。