HashMap

東西都是轉自參考
作者:張拭心
https://blog.csdn.net/u011240877/article/details/53351188

https://blog.csdn.net/u011240877/article/details/53358305

why ? when ? what ? how ?

什麼是 HashMap?

Hash: 散列

Map:意思是地圖(x,y)存儲值

HashMap 是一個採用hash表實現鍵值對集合,繼承 AbstractMap,實現了Map接口。

HashMap 先通過哈希運算,得到目標元素在哈希表中的值,然後再進行少量比較就可以得到元素。當哈希衝突時候,HashMap採用拉鍊法進行解決。HashMap 的底層實現是採用數組+鏈表,jdk1.8之後當衝突元素達到8個後用紅黑樹。

HashMap 的特點

  1. key 用 Set 存放,所以想做到 key 不允許重複,key對應的類需要重寫 hashCode 和 equals 方法
  2. 元素是無序的,而且順序會不定時改變
  3. 插入、獲取的時間複雜度基本是 O(1)(前提是有適當的哈希函數,讓元素分佈在均勻的位置)
  4. 兩個關鍵因子:初始容量、加載因子

HashMap 的 13 個成員變量

1.默認初始容量:16,必須是 2 的整數次方

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

2.最大容量: 2^ 30 次方

static final int MAXIMUM_CAPACITY = 1 << 30;

3.默認加載因子的大小:0.75,可不是隨便的,結合時間和空間效率考慮得到的

static final float DEFAULT_LOAD_FACTOR = 0.75f;

4.當前 HashMap 修改的次數,這個變量用來保證 fail-fast 機制

transient int modCount;

5.閾值,下次需要擴容時的值,等於 容量*加載因子

int threshold;

6.樹形閾值:JDK 1.8 新增的,當使用 樹 而不是列表來作爲桶時使用。必須必 2 大

static final int TREEIFY_THRESHOLD = 8;

7.非樹形閾值:也是 1.8 新增的,擴容時分裂一個樹形桶的閾值,要比 TREEIFY_THRESHOLD 小

static final int UNTREEIFY_THRESHOLD = 6;

8.樹形最小容量:桶可能是樹的哈希表的最小容量。至少是 TREEIFY_THRESHOLD 的 4 倍,這樣能避免擴容時的衝突

static final int MIN_TREEIFY_CAPACITY = 64;

9.緩存的 鍵值對集合(另外兩個視圖:keySet 和 values 是在 AbstractMap 中聲明的)

transient Set

HashMap的初始容量和加載因子

由於 HashMap 擴容開銷很大(需要創建新數組、重新哈希、分配等),決定因素:

  1. 容量:數組的數量
  2. 加載因子:決定了 HashMap 中元素佔有多少比例時擴容

HashMap 的默認加載因子爲 0.75,這是在時間、空間方面均衡考慮

  1. 如果加載因子過大那麼衝突的可能就會大,查找效率反而變低
  2. 太小的話頻繁 rehash,導致性能降低

如果提前知道需要存儲的容量大小,可以設置大點的容量,這樣可以少擴容幾次,設計合理的加載因子,儘可能避免進行擴容。

HashMap 的關鍵方法

HashMap 的 4 個構造方法

//創建一個空的哈希表,初始容量爲 16,加載因子爲 0.75
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//創建一個空的哈希表,指定容量,使用默認的加載因子
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//創建一個空的哈希表,指定容量和加載因子
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);
}

//創建一個內容爲參數 m 的內容的哈希表
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

其中第三種構造方法調用了 tableSizeFor(int) 來根據指定的容量設置閾值,這個方法經過若干次無符號右移、求異運算,得出最接近指定參數 cap 的 2 的 N 次方容量。假如你傳入的是 5,返回的初始容量爲 8 。

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

第四種構造方法調用了 putMapEntries(),這個方法用於向哈希表中添加整個集合:

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {
        //數組還是空,初始化參數
        if (table == null) { // pre-size
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        //數組不爲空,超過閾值就擴容
        else if (s > threshold)
            resize();
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            //先經過 hash() 計算位置,然後複製指定 map 的內容
            putVal(hash(key), key, value, false, evict);
        }
    }
}

HashMap 中的鏈表節點

JDK1.8之前 HashMap 底層數據結構是數組+鏈表
//實現了 Map.Entry 接口
static class Node

HashMap 中的添加操作

put() 是我們使用 HashMap 最頻繁的幾個操作之一。

