【HashMap】知識點整理以及JDK1.8和JDK1.7的對比

僅供自己復(fù)習(xí)

數(shù)據(jù)結(jié)構(gòu)分析

JDK1.7 JDK1.8
底層數(shù)據(jù)結(jié)構(gòu) 數(shù)組 + 鏈表 數(shù)組+鏈表/紅黑樹
沖突解決方法 拉鏈法 “改進的”拉鏈法。當(dāng)鏈表長度大于TREEIFY_THRESHOLD時,鏈表轉(zhuǎn)為紅黑樹
// jdk1.8
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    
    transient Node<K,V>[] table;
    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        ....
    }
    
    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;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
    }

重點參數(shù)講解

參數(shù) 作用
size HashMap已經(jīng)插入了鍵值對的個數(shù),當(dāng)size>threshold時,執(zhí)行擴容
initialCapacity 初始化容量。該參數(shù)并不是HashMap的成員變量
threshold 擴容的閾值。該值=capacity * load factor
loadFactor 負(fù)載因子
TREEIFY_THRESHOLD 鏈表轉(zhuǎn)樹的閾值,默認(rèn)是8,類型是static final int
UNTREEIFY_THRESHOLD 樹轉(zhuǎn)鏈表的閾值,默認(rèn)是6
modCount HashMap發(fā)生結(jié)構(gòu)性修改的次數(shù)。作用于fast-fail機制:在進行序列化或者迭代等操作時,比較操作前后 modCount 是否改變,若改變,則拋出 ConcurrentModificationException

關(guān)于紅黑樹和鏈表轉(zhuǎn)換一些細節(jié)

1)鏈表轉(zhuǎn)紅黑樹

  • 時機:發(fā)生在鏈表長度大于等于 8 時
  • 原因:此時,紅黑樹的平均查找長度為 O(log n)=3 < 鏈表平均查找長度 O(n/2)=4。
  • 發(fā)生:插入時進行判斷與轉(zhuǎn)換

2)紅黑樹轉(zhuǎn)鏈表

  • 時機:紅黑樹節(jié)點數(shù)小于等于 6 時
  • 原因:鏈表平均查找長度更小。
  • 發(fā)生:刪除節(jié)點時進行判斷和轉(zhuǎn)換

如果留心點,會發(fā)現(xiàn)兩個臨界值之間隔了一個 7。原因是由于轉(zhuǎn)換操作比較耗時,中間隔個 7,可以防止轉(zhuǎn)換操作過于頻繁。

假設(shè)一下,如果設(shè)計成鏈表個數(shù)超過8則鏈表轉(zhuǎn)換成樹結(jié)構(gòu),鏈表個數(shù)小于8則樹結(jié)構(gòu)轉(zhuǎn)換成鏈表,如果一個HashMap不停的插入、刪除元素,鏈表個數(shù)在8左右徘徊,就會頻繁的發(fā)生樹轉(zhuǎn)鏈表、鏈表轉(zhuǎn)樹,效率會很低。

重點函數(shù)分析

哈希值計算hash()

在這方面,JDK1.8 也作為優(yōu)化。

JDK1.7 的 hash() = hashCode() + 擾動操作(包括4次位運算 + 5次異或運算)
JDK1.8 的 hash() = hashCode() + 擾動操作(包括1次位運算 + 1次異或運算)

PS:擾動操作的作用是「加大哈希碼低位的隨機性,使得分布更均勻,從而提高對應(yīng)數(shù)組存儲下標(biāo)位置的隨機性 & 均勻性,最終減少Hash沖突」

// JDK 1.7實現(xiàn):將 key 轉(zhuǎn)換成 哈希碼(hash值)操作  = 使用hashCode() + 4次位運算 + 5次異或運算(9次擾動)
static final int hash(int h) {
        h ^= k.hashCode(); 
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
}

// JDK 1.8實現(xiàn):將 鍵key 轉(zhuǎn)換成 哈希碼(hash值)操作 = 使用hashCode() + 1次位運算 + 1次異或運算(2次擾動)
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

插入操作putVal()

插入流程圖[4]

