1. 程式人生 > >JAVA筆記 —— HashMap(1.7) 底層實現原理

JAVA筆記 —— HashMap(1.7) 底層實現原理

HashMap 底層實現原理  

  兩年前,我總覺得很多東西會用就行,不喜歡總結,不喜歡深入瞭解,這或許就是因為當時太懶。一年前,我覺得必須要把在工作積累到的東西、遇到的問題及解決方法給總結記錄下來,以便快速提升自己,所以從那時候起就開始寫 txt 文字,做一些簡單記錄。而至今,工作近三年,我越來越覺得了解底層原理的重要性。

一、HashMap本質:陣列 + 連結串列

  在JAVA資料結構中,常用陣列和連結串列這兩種結構來儲存資料。

  陣列的儲存區間(在記憶體的地址)是連續的,其大小固定,一旦分配就不能被其他引用佔用,佔用記憶體嚴重。陣列的特點是:定址容易,查詢操作快,時間複雜度為O(1);但插入和刪除的操作比較慢,時間複雜度是O(n)。

  連結串列的儲存區間是非連續(離散)的,其大小不固定,可以擴容,佔用記憶體比較寬鬆,故空間複雜度很小。連結串列的特點是:定址困難,查詢速度慢,複雜度是O(n),插入快,時間複雜度為O(1)。

  HashMap的資料結構:陣列 + 連結串列(單鏈表),結合了兩者的優點。HashMap的主幹是一個Entry陣列,陣列每一個元素的初始值都是Null。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。

  HashMap的初始長度為16,且每次自動擴容或者手動初始化的時候必須是2的冪(以2次方增長)。所以,HashMap 的容量值都是 2^n 大小。

Entry是HashMap中的一個靜態內部類。原始碼如下:

    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;  // 儲存指向下一個Entry的引用,單鏈表結構
        int hash;         // 對key的hashcode值進行hash運算後得到的值,儲存在Entry,避免重複計算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (!(o instanceof Map.Entry))
                return false;
            Map.Entry e = (Map.Entry)o;
            Object k1 = getKey();
            Object k2 = e.getKey();
            if (k1 == k2 || (k1 != null && k1.equals(k2))) {
                Object v1 = getValue();
                Object v2 = e.getValue();
                if (v1 == v2 || (v1 != null && v1.equals(v2)))
                    return true;
            }
            return false;
        }

        public final int hashCode() {
            return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
        }

        public final String toString() {
            return getKey() + "=" + getValue();
        }

        /**
         * This method is invoked whenever the value in an entry is
         * overwritten by an invocation of put(k,v) for a key k that's already
         * in the HashMap.
         */
        void recordAccess(HashMap<K,V> m) {
        }

        /**
         * This method is invoked whenever the entry is
         * removed from the table.
         */
        void recordRemoval(HashMap<K,V> m) {
        }
    }

二、HashMap -- Put 方法實現

方法實現:將指定值與此對映中的指定鍵關聯。如果對映以前包含了鍵的對映,則值被替換。

執行put存值時,HashMap首先會獲取key的雜湊值,通過雜湊值快速找到某個存放位置,這個位置可以被稱之為bucketIndex。當計算出來的bucketIndex相同(hash碰撞)時,則通過hashCodeequals最終判斷出K(key)是否已存在,如果已存在,則使用新V值替換舊V值,並返回舊V值;如果不存在 ,則存放新的鍵值對<K, V>到bucketIndex位置。對於一個key,如果hashCode不同,equals一定為false,如果hashCode相同,equals不一定為true。

原始碼如下:

    // 將指定值與此對映中的指定鍵關聯。如果對映以前包含了鍵的對映,則值被替換。
    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        // 當key為null,呼叫putForNullKey方法,將該鍵值對新增到table[0]中,這是HashMap允許為null的原因 
        if (key == null)
            return putForNullKey(value);
        // 計算key的hash值
        int hash = hash(key);
        // 計算key hash 值在 table 陣列中的位置
        int i = indexFor(hash, table.length);
        // 從i出開始迭代 e,找到 key 儲存的位置 
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            // 判斷該條鏈上是否有hash值相同的(key相同)  
            // 若存在相同,則直接覆蓋value,返回舊value,equals方法是hash碰撞時才會執行的方法 
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;   // 舊值 = 新值
                e.value = value;
                e.recordAccess(this);
                return oldValue;        // 返回舊值 
            }
        }
        // modCount++代表修改次數+1,與迭代相關
        modCount++;
        // 增加新的節點,將key、value新增至i位置處
        addEntry(hash, key, value, i);
        return null;
    }

例子 : hashMap.put(“clear”, 888)

首先計算key的hash值:int hash = hash(“clear”);

接著計算key hash 值在 table 陣列中的位置bucketIndex:int i = indexFor(hash, table.length);

假定最後計算出的bucketIndex是1,那麼結果如下 :

HashMap通過鍵的hashCode存取元素,HashCode是使用Key通過Hash函式計算出來的,當插入的Entry越來越多時,由於不同的Key,通過此Hash函式可能會算的同樣的HashCode,即發生了HashCode碰撞,也叫Hash衝突。此時,HashMap通過單鏈表來解決,把對應節點以連結串列的形式儲存,將新元素加入連結串列表頭,通過next指向原有的元素。

  頭插法:新節點都增加到頭部,新節點的next指向老節點;如下圖中新的 Entry 2 指向舊的 Entry 1

Put 方法執行流程:

1、首先判斷key是否為null,當插入的key為null時,呼叫putForNullKey方法,預設儲存到table[0]開頭的連結串列。然後遍歷table[0]的連結串列的每個節點Entry,如果發現其中存在節點Entry的key為null,就替換新的value,然後返回舊的value,如果沒發現key等於null的節點Entry,就增加新的節點。

    /**
     * Offloaded version of put for null keys
     * 獲取key為null的鍵值對,HashMap將此鍵值對儲存到table[0]的位置 
     */
    private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;   // 修改次數+1
        addEntry(0, null, value, 0);  // 增加新的節點到 table[0] 位置
        return null;  // 返回 null
    }

2、計算key的hash值,int hash = hash(key.hashCode()),再用計算的結果二次hash(indexFor(hash, table.length)),找到Entry陣列的索引 i

3、遍歷以table[i]為頭節點的連結串列,如果發現hash,key都相同的節點時,就替換為新的value,然後返回舊的value,只有hash相同時,迴圈內並沒有做任何處理。

 4、對於hash相同但key不相同的節點以及hash不相同的節點,就增加新的節點( addEntry() )。

    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

   5、HashMap擴容問題。由於table陣列的預設初始長度是固定的(16),隨著HashMap中的元素數量越來越多的時候,發生hash碰撞的概率就越來越大,所產生的連結串列長度就會越來越長,這樣就會影響HashMap的查詢速度。為了提高HashMap的查詢效率,就要對HashMap的陣列table進行擴容。系統必須要在某個臨界點進行擴容處理,該臨界點在當HashMap中元素的數量等於table陣列長度 * 載入因子(如 16 * 0.75 = 12 )。

  resize(2 * table.length);// 當HashMap中元素個數超過16*0.75=12時,就把陣列的大小擴充套件為 2*16=32,即擴大一倍

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

擴容是一個非常耗時耗效能的過程,因為它需要重新計算每個元素在新table陣列中的位置並進行復制處理。所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的效能。

三、HashMap -- Get 方法實現

Get 方法執行流程:

首先會判斷key,若為null,呼叫getForNullKey方法返回相對應的value;

原始碼如下:

   public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);
        return null == entry ? null : entry.getValue();
    }
   private V getForNullKey() {
        if (size == 0) {
            return null;
        }
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

把輸入的Key做一次Hash對映,得到對應的index:int hash = (key == null) ? 0 : hash(“clear”);由於存在Hash衝突,同一個位置有可能匹配到多個Entry,這時候就需要順著對應連結串列的頭節點,一個一個向下來查詢。 e.next

   final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<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;
    }

四、HashMap的建構函式

  public class HashMap<K,V>    extends AbstractMap<K,V>    implements Map<K,V>, Cloneable, Serializable { ... }

  HashMap實現了Map介面,繼承AbstractMap。其中Map介面定義了鍵對映到值的規則,而AbstractMap類提供 Map 介面的骨幹實現。

HashMap提供了三個建構函式:

  HashMap():構造一個具有預設初始容量 (16) 和預設載入因子 (0.75) 的空 HashMap。

  HashMap(int initialCapacity):構造一個帶指定初始容量預設載入因子 (0.75) 的空 HashMap。

       HashMap(int initialCapacity, float loadFactor):構造一個帶指定初始容量指定載入因子的空 HashMap。

原始碼如下:

    // HashMap的三個建構函式  -- 原始碼檢視
    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity (16) and the default load factor (0.75).
     *使用預設初始容量(16)和預設負載因子(0.75)來構造空<TT> HashMap </TT>
     */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial capacity and the default load factor (0.75).
     * 用指定的初始容量和預設負載因子(0.75)來構造空<TT> HashMap </TT>
     * 如果初始容量為負值,則丟擲非法的異常。
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial capacity and load factor.
     * 用指定的初始容量和負載係數 來構造空<TT> HashMap </TT>
     * initialCapacity 設定的初始化容量,或者說是 HashMap 擴充陣列時的閥值  
     * loadFactor 負載因子,預設時 0.75  
     * 如果初始容量為負值或負載因子為非正,則丟擲非法邏輯異常   
     */
    public HashMap(int initialCapacity, float loadFactor) {
        // 初始容量不能<0 
        if (initialCapacity < 0)             
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // 初始容量不能 > 最大容量值,HashMap的最大容量值為2^30
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        // 負載因子不能 < 0 
        if (loadFactor <= 0 || Float.isNaN(loadFactor))         
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        init();
    }

五、HashMap執行緒不安全原因

Hashmap在 HashMap.Size >= Capacity * LoadFactor 時,就會呼叫 resize 方法,進行擴容ReHash兩個步驟 。此時,若在單執行緒情況下,rehash 不會出現任何問題;若在多執行緒情況下,rehash 則可能會導致hashmap出現連結串列閉環,程式就會進入死迴圈,所以HashMap是非執行緒安全的。

因此,在高併發場景下,我們通常採用另一個集合類ConcurrentHashMap,這個集合類兼顧了執行緒安全和效能。

六、總結

  1、HashMap結合了陣列和(單)連結串列的優點,使用Hash演算法加快訪問速度,使用連結串列解決hash碰撞衝突的問題,其中陣列的每個元素是單鏈表的頭結點。
  2、HashMap的put方法中,當HashMap中元素的數量大於等於table陣列長度 * 載入因子時,要對hashMap進行擴容,擴容過程始終以2次方增長,因此,HashMap 的容量一定是2的整數次冪,即 2^n
  3、從HashMap的put和get方法中可以看出,HashMap是泛型類,key和value可以為任何型別,包括null型別。key為null的鍵值對永遠都放在以table[0]為頭結點的中,當然不一定是存放在頭結點table[0]中。
  4、HashMap有三個建構函式,有兩個重要引數:初始容量載入因子。預設初始容量(16)和預設負載因子(0.75)。
  5、HashMap非執行緒安全