下文用“桶”來指代要數組,每個桶都對應着一條鏈表:
//添加指定的鍵值對到 Map 中,如果已經存在,就替換
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) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //如果當前 哈希表內容爲空,新建,n 指向最後一個桶的位置,tab 爲哈希表另一個引用
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //如果要插入的位置沒有元素,新建個節點並放進去
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        //如果要插入的桶已經有元素,替換
        // e 指向被替換的元素
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //p 指向要插入的桶第一個 元素的位置,如果 p 的哈希值、鍵、值和要添加的一樣,就停止找,e 指向 p
            e = p;
        else if (p instanceof TreeNode)
            //如果不一樣,而且當前採用的還是 JDK 8 以後的樹形節點,調用 putTreeVal 插入
            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;
                }
                // 如果找到要替換的節點,就停止,此時 e 已經指向要被替換的節點
                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;
}

根據代碼可以總結插入邏輯如下:

  1. 先調用 hash()方法進行計算哈希值
  2. 然後調用 putVal()方法根據哈希值進行相關操作
  3. 如果當前哈希表內容爲空,新建一個哈希表
  4. 如果要插入的桶中沒有元素,新建個節點放進去
  5. 否則從桶中第一個元素開始查找哈希值對應位置

    1.如果桶中第一個元素的哈希值要和添加的一樣,替換,結束查找

    2.如果第一個元素不一樣,而且當前採用的還是 JDK8以後的樹形節點,調用 putTreeVal()進行插入

    3.否則還是從傳統的鏈表數據中查找、替換、結束查找

    4.當這個桶內鏈表個數大於等於 8 ,就要調用 treeifyBin方法進行樹形化

  6. 最後檢查是否需要擴容

插入過程中涉及到幾個其他關鍵的方法 :

hash():計算對應的位置

resize():擴容

putTreeVal():樹形節點的插入

treeifyBin():樹形化容器

HashMap 中的哈希函數 hash()

HashMap 中通過將傳入鍵的 hashCode 進行無符號右移 16 位,然後進行按位異或,得到這個鍵的哈希值。

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

由於哈希表的容量都是 2 的 N 次方,在當前,元素的 hashCode() 在很多時候下低位是相同的,這將導致衝突(碰撞),因此 1.8 以後做了個移位操作:將元素的 hashCode() 和自己右移 16 位後的結果求異或。

這樣可以避免只靠低位數據來計算哈希時導致的衝突,計算結果由高低位結合決定,可以避免哈希值分佈不均勻。

而且,採用位運算效率更高。

HashMap 中的初始化/擴容方法 resize

每次添加時會比較當前元素個數和閾值:

//如果超出閾值,就得擴容
if (++size > threshold)
    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;
        }
        //新的容量爲舊的兩倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //如果舊容量小於等於 16,新的閾值就是舊閾值的兩倍
            newThr = oldThr << 1; // double threshold
    }
    //如果舊容量爲 0 ,並且舊閾值>0,說明之前創建了哈希表但沒有添加元素,初始化容量等於閾值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        //舊容量、舊閾值都是0,說明還沒創建哈希表,容量爲默認容量,閾值爲 容量*加載因子
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //如果新的閾值爲 0 ,就得用 新容量*加載因子 重計算一次
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    //更新閾值
    threshold = newThr;
    //創建新鏈表數組,容量是原來的兩倍
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //接下來就得遍歷複製了
    if (oldTab != null) {
        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)
                    //如果舊哈希表中這個位置的桶是樹形結構,就要把新哈希表裏當前桶也變成樹形結構
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { //保留舊哈希表桶中鏈表的順序
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        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;
}

擴容過程中幾個關鍵的點:

  1. 新初始化哈希表時,容量爲默認容量,閾值爲 容量*加載因子
  2. 已有哈希表擴容時,容量、閾值均翻倍
  3. 如果之前這個桶的節點類型是樹,需要把新哈希表裏當前桶也變成樹形結構
  4. 複製給新哈希表中需要重新索引(rehash),這裏採用的計算方法是
    e.hash & (newCap - 1),等價於 e.hash % newCap

結合擴容源碼可以發現擴容的確開銷很大,需要迭代所有的元素,rehash、賦值,還得保留原來的數據結構。

所以在使用的時候,最好在初始化的時候就指定好 HashMap 的長度,儘量避免頻繁 resize()。

HashMap 的獲取方法 get()

HashMap 另外一個經常使用的方法就是 get(key),返回鍵對應的值:

如果 HashMap 中包含一個鍵值對 k-v 滿足:

(key == null ? k == null : key.equals(k))

