Java 容器學習之 HashMap

shelbylee發表於2019-01-19

前言

把 Java 容器的學習筆記放到 github 裡了,還在更新~
其他的目前不打算抽出來作為文章寫,感覺挖的還不夠深,等對某些東西理解的更深了再寫文章吧
Java 容器
目錄如下:

後面還會對併發、和一些 Java 基礎的東西做整理
為啥要做那麼多筆記呢?個人比較喜歡把東西寫出來~嘻嘻

如果真的有人認真看了的話,要是有錯誤或者對我寫的感到迷惑的地方,再或者希望對哪些知識再深入瞭解一些,請儘管說出來,給我的個人部落格留言 or 發郵件 or 提 issue 都 ok,我會非常感謝你的~
個人部落格連結


一、HashMap簡介

看一下官方文件中對HashMap的描述

 * Hash table based implementation of the <tt>Map</tt> interface.  This
 * implementation provides all of the optional map operations, and permits
 * <tt>null</tt> values and the <tt>null</tt> key.  (The <tt>HashMap</tt>
 * class is roughly equivalent to <tt>Hashtable</tt>, except that it is
 * unsynchronized and permits nulls.)  This class makes no guarantees as to
 * the order of the map; in particular, it does not guarantee that the order
 * will remain constant over time.
  • HashMap 是基於雜湊表的 Map 介面的實現。
  • 允許使用 null 值和 null 鍵。
  • 除了不同步和允許使用 null 之外,HashMap 類與 Hashtable 大致相同。
  • 不保證順序
  • 不保證該順序恆久不變。

HashMap 底層的資料結構就是陣列+連結串列+紅黑樹,紅黑樹是在 JDK 1.8 中加進來的。尤其是在 JDK 1.8 對它優化以後,HashMap 變成了一個更強的容器…嗯…真的很強。

當新建一個 HashMap 時,就會初始化一個陣列。在這個陣列中,存放的是 Node 類,它擁有指向單獨的一個連結串列的頭結點的引用,這個連結串列是用來解決 hash 衝突的(如果不同的 key 被對映到陣列中同一位置的話,就將其放入連結串列中,從而解決衝突)。

大概就是這樣子... ⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄

陣列
 __
|__|     連結串列
 __       __    __    __    __    
|__|---> |__|->|__|->|__|->|__|->...
 __
|__|
 __
|__|
 __
|__|

           __
          |__| : Node<K, V>

但是,在 JDK 1.8 之前的這種做法,即使負載因子和 Hash 演算法設計的再合理,也無法避免會出現連結串列過長的情況, 一旦連結串列過長,會嚴重影響 HashMap 的效能,所以,在 JDK 1.8 之後,使用了紅黑樹這個資料結構,當連結串列長度大於 8 時,該連結串列就會轉化成紅黑樹,利用紅黑樹快速增刪查改的特點提高 HashMap 的效能。

因為 HashMap 是不同步的,如果需要考慮執行緒安全,需要使用 ConcurrentHashMap,或者可以使用 Collections.synchronizedMap() 方法返回被指定 map 支援的同步的 map。

Map<Integer, Integer> map = Collections.synchronizedMap(new HashMap<>());

二、原始碼分析(基於JDK1.8)

1. 成員變數

// 預設初始容量是16,必須是2的冪  
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16  
  
// 最大容量(必須是2的冪且小於2的30次方,傳入容量過大會被這個值替換)  
static final int MAXIMUM_CAPACITY = 1 << 30;  
  
// 預設載入因子,載入因子就是指雜湊表在其容量自動增加之前可以達到多滿的一種尺度  
static final float DEFAULT_LOAD_FACTOR = 0.75f;  

// 預設的轉換成紅黑樹的閾值,即連結串列長度達到該值時,該連結串列將轉換成紅黑樹
static final int TREEIFY_THRESHOLD = 8;
  
// 儲存Entry的預設空陣列  
static final Entry<?,?>[] EMPTY_TABLE = {};  
  