源碼節(jié)選:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 1)第一次執(zhí)行put,才為hashmap分配空間
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        // 2)插入的key在數(shù)組的下標(biāo)值 i=(n-1)&hash
        //       如果tab[i]==null,直接插入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        
        // 3)如果tab[i]!=null,則向鏈表或紅黑樹中插入
        else {
            Node<K,V> e; K k;
            // 4)判斷對應(yīng)數(shù)組上的元素的key與插入的key相等,
            //       相等則代替。
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            
            // 5)不相等,且數(shù)組上的元素是樹節(jié)點,則插入到樹中
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            
            // 6)不相等,且數(shù)組上的元素是鏈表節(jié)點,則插入到鏈表中
            else {
                // 7)遍歷鏈表,找相等的節(jié)點。找不到就指向最后一個節(jié)點
                for (int binCount = 0; ; ++binCount) {
                    // i.說明走到了鏈表尾部,則代表沒找到,就直接插入
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 插入完成后,計算鏈表長度,如果大于閾值,轉(zhuǎn)換為樹
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    // ii. 找到相等的節(jié)點,就推出循環(huán)
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 針對上面遍歷中,找到了相等的節(jié)點,現(xiàn)在執(zhí)行替換
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);  // 替換完成后會調(diào)用該方法。默認(rèn)實現(xiàn)是空
                return oldValue;
            }
        }
        ++modCount;    // 記錄map的結(jié)構(gòu)性修改
        
        // 8)當(dāng)map的鍵值對大于閾值,則擴容。
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);    // 插入成功后會調(diào)用該方法。默認(rèn)實現(xiàn)為空
        return null;
    }

擴容操作resize()

final Node<K,V>[] resize() {
        // 1)保存當(dāng)前數(shù)組以及相關(guān)參數(shù)。下面稱為舊數(shù)組
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        
        // 2)容量擴展的兩種情況:
        //              i. 舊容量已經(jīng)達到最大值,就不擴容了
        //              ii. 如果沒達到,就擴大 1 倍。
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        
        // 3)此處代表map尚未初始化(因為容量小于0),故此這兩個else都是為新map初始化
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        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;
        
        // 4)創(chuàng)新新的數(shù)組,并指向它
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;

        if (oldTab != null) {
            // 5)遍歷舊數(shù)組,將元素搬遷到新數(shù)組中
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;    // 釋放舊數(shù)組的空間
                    // 6)根據(jù)當(dāng)前遍歷到的節(jié)點類型,選擇搬遷到新數(shù)組的方法
                    //         i:   當(dāng)前節(jié)點后面沒有鏈表/樹,直接插入到新數(shù)組
                    //         ii:  當(dāng)前節(jié)點是一棵樹,則遍歷每個節(jié)點計算他們的新位置。而鏈表中的節(jié)點新位置只有兩種可能
                    //         iii: 當(dāng)前節(jié)點是鏈表,則遍歷每個節(jié)點計算他們的新位置。而鏈表中的節(jié)點新位置只有兩種可能
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;  
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 將節(jié)點一分為二。因為新位置只有兩種可能。
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;  // 存放節(jié)點在新數(shù)組的下標(biāo)=原下標(biāo)
                        Node<K,V> hiHead = null, hiTail = null;  // 存放節(jié)點在新數(shù)組的下標(biāo)=原下標(biāo)+舊容量
                        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;
    }

還有一個關(guān)鍵點。

  • 不管是同一棵樹或者同一個鏈表,每個節(jié)點的 hash() 一定相等的,只不過是每個節(jié)點之間 equals() 不相等。
  • 擴容后元素的新數(shù)組下標(biāo)只有兩種情況:
    1. 擴容后,若hash() 新增參與運算的位=0,新數(shù)組下標(biāo)=原下標(biāo)
    2. 擴容后,若hash() 新增參與運算的位=1,新數(shù)組下標(biāo)=原下標(biāo)+擴容前的舊容量
    • 相關(guān)證明見[2]

線程不安全原因

本質(zhì):其中一個線程擴容進行了一個部分被掛起了,另一個線程獲取資源并完成擴容后,鏈表的節(jié)點的 next 指針已經(jīng)發(fā)生了改變,導(dǎo)致切換回掛起的線程繼續(xù)擴容時,會出現(xiàn)數(shù)據(jù)丟失或循環(huán)鏈表的情況。

JDK1.7 和 JDK1.8 的 HashMap 都是線程不安全的。但只查閱到 JDK1.7 resize() 引發(fā)的死循環(huán)的例子,故此下例是基于 JDK1.7 的。

舉個例子說明一下,例子來源于[3]。

1)假設(shè) HashMap 現(xiàn)有數(shù)據(jù)如下圖所示,數(shù)組大小為2,故此插入到需要擴容。


2)在單線程情況,擴容后的結(jié)果如下:


3)在多線程,擴容就可能產(chǎn)生循環(huán)鏈表或者丟失數(shù)據(jù)。問題的根源在于 tranfer()。

void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;  // 最最最關(guān)鍵的代碼
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
}
擴容,死循環(huán)的產(chǎn)生過程
擴容時,丟失數(shù)據(jù)的產(chǎn)生過程

PS:

  • 線程 1 和線程 2 操作的是同一個 HashMap,因此線程 2 先完成,修改了結(jié)構(gòu)。線程 1 最初記錄已經(jīng)不準(zhǔn)確了,再根據(jù)之前的記錄修改 HashMap,則發(fā)生錯誤。
  • 當(dāng)發(fā)生死循環(huán)時,會造成CPU利用率達到100%
  • JDK1.8 改為尾插入,能夠避免1.7的上述類型的死循環(huán)。但依舊會出現(xiàn)死循環(huán)