就返回值 v,否則返回 null;

   public V get(Object key) {
    Node<K,V> e;
    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;
    //tab 指向哈希表,n 爲哈希表的長度,first 爲 (n - 1) & hash 位置處的桶中的頭一個節點
    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) {
            if (first instanceof TreeNode)
                //如果是樹形節點,就調用樹形節點的 get 方法
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                //do-while 遍歷鏈表的所有節點
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

查找 方法比較簡單:

  1. 先計算哈希值;
  2. 然後再用 (n - 1) & hash 計算出桶的位置;
  3. 在桶裏的鏈表進行遍歷查找。

時間複雜度一般跟鏈表長度有關,因此哈希算法越好,元素分佈越均勻,get() 方法就越快,不然遍歷一條長鏈表,太慢了。

不過在 JDK 1.8 以後 HashMap 新增了紅黑樹節點,優化這種極端情況下的性能問題。

紅黑樹操作

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;  // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;    // needed to unlink next upon deletion
boolean red;
}

可以看到就是個紅黑樹節點,有父親、左右孩子、前一個元素的節點,還有個顏色值。

另外由於它繼承自 LinkedHashMap.Entry ,而 LinkedHashMap.Entry 繼承自 HashMap.Node ,因此還有額外的 6 個屬性:

//繼承 LinkedHashMap.Entry 的
Entry<K,V> before, after;

//HashMap.Node 的
final int hash;
final K key;
V value;
Node<K,V> next;

HashMap 中關於紅黑樹的三個關鍵參數

HashMap 中有三個關於紅黑樹的關鍵參數:

  1. TREEIFY_THRESHOLD
  2. UNTREEIFY_THRESHOLD
  3. MIN_TREEIFY_CAPACITY

值及作用如下:

//一個桶的樹化閾值
//當桶中元素個數超過這個值時,需要使用紅黑樹節點替換鏈表節點
//這個值必須爲 8,要不然頻繁轉換效率也不高
static final int TREEIFY_THRESHOLD = 8;

//一個樹的鏈表還原閾值
//當擴容時,桶中元素個數小於這個值,就會把樹形的桶元素 還原(切分)爲鏈表結構
//這個值應該比上面那個小,至少爲 6,避免頻繁轉換
static final int UNTREEIFY_THRESHOLD = 6;

//哈希表的最小樹形化容量
//當哈希表中的容量大於這個值時,表中的桶才能進行樹形化
//否則桶內元素太多時會擴容,而不是樹形化
//爲了避免進行擴容、樹形化選擇的衝突,這個值不能小於 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;

桶的樹形化 treeifyBin()

在Java 8 中,如果一個桶中的元素個數超過 TREEIFY_THRESHOLD(默認是 8 ),就使用紅黑樹來替換鏈表,從而提高速度。

這個替換的方法叫 treeifyBin() 即樹形化。

//將桶內所有的 鏈表節點 替換成 紅黑樹節點
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //如果當前哈希表爲空,或者哈希表中元素的個數小於 進行樹形化的閾值(默認爲 64),就去新建/擴容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        //如果哈希表中的元素個數超過了 樹形化閾值,進行樹形化
    // e 是哈希表中指定位置桶裏的鏈表節點,從第一個開始
        TreeNode<K,V> hd = null, tl = null;//紅黑樹的頭、尾節點
        do {
            //新建一個樹形節點,內容和當前鏈表節點 e 一致
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)//確定樹頭節點
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        //讓桶的第一個元素指向新建的紅黑樹頭結點,以後這個桶裏的元素就是紅黑樹而不是鏈表了
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

上述操作做了這些事:

  1. 根據哈希表中元素個數確定是擴容還是樹形化
  2. 如果是樹形化

    1.遍歷桶中的元素,創建相同個數的樹形節點,複製內容,建立起聯繫

    2.然後讓桶第一個元素指向新建的樹頭結點,替換桶的鏈表內容爲樹形內容

但是我們發現,之前的操作並沒有設置紅黑樹的顏色值,現在得到的只能算是個二叉樹。在 最後調用樹形節點 hd.treeify(tab) 方法進行塑造紅黑樹,來看看代碼:

final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        if (root == null) { //頭回進入循環,確定頭結點,爲黑色
            x.parent = null;
            x.red = false;
            root = x;
        }
        else {  //後面進入循環走的邏輯,x 指向樹中的某個節點
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            //又一個循環,從根節點開始,遍歷所有節點跟當前節點 x 比較,調整位置,有點像冒泡排序
            for (TreeNode<K,V> p = root;;) {
                int dir, ph;        //這個 dir 
                K pk = p.key;
                if ((ph = p.hash) > h)  //當比較節點的哈希值比 x 大時, dir 爲 -1
                    dir = -1;
                else if (ph < h)  //哈希值比 x 小時 dir 爲 1
                    dir = 1;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    // 如果比較節點的哈希值、 x 
                    dir = tieBreakOrder(k, pk);

                    //把 當前節點變成 x 的父親
                    //如果當前比較節點的哈希值比 x 大,x 就是左孩子,否則 x 是右孩子 
                TreeNode<K,V> xp = p;
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    moveRootToFront(tab, root);
}

可以看到,將二叉樹變爲紅黑樹時,需要保證有序。這裏有個雙重循環,拿樹中的所有節點和當前節點的哈希值進行對比(如果哈希值相等,就對比鍵,這裏不用完全有序),然後根據比較結果確定在樹種的位置。

紅黑樹中添加元素 putTreeVal()

在添加時,如果一個桶中已經是紅黑樹結構,就要調用紅黑樹的添加元素方法 putTreeVal()。

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                               int h, K k, V v) {
    Class<?> kc = null;
    boolean searched = false;
    TreeNode<K,V> root = (parent != null) ? root() : this;
    //每次添加元素時,從根節點遍歷,對比哈希值
    for (TreeNode<K,V> p = root;;) {
        int dir, ph; K pk;
        if ((ph = p.hash) > h)
            dir = -1;
        else if (ph < h)
            dir = 1;
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))  
        //如果當前節點的哈希值、鍵和要添加的都一致,就返回當前節點(奇怪,不對比值嗎?)
            return p;
        else if ((kc == null &&
                  (kc = comparableClassFor(k)) == null) ||
                 (dir = compareComparables(kc, k, pk)) == 0) {
            //如果當前節點和要添加的節點哈希值相等,但是兩個節點的鍵不是一個類,只好去挨個對比左右孩子 
            if (!searched) {
                TreeNode<K,V> q, ch;
                searched = true;
                if (((ch = p.left) != null &&
                     (q = ch.find(h, k, kc)) != null) ||
                    ((ch = p.right) != null &&
                     (q = ch.find(h, k, kc)) != null))
                    //如果從 ch 所在子樹中可以找到要添加的節點,就直接返回
                    return q;
            }
            //哈希值相等,但鍵無法比較,只好通過特殊的方法給個結果
            dir = tieBreakOrder(k, pk);
        }

        //經過前面的計算,得到了當前節點和要插入節點的一個大小關係
        //要插入的節點比當前節點小就插到左子樹,大就插到右子樹
        TreeNode<K,V> xp = p;
     //這裏有個判斷,如果當前節點還沒有左孩子或者右孩子時才能插入,否則就進入下一輪循環 
        if ((p = (dir <= 0) ? p.left : p.right) == null) {
            Node<K,V> xpn = xp.next;
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
            if (dir <= 0)
                xp.left = x;
            else
                xp.right = x;
            xp.next = x;
            x.parent = x.prev = xp;
            if (xpn != null)
                ((TreeNode<K,V>)xpn).prev = x;
            //紅黑樹中,插入元素後必要的平衡調整操作
            moveRootToFront(tab, balanceInsertion(root, x));
            return null;
        }
    }
}

//這個方法用於 a 和 b 哈希值相同但是無法比較時,直接根據兩個引用的地址進行比較
//這裏源碼註釋也說了,這個樹裏不要求完全有序,只要插入時使用相同的規則保持平衡即可
 static int tieBreakOrder(Object a, Object b) {
    int d;
    if (a == null || b == null ||
        (d = a.getClass().getName().
         compareTo(b.getClass().getName())) == 0)
        d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
             -1 : 1);
    return d;
}

通過上面的代碼可以知道,HashMap 中往紅黑樹中添加一個新節點 n 時,有以下操作:

  1. 從根節點開始遍歷當前紅黑樹中的元素 p,對比 n 和 p 的哈希值;
  2. 如果哈希值相等並且鍵也相等,就判斷爲已經有這個元素(這裏不清楚爲什麼不對比值);
  3. 如果哈希值就通過其他信息,比如引用地址來給個大概比較結果,這裏可以看到紅黑樹的比較並不是很準確,註釋裏也說了,只是保證個相對平衡即可;
  4. 最後得到哈希值比較結果後,如果當前節點 p 還沒有左孩子或者右孩子時才能插入,否則就進入下一輪循環;
  5. 插入元素後還需要進行紅黑樹例行的平衡調整,還有確保根節點的領先地位。

紅黑樹中查找元素 getTreeNode()

HashMap 的查找方法是 get():

public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

它通過計算指定 key 的哈希值後,調用內部方法 getNode();

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) {
        if (first instanceof TreeNode)
            return ((TreeNode<K,V>)first).getTreeNode(hash, key);
        do {
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        } while ((e = e.next) != null);
    }
}
return null;
}