// 儲存Entry的陣列,長度為2的冪。HashMap採用拉鍊法實現的,
// 每個Entry的本質是個單向連結串列  
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;  
  
// HashMap的大小,即HashMap中實際存在的鍵值對數量 
transient int size;  
  
// 閾值,表示所能容納的key-value對的極限,用於判斷是否需要調整HashMap的容量
// 如果 table 還是空的,那麼這個閾值就是 0 或者是預設的容量 16
int threshold;  
  
// 載入因子實際大小  
final float loadFactor;  
  
// HashMap被修改的次數,用於 fail-fast 機制  
transient int modCount;  

其中需要特別注意的是capacity和load factor這兩個屬性
官方文件中對其描述是:

 The capacity is the number of buckets in the hash table, and the initial capacity is simply the capacity at the time the hash table is created. 

 The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased. 
  • capacity(容量):就是buckets的數目。
  • load factor(負載因子):雜湊表中的填滿程度。

    • 若載入因子設定過大,則填滿的元素越多,從而提高了空間利用率,但是衝突的機會增加了,衝突的越多,連結串列就會變得越長,那麼查詢效率就會變低;
    • 若載入因子設定過小,則填滿的元素越少,那麼空間利用率就會降低,表中資料將變得更加稀疏,但是衝突的機會減小了,這樣連結串列就不會太長,查詢效率就會變高。
    • 一般,如果機器記憶體足夠,想增加查詢速度,可以將load factor設小一點;相反,如果記憶體不足,並且對查詢速度要求不高,可以將load factor設大一點。

2. 靜態內部類 Node

Node 實際上就是一個單連結串列,它實現了Map.Entry介面,其中next也是一個Node物件,用來處理hash衝突,形成一個連結串列。

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next; // 指向下一個節點

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

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

        // 判斷兩個node是否equal(必須key和value都相等)
        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

3. 建構函式

