原始碼的魅力 – HashMap 的工作原理

Nichoool發表於2019-03-04

HashMap 的工作原理(Android7.1原始碼)

其他相關文章

  1. 原始碼的魅力 – ArrayDeque 的工作原理
  2. 原始碼的魅力 – ArrayMap的工作原理
  3. 原始碼的魅力 – TreeMap 的工作原理
  4. GankIo又一個ReactNative客戶端

簡介

HashMap 是以雜湊表形式實現的Map (Key-Value)

初始化

   ...
   //儲存資料的雜湊表
   transient HashMapEntry<K,V>[] table = (HashMapEntry<K,V>[]) EMPTY_TABLE;
   ...
   //實際存在的size個數
   transient int size;
   ...
   //table擴充套件的閾值
   int threshold;
   //HashMap建構函式中並沒有對table分配空間 而是使用EMPTY_TABLE
   public HashMap(int initialCapacity, float loadFactor) {
           if (initialCapacity < 0)
               throw new IllegalArgumentException("Illegal initial capacity: " +
                                                  initialCapacity);
           if (initialCapacity > MAXIMUM_CAPACITY) {
               initialCapacity = MAXIMUM_CAPACITY;
           } else if (initialCapacity < DEFAULT_INITIAL_CAPACITY) {
               initialCapacity = DEFAULT_INITIAL_CAPACITY;
           }

           if (loadFactor <= 0 || Float.isNaN(loadFactor))
               throw new IllegalArgumentException("Illegal load factor: " +
                                                  loadFactor);
           // Android-Note: We always use the default load factor of 0.75f.

           // This might appear wrong but it`s just awkward design. We always call
           // inflateTable() when table == EMPTY_TABLE. That method will take "threshold"
           // to mean "capacity" and then replace it with the real threshold (i.e, multiplied with
           // the load factor).
           //註釋的意思是在當table為空(也就是當前,剛建立的HashMap就是一個空列表)時inflateTable中會對table雜湊表進行分配空間
           threshold = initialCapacity;
           //空實現
           init();
       }複製程式碼

新建立的HashMap並沒有對table雜湊表分配記憶體空間,在後面的put方法中我們將分析具體分配空間的位置以及函式.雜湊表的儲存元素是HashMapEntry。

    /** @hide */  // Android added.
    static class HashMapEntry<KV> implements Map.Entry<KV> {
        final K key;
        V value;
        HashMapEntry<K,V> next;
        int hash;

   }複製程式碼

除了Key與Value值之外還有HashMapEntry的引用,這裡先簡單介紹下這個next值,它連結的物件將會是一個連結串列的Head或者紅黑樹的Head,它就是解決HashMap衝突的方法之一 – 連結法。

