Jdk1.8下的HashMap原始碼分析

夕陽下飛奔的豬發表於2020-08-11

目錄結構
一、面試常見問題
二、基本常量屬性
三、構造方法
四、節點結構
       4.1 Node類
       4.2.TreeNode
五、put方法
       5.1 key的hash方法
       5.2 resize() 擴容方法
六、get方法

一、面試常見問題

Q1: HashMap底層資料結構是什麼?

jdk1.7是陣列+連結串列; jdk1.8 採用陣列+ 連結串列+紅黑樹 

Q2:為什麼要引入紅黑樹,有什麼優勢,解決什麼問題?

紅黑樹的引入是為了提高查詢效率,雜湊衝突可以減少(元素key均勻雜湊程度和通過擴容),不可避免,
如果衝突元素較多,會導致連結串列過長,而連結串列查詢效率是O(n), 當連結串列長度超過8後,且陣列長度(length)超過64才轉化為紅黑樹, 這點易忽略(後原始碼證明),
不是連結串列長度一超高8個就將其樹化為紅黑樹,此時擴容解決衝突效果更好。(注:紅黑樹的特性需要了解掌握)

Q3: 在一條連結串列上新增節點,什麼方式插入?

1.8 是尾插法,1.7是採用頭插法,為什麼1.7需要頭插法?頭插法效率高?

Q4:放入HashMap中元素需要什麼特性?

需要重寫hashCode和 equals()方法 ,不重寫會有什麼問題? 

Q5: HashMap是執行緒安全的嗎?你列舉其他執行緒安全的,特性?

不是, 因為它的方法沒有同步鎖,HashTable 是執行緒安全的,每個方法有加synhronized關鍵字,執行緒安全,但也會導致效率變低;
一般多執行緒環境使用 ConcurrentHashMap,其原理是採用了分段鎖機制,每一段(Segment)中是一個HashMap,每個Segment中操作是加鎖的,
即保證執行緒操作某一個Segment是排他的,但不同執行緒在不同Segment是可以同時操作的,即保證了執行緒安全,又提高了併發效率。
(此處不詳細展開ConcurrentHashMap,面試問題是環環相套的,好的面試官會步步引導,目的是為了全面考察面試者的技術水平)。

Q6: 1.7中 HashMap存在什麼問題?

在多執行緒環境下,1.7中Map可能會導致cpu使用率過高,是因為存在環形連結串列了,HashMap中 連結串列是單連結串列結構,怎麼會有環?
是連結串列中元素指向下一個元素的指標next,指向了前面的元素,導致了環,所以在遍歷連結串列時,
程式一直死迴圈無法結束。在多個執行緒放置元素時,resize()方法中導致(後原始碼證明)。

Q7: 1.8是先插入新值再判斷是否擴容,還是先擴容在插入新值?

1.8是先插入元素,在判斷容量是否超過閾值,擴容,1.7是先擴容再插入新值

Q8: HashMap是延遲初始化?

是的,建立的Map物件,如果有指定容量大小,會記錄下來;沒有指定會使用預設16,在第一次put元素時才初始化陣列,1.7,1.8都是如此,可以節省記憶體空間。

Q9: HashMap允許放置空鍵(null),空值(null)嗎?

允許放置,null 鍵是放置在陣列第一個位置的,因此在判斷某個key是否存在時,不能通過該get() 方法獲取value為null判斷,
這時鍵值對可能是 null:null,此時是存在Node物件的,可以通過containsKey(key)判斷 ,key為null,Node物件不為null,如下圖1所示       

對比HashTable ,HashTable 不允許 null鍵,null值.

Q10 :簡要講述一下HashMap放置元素過程,即put()方法。

put方法流程如下圖2所示

二、基本常量屬性
//預設初始容量 16 
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
 *最大容量,如果在建構函式中指定大於該值,會使用該值作為容量大小,2^30
 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
 * 預設載入因子75%,好比地鐵滿載率,受疫情影響地鐵滿載率不超過30%
 * 1,載入因子設定較大,隨著陣列中元素裝的越多,發生的衝突的概率越大,即對應的連結串列
 * 越長,影響查詢效率。
 * 2,載入因子設定較小,元素很容易達到設定的閾值,發生擴容操作,陣列空間還有很大部分          
 * 沒有利用上,造成空間浪費。
 * 因此在時間與空間上的權衡考慮
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 樹化閾值:在連結串列元素超過8個了,將連結串列轉化為紅黑數結果,提高查詢效率
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 取消樹化閾值:在紅黑樹中節點數量小於6個,將其轉化為連結串列結構
 */