HashMap 有四個建構函式

    /**
    用指定的初始容量和負載因子建立HashMap
     */
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

    /**
    用指定的容量建立HashMap,負載因子為預設的0.75
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
    均使用預設值(初始容量:16 預設負載因子:0.75)
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    /**
     用指定的一個 map 構造一個新HashMap
     新的 HashMap 的負載因子為預設值 0.75,容量為足以裝載該 map 的容量,會在 putMapEntries 中設定
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

4. 確定雜湊桶陣列索引的位置

確定位置這部分是很重要的,無論增刪查鍵值對,首先都要定位到雜湊桶陣列的位置!理想的情況就是陣列中每個位置都只有一個元素,這樣在用演算法求得這個位置後,我們就能直接命中該元素,不用再遍歷連結串列了,這樣可以極大地優化查詢的效率.

在原始碼中,採用的方法就是先根據 hashCode 先計算出 hash 值,然後根據 hash 值再求得索引,從而找到位置。

求hash值

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

(”>>>”為按位右移補零操作符。左運算元的值按右運算元指定的位數右移,移動得到的空位以零填充。)

hash 值的計算主要分三步:

  1. 取key的hashCode值
  2. 高位運算
  3. 對1、2進行異或運算得到hash值

計算索引

// 此處取的put方法片段,這裡就是用(n - 1) & hash 計算的索引(n為表的長度)
 if ((p = tab[i = (n - 1) & hash]) == null)

計算方法其實就是取模運算。

對於計算索引的取模運算,是一個非常非常巧妙的運算~ ヽ(✿゚▽゚)ノ

它是用 hash & (n – 1) 得到索引值,因為 HashMap 底層陣列的長度總是 2 的 n 次方(這是 HashMap 在速度上的優化),通過下面這個函式去保證 table 的長度為 2 的次冪。

    // 這個靜態函式的作用就是返回一個比 cap 大但是又最接近 cap 的 2 次冪的整數
    // 原理就是通過不斷地 位或 和 按位右移補零 操作,
    // 將 n 變成 0..0111..111 這種形式,最後 + 1,就變成了 2 的次冪
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

有了這個前提: n 一定為 2 的 n 次方,那麼這個表示式才能等價於 hash % n,為什麼不直接用 hash % n 呢?因為 & 比 % 具有更高的效率呀,所以採用的是 hash & (n – 1) 而不是 hash % n。

那麼為什麼n 為 2 的 n 次方時 hash & (n – 1) 可以等價於對 n 取模呢?

我是這樣想的

  • 首先,n,即連結串列長度,為 2 的 n 次方,那麼 n 就可以表示成 100…00 的這種樣子,那麼 n – 1 就是 01111…11。

    • 如果 hash < n,& 後都是 hash 本身。
    • 如果 hash = n,& 後結果為 0。
    • 如果 hash > n,& 過後相當於 hash – k*n,即 hash % n。
  • 其次,因為 n 為 2 的次冪,是偶數,偶數最後一位是 0,而 n – 1 肯定是奇數,奇數的最後一位是 1,這樣便保證了 hash & (n – 1) 的最後一位可能為 0 也可能為 1,這樣便可以保證雜湊的均勻性,即均勻分佈在陣列 table 中;而如果 n 為奇數,則 n – 1 肯定是偶數,那麼它的最後一位肯定是 0,這樣 hash & (n – 1) 得到的結果的最後一位肯定是 0,即只能為偶數,這樣任何 hash 值都會被對映到陣列的偶數下標位置上,這就浪費了近一半的空間!

因此,HashMap 的作者要求連結串列的長度必須為 2 的整數次冪,應該就是為了這樣能使不同 hash 值發生碰撞的概率較小,讓元素在雜湊表中均勻的雜湊。

5. put方法原始碼分析

put的過程大致是:

  • 根據key計算hash值
  • 判斷tab是否為空,若為空則進行resize()擴容
  • 根據hash值計算出索引
  • 如果沒有碰撞就直接放入
  • 如果有碰撞,就先放到連結串列裡
  • 若連結串列長度超過8(預設的TREEIFY_THRESHOLD),則轉換成紅黑樹再放
  • 如果key已經存在,就覆蓋其oldValue
  • 插入成功後,如果size > threshold,就要擴容
    public V put(K key, V value) {
        // 計算hash值
        return putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {

        // tab為雜湊表陣列,p為我們要找的那個插入位置的節點
        Node<K,V>[] tab; Node<K,V> p; int n, i;

        // 若tab為空,就建立一個(進行擴容操作)
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        //根據key計算hash值並處理後得到索引,如果表的這個位置為空,則直接插入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 如果不為空
        else {
            Node<K,V> e; K k;
            // 判斷key是否存在,如果存在,則直接覆蓋其value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 判斷該連結串列是否為TreeNode,如果是,紅黑樹直接插入鍵值對
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 如果不是,則遍歷連結串列,然後插入
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 連結串列長度大於8,則轉換成紅黑樹,並插入鍵值對
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 插入
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 如果超過閾值,則進行擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

6. get方法原始碼分析

get方法和put方法過程類似

    public V get(Object key) {
        Node<K,V> e;

        // 計算hash值
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

        // 如果表非空,並且根據計算出的索引值對應的值非空
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
                // 直接命中
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
                // 未直接命中
            if ((e = first.next) != null) {
                // 在紅黑樹中get
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                    // 在連結串列中get
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

7. resize() 的擴容機制

  • 為什麼要進行 resize?

    • 假設 table 的長度為 n,總共需要放入 HashMap 的鍵值對數量為 m,那麼,大約每條連結串列的長度就是 m / n,查詢的時間複雜度也就是 O(m / n),顯然,如果要儘量降低時間複雜度,需要加大 n,也就是對 table 擴容。
  • 什麼時候進行resize?

    • 在 put 過程中,如果發現當前 HashMap 的 size 已經超過了 load factor 希望佔的比例,那麼就會進行 resize 操作。

下面是對 resize 原始碼的分析,這段我覺得是最艱難的一段。。這還跳過了紅黑樹 :(´□`」 ∠):

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            // 如果超過最大值就不再擴容了
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 如果沒超過最大值,並且假設容量 double 後也不超過最大值,
            // 那就擴容為原來的 2 倍,
            // 然後再看原來的容量是不是還夠
            // 如果不夠了,閾值再 double,否則只是擴容,不改變閾值
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        // 從閾值中取出 resize 應該擴容的值
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        // oldCap = 0
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 計算新的閾值
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        // 建立一個新的 table
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        // 指向新的 table
        table = newTab;
        if (oldTab != null) {
            // 把每個bucket都移動到新的buckets中
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    // 刪除
                    oldTab[j] = null;
                    // 如果當前結點是尾結點
                    if (e.next == null)
                        // 重新計算索引值,然後放入元素
                        newTab[e.hash & (newCap - 1)] = e;
                    // 如果當前結點已經轉換成紅黑樹了
                    else if (e instanceof TreeNode)
                        // 將樹上的結點 rehash,然後放到新位置,紅黑樹這塊以後在分析
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // 進行連結串列複製
                        // lo鏈的新索引值和以前相同
                        // hi鏈的新索引值為:原索引值 + oldCap 
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            /**
                            (e.hash & oldCap) == 0 這個地方比較難理解,但也是擴容最關鍵的地方

                            假設現在 (e.hash & oldCap) == 0 為 true

                            oldCap 和 new Cap 肯定都是 2 的次冪,也就是 100... 這種形式,那麼假如現在 oldCap = 16,

                            那麼原索引為 
                            e.hash & (oldCap - 1) = e.hash & 01111 --> index ①
                            新的索引為
                            e.hash & (newCap - 1) = e.hash & 11111

                            同時我們已知 e.hash & oldCap = 0,
                            即 e.hash & 10000 = 0 ②

                            通過 ① ② 就可以推出
                            e.hash & 11111 --> index 
                            即索引位置不變
                            */
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