JDK1.8和JDK1.7對比

相比于 JDK1.7,JDK1.8 的 HashMap 做了如下的調(diào)整優(yōu)化:

主要區(qū)別 JDK1.7 JDK1.8
數(shù)據(jù)結(jié)構(gòu) 數(shù)組+鏈表 數(shù)組+鏈表/紅黑樹
插入操作 當(dāng)發(fā)生沖突時,采用“頭插法”插入到鏈表中 當(dāng)發(fā)生沖突時,插入到樹中或者采用“尾插法”插入到鏈表中
初始化容量 調(diào)用inflateTable() 集成到resize()
計算哈希 擾動操作更多 擾動操作更少

PS:

  • 頭插法即插入在鏈表的頭部;尾插法即插入在鏈表的尾部。
  • 頭插法會導(dǎo)致鏈表倒序,即原本是 1->2->3,擴容后變成 3->2->1。
  • 還有一些小的改動,比如說 JDK1.7 和 JDK1.8 對 key 的哈希值計算有些許的優(yōu)化調(diào)整,詳見[2]

常見問題講解

Q:HashMap容量為什么一定是2的冪次方?

前提:HashMap 容量指的是 table 的大小。
原因:提高 HashMap 的計算 key 的哈希值的速度,從而提高 HashMap 的存取速度。因為當(dāng) table 的大小是 2 的冪次方時,計算哈希用的取余操作,可換算為位運算,從而提高速度。

// 為什么hashMap大小一定是2的冪次方。
// 原因:提高存取速度(更細:提高搜索數(shù)組下標(biāo)的速度)

// 原本:一般計算數(shù)組下標(biāo):通過取余
int h = hash(key.hashCode());
int index = h % table.size();

// 改進:當(dāng)table.size()為2的冪次方時計算數(shù)組下標(biāo):通過位運算
int h = hash(key.hashCode());
int index = h & (table.size()-1);

Q:為什么String和Integer等包裝類適合作為 key?

原因:String和Integer等包裝類具有不變性,從而保證 key 具有不變性。

Q:如何自定義類要作為key,需要重寫哪些方法?

需要重寫的方法:hashcode()equals()。這兩個函數(shù)在 get()put() 都用到,用于判斷傳入的 key 與 HashMap 保存的 key 是否有相等。

比如,在 put() 的用法:

  • 數(shù)組的索引:取決于 key 的 hashcode。
  • 當(dāng)兩個元素的 hashcode 相同時,然后再判斷 equals() 返回值
    • 當(dāng) equals() == true,覆蓋原有的值
    • 當(dāng) equals() == false,插入到鏈表/紅黑樹。這就是 hash 沖突的解決方法。

Q:HashMap 實現(xiàn)同步的方法有哪些?

1) 使用 Collections.synchronizedMap(HashMap)
2) 使用 HashTable
3) 使用 ConcurrentHashMap

Q:Collections.synchronizedMap(HashMap)如何實現(xiàn)同步?

答案:Collections.synchronizedMap(HashMap) 得到的對象中有一個鎖變量 mutex,然后所有操作進行加鎖 synchronized(mutex)

public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
        return new SynchronizedMap<>(m);
    }

    /**
     * @serial include
     */
    private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable {

        private final Map<K,V> m;     // Backing Map
        final Object      mutex;        // Object on which to synchronize

        public int size() {synchronized (mutex) {return m.size();} }
        public boolean isEmpty() { synchronized (mutex) {return m.isEmpty();} }
        public boolean containsKey(Object key) { synchronized (mutex) {return m.containsKey(key);}  }
        public boolean containsValue(Object value) { synchronized (mutex) {return m.containsValue(value);} }
        public V get(Object key) { synchronized (mutex) {return m.get(key);} }
        public V put(K key, V value) { synchronized (mutex) {return m.put(key, value);} }
        public V remove(Object key) { synchronized (mutex) {return m.remove(key);}  }
        public void putAll(Map<? extends K, ? extends V> map) { synchronized (mutex) {m.putAll(map);} }
        ...
}

參考文獻

[1] Java:手把手帶你源碼分析 HashMap 1.7_Java,集合,HashMap_專注分享 Android開發(fā) 干貨-CSDN博客
[2] Java源碼分析:關(guān)于 HashMap 1.8 的重大更新_專注分享 Android開發(fā) 干貨-CSDN博客
[3] [HashMap]HashMap死循環(huán)與元素丟失(一) - 技術(shù)棧 - OSCHINA
[4] Java 8系列之重新認(rèn)識HashMap - 簡書

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容