static final int UNTREEIFY_THRESHOLD = 6;
/**
 * 最小樹化容量,容易忽視
 * 連結串列樹化紅黑樹條件:
 * 1,連結串列中元素數量超過(>)8個;
 * 2, 滿足map中元素個數(size)大於等於64否則會先擴容,擴容對解決hash衝突更有效。 
 */
static final int MIN_TREEIFY_CAPACITY = 64;
三、構造方法
//傳有初始容量和載入因子
1.HashMap(int initialCapacity, float loadFactor)
//有初始容量,載入因子預設
2.HashMap(int initialCapacity)
//初始容量, 載入因子預設
3.HashMap()
//傳遞的一個Map子集
4.HashMap(Map<? extends K, ? extends V> m)
四、節點結構
  • 1.陣列和連結串列節點物件為HashMap內部類 Node.

  • 2.紅黑樹節點 TreeNode,繼承LinkedHashMap.Entry , 而 Entry繼承Node,因此 TreeNode 實際是 Node孫子.

  • 3.Node類,重寫了hashCode和 equals方法,記錄了當前key, value, key的hash值,以及指向後一個元素指標.

4.1 Node類
//實現Map集合的Entry類
static class Node<K,V> implements Map.Entry<K,V> {
    //記錄當前節點key的hash
    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; }
    //重寫了hashcode 
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
     //重寫了equals
    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;
    }
}
4.2.TreeNode

什麼是紅黑樹?特點?

性質1. 節點是紅色或黑色.

性質2. 根節點是黑色.

性質3.所有葉子都是黑色.(葉子是NUIL節點)

性質4. 每個紅色節點的兩個子節點都是黑色.(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)

性質5. 從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點.

// 繼承LinkedHashMap.Entry  繼承>> HashMap.Node
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;
五、put方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
5.1 key的hash方法
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hash(key)方法:

1.hashMap支援 key 為null,放在陣列索引為0的位置

2.為了保證key的hash值分佈均分、雜湊,減少衝突

 (h = key.hashCode()) ^ (h >>> 16) 

等於 key的hash值 異或於 其hash值的低 16位

例:"水果"的 hashCode()

h = 11011000000111101000

h>>>16=00000000000000001101

兩者異或,不同為1,相同為0

1101 10000001 11101000 
^ 
0000 00000000 00001101 
---------------------- 
1101 10000001 11100101

put核心方法

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
       //1,陣列為空,初始化操作,此處resize() 待解析1
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
     /**2,通過key的計算出的hash值與陣列長度-1與運算,算出在陣列下標位置
       * 若該位置為空則建立新節點封裝元素值,放在該位置
       */
        tab[i] = newNode(hash, key, value, null);
    else {
      // 若陣列下標位置已經有值
        Node<K,V> e; K k;
        //3,首先判斷陣列位置兩個key是否相同,e用來指向存在相同key的節點。
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
  //4,key不相同的情況下,判斷陣列該下標位置連線的是連結串列還是樹節點
 //若是樹結構,就將該值放入樹中(放入樹中操作,也會遍歷樹判斷是否存在相同的key的節點,若無相同會以新節點插入樹中)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        //陣列該下標位置只有1個節點或者連結串列:這裡統一為連結串列形式
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                //5,沒有找到相同key的元素,在連結串列後插入新節點
                    p.next = newNode(hash, key, value, null);
                /**此處為什麼要>=7,因為bincount從0開始遞增,當bincount=7時,
                *此時for迴圈執行了7次,加上新增加的節點,以及陣列下標位置節點
                *共7+1+1= 9了,即該陣列下標位置連結串列長度大於8了,需要轉化為紅黑樹
                */
                 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //遍歷連結串列比較判斷是否存在兩個key相同元素
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
  //有上面 e= p.next,這裡 p有重新被賦值為e,即繼續遍歷連結串列下一個元素    
                p = e;
            }
        }
        // 存在相同的key的元素,可能在陣列、連結串列、樹上 
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
       //6,覆蓋舊值,返回舊值,也可設定僅僅當舊value存在,不為null情況才覆蓋原值
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //修改次數+1
    ++modCount;
    //7,判斷整個map中元素個數是否大於閾值,大於則擴容,由此可見是先插入元素再判斷容量大小是否擴容,區別1.7先擴容再插入新值
    if (++size > threshold)
        resize();
    //該方法為空,不用理會
    afterNodeInsertion(evict);
    return null;
}
5.2 resize() 擴容方法

該方法有個比較有意思的是將舊陣列的元素移到擴大為原來容量2倍的新陣列中時,原來陣列中連結串列需要進行拆鏈,非常巧妙,下見。