樹形結構修剪 split()

HashMap 中, resize() 方法的作用就是初始化或者擴容哈希表。當擴容時,如果當前桶中元素結構是紅黑樹,並且元素個數小於鏈表還原閾值 UNTREEIFY_THRESHOLD (默認爲 6),就會把桶中的樹形結構縮小或者直接還原(切分)爲鏈表結構,調用的就是 split():

//參數介紹
//tab 表示保存桶頭結點的哈希表
//index 表示從哪個位置開始修剪
//bit 要修剪的位數(哈希值)
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        //如果當前節點哈希值的最後一位等於要修剪的 bit 值
        if ((e.hash & bit) == 0) {
                //就把當前節點放到 lXXX 樹中
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            //然後 loTail 記錄 e
            loTail = e;
            //記錄 lXXX 樹的節點數量
            ++lc;
        }
        else {  //如果當前節點哈希值最後一位不是要修剪的
                //就把當前節點放到 hXXX 樹中
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            //記錄 hXXX 樹的節點數量
            ++hc;
        }
    }


    if (loHead != null) {
        //如果 lXXX 樹的數量小於 6,就把 lXXX 樹的枝枝葉葉都置爲空,變成一個單節點
        //然後讓這個桶中,要還原索引位置開始往後的結點都變成還原成鏈表的 lXXX 節點
        //這一段元素以後就是一個鏈表結構
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
        //否則讓索引位置的結點指向 lXXX 樹,這個樹被修剪過,元素少了
            tab[index] = loHead;
            if (hiHead != null) // (else is already treeified)
                loHead.treeify(tab);
        }
    }
    if (hiHead != null) {
        //同理,讓 指定位置 index + bit 之後的元素
        //指向 hXXX 還原成鏈表或者修剪過的樹
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

從上述代碼可以看到,HashMap 擴容時對紅黑樹節點的修剪主要分兩部分,先分類、再根據元素個數決定是還原成鏈表還是精簡一下元素仍保留紅黑樹結構。

1.分類

指定位置、指定範圍,讓指定位置中的元素 (hash & bit) == 0 的,放到 lXXX 樹中,不相等的放到 hXXX 樹中。

2.根據元素個數決定處理情況

符合要求的元素(即 lXXX 樹),在元素個數小於 6 時還原成鏈表,最後讓哈希表中修剪的痛 tab[index] 指向 lXXX 樹;在元素個數大於 6 時,還是用紅黑樹,只不過是修剪了下枝葉;

不符合要求的元素(即 hXXX 樹)也是一樣的操作,只不過最後它是放在了修剪範圍外 tab[index + bit]。


總結

JDK 1.8 以後哈希表的 添加、刪除、查找、擴容方法都增加了一種 節點爲 TreeNode 的情況:

  1. 添加時,當桶中鏈表個數超過 8 時會轉換成紅黑樹;
  2. 刪除、擴容時,如果桶中結構爲紅黑樹,並且樹中元素個數太少的話,會進行修剪或者直接還原成鏈表結構;
  3. 查找時即使哈希函數不優,大量元素集中在一個桶中,由於有紅黑樹結構,性能也不會差。

1.HashMap 的缺點:不同步

當多線程併發訪問一個 哈希表時,需要在外部進行同步操作,否則會引發數據不同步問題。

你可以選擇加鎖,也可以考慮用 Collections.synchronizedMap 包一層,變成個線程安全的 Map:

Map m = Collections.synchronizedMap(new HashMap(...));

最好在初始化時就這麼做。

2.HashMap 三個視圖返回的迭代器都是 fail-fast 的:如果在迭代時使用非迭代器方法修改了 map 的內容、結構,迭代器就會報 ConcurrentModificationException 的錯。

3.當 HashMap 中有大量的元素都存放到同一個桶中時,這時候哈希表裏只有一個桶,這個桶下有一條長長的鏈表,這個時候 HashMap 就相當於一個單鏈表,假如單鏈表有 n 個元素,遍歷的時間複雜度就是 O(n),完全失去了它的優勢。

針對這種情況,JDK 1.8 中引用了 紅黑樹(時間複雜度爲 O(logn)) 優化這個問題。

4.HashMap 允許 key, value 爲 null,同時他們都保存在第一個桶中。

5.HashMap 中 equals() 和 hashCode() 有什麼作用?

HashMap 的添加、獲取時需要通過 key 的 hashCode() 進行 hash(),然後計算下標 ( n-1 & hash),從而獲得要找的同的位置。

當發生衝突(碰撞)時,利用 key.equals() 方法去鏈表或樹中去查找對應的節點。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章