put 方法

  public V put(K key, V value) {
      if (table == EMPTY_TABLE) {
          inflateTable(threshold);
      }
      if (key == null)
          return putForNullKey(value);
      int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);
      int i = indexFor(hash, table.length);
      for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {
          Object k;
          if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
              V oldValue = e.value;
              e.value = value;
              e.recordAccess(this);
              return oldValue;
          }
      }

      modCount++;
      addEntry(hash, key, value, i);
      return null;
  }複製程式碼
  • 當table為空時,第一次使用put方法時會觸發這個table雜湊表的初始化。
  • 當key是空時,將會插入value,並返回老的資料
  • 通過singleWordWangJenkinsHash方法來獲取HashCode.

        ...
        public static int singleWordWangJenkinsHash(Object k) {
            int h = k.hashCode();
            h += (h <<  15) ^ 0xffffcd7d;
            h ^= (h >>> 10);
            h += (h <<   3);
            h ^= (h >>>  6);
            h += (h <<   2) + (h << 14);
            return h ^ (h >>> 16);
        }複製程式碼

    實質是通過key的hashCode(),然後再處理得到hash值,這個hash的值很重要。

  • 通過indexFor方法計算得出當前資料在table雜湊表的索引位置

    static int indexFor(int h, int length) {
      // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
      return h & (length-1);
    }複製程式碼

    依舊是通過`與`運算來高效計算出索引值,由於length永遠是2的倍數或者是0,所以在這裡位運算提高了速度(通過與上length – 1可以快速計算出index)。
    一般HashMap的平均查詢資料時間複雜度(1),這一個優點主要得益於這個Hashcode的計算,為了更低的衝突率,在前面的singleWordWangJenkinsHash函式中最後一步的h ^ (h >>> 16),就是將h的高16與低16位異或操作,讓hash值盡力不會出現部分位數相同的情況, 讓indexFor計算更加平均,每一個值對應一個index,減少衝突率。

  • 通過獲得的index在雜湊表指定位置找到HashMapEntry,由於HashMap是使用連結法來解決衝突的,所以如果出現衝突(也就是不同的key得到的index相同),通過上面我們講的next值向下查詢如果找到一樣的資料,則替換並返回,如果不存在則在此處新增資料。

    void addEntry(int hash, K key, V value, int bucketIndex) {
       if ((size >= threshold) && (null != table[bucketIndex])) {
           resize(2 * table.length);
           hash = (null != key) ? sun.misc.Hashing.singleWordWangJenkinsHash(key) : 0;
           bucketIndex = indexFor(hash, table.length);
       }
    
       createEntry(hash, key, value, bucketIndex);
    }
    //擴充table雜湊表
    void resize(int newCapacity) {
       HashMapEntry[] oldTable = table;
       int oldCapacity = oldTable.length;
       if (oldCapacity == MAXIMUM_CAPACITY) {
           threshold = Integer.MAX_VALUE;
           return;
       }
    
       HashMapEntry[] newTable = new HashMapEntry[newCapacity];
       transfer(newTable);
       table = newTable;
       threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    //將老列表中的資料插入到新資料表中
    void transfer(HashMapEntry[] newTable) {
        int newCapacity = newTable.length;
        for (HashMapEntry<K,V> e : table) {
            //此處e代表table中的Entry
            while(null != e) {
                //這個while迴圈是如果Entry含有next值,將會順著next向下查詢
                HashMapEntry<K,V> next = e.next;
                //計算在新table中的index
                int i = indexFor(e.hash, newCapacity);
                //將當前Entry拷貝到新位置前如果那個位置存在資料
                //則儲存到Entry的next中
                e.next = newTable[i];
                //移到新位置
                newTable[i] = e;
                e = next;
            }
        }
    }複製程式碼
  • 呼叫addEntry方法

    • 當資料數量達到閾值則要擴充套件成原先的兩倍
    • 在resize函式中,當列表的大小已經是最大值,設定閾值為integer的最大值,不再擴充套件
    • 生成一個新的表,然後執行transfer將老表中的資料轉換到新表中去。
    • 在transfer函式中,先遍歷老表table,找出已經有資料的Entry,重新通過indexFor計算在新表中的index,將原先的entry移到新位置, 如果原先資料中存在next值則繼續順著next進行移動資料。transfer函式不僅僅是擴充套件雜湊表大小那麼簡單,通過transfer這一步可以將原先已經存在的衝突均勻分散開,這一步可以提高當前HashMap的獲取資料的速度, 重點就在indexFor方法中的與操作,待會我將來分析為何起到這個作用
    • transfer完資料後,更新閾值.
    • 結束了resize方法後,重新計算bucketIndex,然後通過createEntry來插入資料.

indexFor 的神奇作用

  //計算公式
  h & (length-1);

  //假設一個Key的hashCode是  0000 0000 0000 0000 0000 0001 1111 1111
  //另一個Key的hashCode是    0000 0000 0000 0000 0000 0000 1111 1111
  //length正好是256 也就是2的八次方                    0001 0000 0000
  //length - 1等於                                   0000 1111 1111
  //執行與運算
  //兩個index一樣 index1 = index2 = 255;
  //當resize()函式執行翻倍時
  //length正好是512 也就是2的九次方                    0010 0000 0000
  //length - 1等於                                   0001 1111 1111
  //執行與運算
  //index1 等於                                      0001 1111 1111
  //index2 等於                                      0000 1111 1111
  //兩者不相等了複製程式碼

通過上面的註釋的計算介紹,可以很清晰的看到原本衝突的兩個key,通過擴充後,並且只要一個indexFor的函式,執行相與操作就可以將衝突完美化解。

get 方法

  public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }


    final Entry<K,V> getEntry(Object key) {
          if (size == 0) {
              return null;
          }
        int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
        for (HashMapEntry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
              Object k;
              if (e.hash == hash &&
                  ((k = e.key) == key || (key != null && key.equals(k))))
                  return e;
          }
          return null;
      }複製程式碼

get方法很簡單,就是通過key直接找資料,第一步獲取hash值,通過indexFor獲取資料在陣列的位置,然後遍歷,如果沒有衝突的話,直接可以獲取到資料並退出遍歷,存在衝突就需要在next的連結串列中查詢。

衝突的壞處

這個標題段也許是多餘的吧,希望不明白的人可以知道下吧。
HashMap的高效率依靠的就是通過HashCode雜湊式插入到表的不同位置,當不存在衝突的時候,get()查詢可以是(1)的時間複雜度,直接就可以取到資料,如果存在衝突就必須沿著next的連結串列一個一個查詢比對,效率大大降低。

題外話

Java8中的HashMap原始碼中,在解決衝突部分,使用了紅黑樹與連結串列替換使用的方式來管理衝突的資料,提高衝突時的get(object)搜尋速度,當衝突資料少時用連結串列,大時使用紅黑樹。
總是HashMap是出了名的用空間換時間的資料結構,也是常用的資料結構,但是記憶體使用率低是它致命的弱點,為此Android有一個ArrayMap資料結構在一定程度上來替代它,下面的章節中我將分析ArrayMap這個資料結構,講解什麼時候使用ArrayMap什麼時候使用HashMap。


更多好文章請關注微信公眾號【Android技術棧】,獵豹移動大牛入駐公眾號將提供給你更好的技術心得,公眾號才剛剛起步希望大家多多支援。

Android技術棧
Android技術棧

相關文章