final Node<K,V>[] resize() {

    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //1,原來陣列容量>0情況
    if (oldCap > 0) {
        //如果陣列容量已經為最大值了 2^30,那就僅僅是將擴容閾值修改
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
   //1.1 如果原來陣列容量>=16,且其2倍小於最大容量(oldCap<<1 等價於 oldCap*2^1)
    // 閾值也擴容為原來2倍  
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
     //1.2 什麼情況陣列容量=0,閾值>0呢?
     //建立HashMap,指定了陣列初始容量cap,會將其轉換為大於等於cap的最小的2的冪次方的數賦值給threshold,這裡即將陣列容量賦值
         newCap = oldThr;
    else {         // zero initial threshold signifies using defaults
     //1.3原來陣列容量和閾值都為0,即常用的無參構造方式,使用預設容量大小
     //閾值根據容量和負載因子算出   
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //2,newThr = 0情況出現在1.1中,沒滿足條件擴大為原來2倍
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
   //小於最大容量,則用容量和載入因子乘積,否則為 Integer最大值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    //3,建立新陣列物件
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //4,原陣列物件不為空,需要將原陣列元素移到新陣列中
    if (oldTab != null) {
       //遍歷舊陣列
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //陣列下標位置有元素
            if ((e = oldTab[j]) != null) {
        //直接把該下標位置連結串列(或紅黑樹)取出來,1個元素也當做連結串列看待,原陣列該位置置空,只要我們拿著連結串列頭節點/樹根節點(e)就行
                oldTab[j] = null;
                if (e.next == null)
              //該位置只有1個元素,直接與新陣列長度hash,確定位置放入新陣列
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                 //該位置是一顆紅黑樹,遷移到新陣列
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
     //5,重點解析:這裡將原陣列連結串列拆分為2條連結串列放入新陣列,那它是怎麼拆的呢?
    // loHead,loTail(lo 理解為low縮寫)分別記錄放在新陣列下標小的那一條連結串列的頭節點和尾結點,
    // 同理hiHead,hiTail(hi理解為 hight縮寫)放在新陣列下標大的位置
                    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 是什麼目的呢? 
       //  e.hash & (oldCap-1)求得是陣列下標,e.hash & oldCap 
       // 是為了獲取比oldCap-1更高的那位一是0還是1,是0的就留在原位,是1的話需要增加oldCap。這裡不易理解,下面詳解
                        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,擴容前位置
23&15= 7 
0001 0111
 &
0000 1111
---------
0000 0111
#2,擴容後位置 
23&31= 23
0001 0111
 &
0001 1111
---------
0001 0111
/**因為每次擴容都是old陣列長度的2倍,那麼在要計算在擴容後新陣列位置,
那麼只需要關心key的hash值左邊新增計算的那位是0還是1,如上未擴容前23與15與運算,只需關心低4位值,高位無論其是否有1,與運算後都會為0; 而擴容後,15變為31,那麼key的hash值影響計算結果位由4位變為5位,
因此 e.hash & oldCap的結果就是獲取新增的位置,000X 0000,即X的值,0或者1;
0運算結果不變,放在原位,1與運算結果會增加一個原陣列長度。
*/

如下圖3所示:

7 & 7=7
0000 0111
0000 0111
----------
23 & 7=7
0001 0111
0000 0111
----------
31 & 7=7
0001 1111
0000 0111
----------
15 & 7=7
0000 1111
0000 0111
#可見第4位(從右向左)為1的有15,31,擴容後位置:原有下標+原陣列長度

拆鏈位運算實現的巧妙:

1,擴容遷移原有陣列中的元素,不用再重複計算原有元素key的hash值,提高效率.

2,擴容後拆鏈,會將連結串列長度縮短,減少hash衝突,提高查詢效率.

六、get方法

根據鍵值對中的鍵(key)獲取值

public V get(Object key) {
    Node<K,V> e;
    //獲取節點賦值給e,e!=null, 返回該鍵值對的value值
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

下見方法:getNode(int hash, Object key)

final Node<K,V> getNode(int hash, Object key) {
   //傳入的hash值與put方法一樣的方法,相同規則計算key的hash值
    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) {
        //1,獲取陣列下標位置的第一個節點,first
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && 
            key.equals(k))))
            //2,檢查是否為相同的key,是直接返回該節點物件;不是則遍歷下一個節點
            return first;
        if ((e = first.next) != null) {
            //3,下一個節點存在,需判斷是紅黑樹,還是連結串列
            if (first instanceof TreeNode)
              //3.1紅黑樹則遍歷樹獲取是否存在與該key相同的節點
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
               //3.2 連結串列則依次遍歷,查詢相同的key,找到則返回
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    //陣列為空,或者沒有找到返回空
    return null;
}

相關文章