擴容部分完結撒花 ★,°:.☆( ̄▽ ̄)/$:.°★

三、關於 HashMap 的執行緒安全性

HashMap 不是執行緒安全的,它在被設計的時候就沒有考慮執行緒安全,因為這本來就不是一個併發容器,相應的併發容器是 ConcurrentHashMap,那麼,HashMap 的執行緒不安全性主要體現在哪兒呢?

最著名的一個就是高併發環境下的死迴圈問題,具體是在 resize 時產生的。

這種死迴圈產生的主要原因是因為 1.7 的 resize 中,新的 table 採用的插入方式是隊頭插入(LIFO,後進先出),比如元素為 {3,5,7,9},插入後就是 {9,7,5,9},會將連結串列順序逆置,它這樣做主要是為了防止遍歷連結串列尾部,因為 resize 本來就是建立了一個新的 table,所以對於元素的順序不關心,因此採用隊頭插入的方式,如果是正常的從尾部插入的話,還需要先找到尾部的位置,增加了遍歷的消耗,而 resize 又正好不在乎元素順序,所以就使用的隊頭插入的方式。

但是這種方式帶來了一個問題,就是死迴圈,具體死迴圈怎麼產生的我就不贅述了,因為網上有很多關於這個的具體分析,我要說的是,在 JDK 1.8 中,HashMap 除了加入了紅黑樹這個資料結構外還有一些其他的調整,在 resize 時對連結串列的操作,變成了兩對指標分別對 lo鏈 和 hi鏈 操作。

                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;          

因為增加了xxTail指標,所以可以隨時找到尾部,避免遍歷尾部,因此可以直接在尾部插入,因而避免了死迴圈問題。

不過這不代表 JDK 1.8 的HashMap就是執行緒安全了的,因為很明顯還存在比如併發時元素的覆蓋之類的問題,所以多執行緒環境下還是建議使用 ConcurrentHashMap 或者進行同步操作。

相關文章