面試時被問到Flutter/Dart的HashMap怎麼辦?

ad6623發表於2021-05-20

前言

相信不少同學在面試的時候有被問到關於HashMap的問題,特別是Java/Android程式設計師,HashMap幾乎是必然會被提及的。因為這裡面可以挖掘的點實在是太多了。關於Java的HashMap面經在網上可以說是隨處可見了。自然而然,隨著Flutter的火爆,後面大家也可能在面試中被問到關於Flutter/Dart的HashMap相關問題。與其到時候一問三不知,不如現在就來了解一下Flutter/Dart的HashMap吧。

本文中,關於Dart的HashMap我先列一些有可能在面試中遇到的問題,然後會對照原始碼做一些介紹,最後會給出這些問題的一個粗淺的答案。希望能幫到大家。

  • HashMapLinkedHashMap有什麼區別?
  • 這個表示式final map = Map();得到的map是上面兩種Map的那一種?
  • HashMap底層的資料結構是什麼樣的?
  • HashMap預設大小是多大?
  • HashMap如何處理hash衝突?
  • HashMap何時擴容?如何擴容?
  • LinkedHashMap底層的資料結構是什麼樣的?
  • LinkedHashMap如何處理hash衝突?
  • LinkedHashMap何時擴容?如何擴容?

下面我們就帶著這些問題來看一下原始碼

使用HashMapLinkedHashMap

建立一個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);
複製程式碼

這一看就是陣列+連結串列的形式嘛。

hashmap.jpg 初始化容量:

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;
  }

複製程式碼

可見取陣列下標就是直接把keyhashCode和陣列長度-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文件上看,LinkedHashMapHashMap的區別就是在遍歷的時候,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陣列按插入順序依次儲存keyvalue

用圖來表示就是下面這個樣子:

Untitled Diagram-2.png

兩個陣列的初始化長度都是_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, 低位叫entryentry指向_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;
   ...
}
複製程式碼

Screen Shot 2021-05-21 at 12.16.13 AM.png

查詢操作

  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陣列。如果是刪除狀態就接著做二次探測。如果是正常佔用狀態,就將pairhashPattern做異或,從前面的圖可知,這樣就得到了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陣列,再將keyvalue緊挨著放入_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的HashMapLinkedHashMap實現還是比較簡單的,並沒有像jdk一樣做一些細緻的優化工作,這可能有待於Dart/Flutter的進一步發展吧。但我們也能看到不論是何種語言,一些基礎的資料結構其設計思想都是相通的。掌握原始碼背後的設計思路就可以舉一反三,不論是哪種新語言,新架構都可以快速掌握,最後,我把最開始的那幾個問題連帶粗淺的答案一起放在下面:

HashMapLinkedHashMap有什麼區別?

從API角度,HashMap遍歷的時候不保證和插入順序相同,而LinkedHashMap則會保證遍歷順序和插入順序相同。

這個表示式final map = Map();得到的map是上面兩種Map的那一種?

LinkedHashMap

HashMap底層的資料結構是什麼樣的?

陣列+連結串列。

HashMap預設大小是多大?

8。

HashMap如何處理hash衝突?

鏈地址法。

HashMap何時擴容?如何擴容?

當鍵值對數量超過陣列長度的75%時會發生擴容,而不是陣列被佔用超過75%的時候會發生擴容。擴容後的新陣列長度將會是原陣列長度的2倍,擴容過程採用頭插法。

LinkedHashMap底層的資料結構是什麼樣的?

兩個陣列:_index_data, _index陣列以雜湊碼為下標記錄對應鍵值對在_data陣列中的位置。_data陣列按插入順序依次儲存keyvalue

LinkedHashMap如何處理hash衝突?

線性探測法。

LinkedHashMap何時擴容?如何擴容?

_data陣列滿時擴容,擴容之前先看陣列中是否有超過一半的元素是處於刪除狀態,是的話擴容長度和原陣列長度是一樣的,否則新陣列長度為原長的2倍。

相關文章