1. 程式人生 > 其它 >JAVA集合之HashMap原始碼分析

JAVA集合之HashMap原始碼分析

一、簡介

HashMap繼承於AbstractMap,實現了MapCloneableSerializable介面。
HashMap是一個散列表(陣列和連結串列),它儲存的內容是鍵值對(key-value)對映,能在查詢和修改方便繼承了陣列的線性查詢和連結串列的定址修改。
HashMap是非synchronized,所以HashMap很快。
HashMap的鍵和值都可以為null,而Hashtable則不能(原因就是equlas()方法需要物件,因為HashMap是後出的API經過處理才可以),HashMap中的對映不是有序的。

HashMap的例項有兩個引數影響其效能:“初始容量”和“載入因子”。
容量 是雜湊表中桶的數量,初始容量

只是雜湊表在建立時的容量,HashMap初始容量是16
載入因子 是雜湊表在其容量自動增加之前可以達到多滿的一種尺度。
當雜湊表中的條目數超出了載入因子與當前容量的乘積時,則要對該雜湊表進行擴容(rehash)操作(即重建內部資料結構),從而雜湊表將具有大約 2的桶數。
通常,預設載入因子是0.75,這是在時間和空間成本上尋求一種折衷。載入因子過高雖然減少了空間開銷,但同時也增加了查詢成本(在大多數HashMap類的操作中,包括getput操作,都反映了這一點)。在設定初始容量時應該考慮到對映中所需的條目數及其載入因子,以便最大限度地減少rehash操作次數。如果初始容量大於最大條目數除以載入因子,則不會發生rehash
操作。

先來看看hashmap的結構。

二、歷史版本

本文HashMap原始碼基於JDK8。不同版本HashMap的變化還是比較大的,在1.8之前,HashMap沒有引入紅黑樹,也就是說HashMap的桶(桶即hashmap陣列的一個索引位置)單純的採取連結串列儲存。這種結構雖然簡單,但是當Hash衝突達到一定程度,連結串列長度過長,會導致時間複雜度無限向O(n)靠近。比如向HashMap中插入如下元素,你會神奇的發現,在HashMap的下表為1的桶中形成了一個連結串列。

map.put(1,1);
map.put(17,17);
map.put(33,33);
map.put(49,49);
map.put(65,65);
map.put(81,81);
map.put(97,97);
...
16^n + 1

為了解決這種簡單的底層儲存結構帶來的效能問題,引入了紅黑樹。在一定程度上緩解了連結串列儲存帶來的效能問題。引入紅黑樹之後當桶中連結串列長度超過8將會樹化即轉為紅黑樹(put觸發)。當紅黑樹元素少於6會轉為連結串列(remove觸發)。

在這裡還有一個很重要的知識點,樹化和連結串列化的閾值不一樣?想一個極端情況,假設閾值都是8,一個桶中連結串列長度為8時,此時繼續向該桶中put會進行樹化,然後remove又會連結串列化。如果反覆putremove。每次都會進行極其耗時的資料結構轉換。如果是兩個閾值,將會形成一個緩衝帶,減少這種極端情況發生的概率。
上面這種極端情況也被稱之為複雜度震盪。類似的複雜度震盪問題ArrayList也存在。

三、基礎知識

3.1 常量和構造方法

// 16預設初始容量(這個容量不是說map能裝多少個元素,而是桶的個數)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
// 最大容量值
static final int MAXIMUM_CAPACITY = 1 << 30; 
// 預設負載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 樹化閾值 一個桶連結串列長度超過 8 進行樹化
static final int TREEIFY_THRESHOLD = 8;
// 連結串列化閾值 一個桶中紅黑樹元素少於 6 從紅黑樹變成連結串列
static final int UNTREEIFY_THRESHOLD = 6;
// 最小樹化容量,當容量未達到64,即使連結串列長度>8,也不會樹化,而是進行擴容。
static final int MIN_TREEIFY_CAPACITY = 64;
// 桶陣列,bucket. 這個也就是hashmap的底層結構。
transient Node<K,V>[] table;
// 數量,即hashmap中的元素數量
transient int size;
// hashmap進行擴容的閾值(這個表示的元素多少,可不是桶被用了多少哦,比如閾值是16,當有16個元素就進行擴容,而不是說當桶被用了16個)
int threshold;
//當前負載因子,預設是DEFAULT_LOAD_FACTOR=0.75
final float loadFactor;
/************************************四個構造方法***************************************/
public HashMap(int initialCapacity, float loadFactor) {//1,初始化容量2,負載因子
    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);//總要保持 初始容量為2的整數次冪
}
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

3.2 桶的兩種資料結構

JDK1.8HashMap採用的是連結串列+紅黑樹

連結串列結構

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

紅黑樹結構

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

3.3 hash演算法實現

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

計算桶下標方法

(n - 1) & hash; //n表示HashMap的容量。相當於取模運算。等同於`hash % n`。

n其實就是HashMap底層陣列的長度。(n-1) & hash這個與運算,等同於hash % nhash()方法,只是keyhashCode的再雜湊,使key更加雜湊。而元素究竟存在哪個桶中。還是 (n - 1) & hash結果決定的。綜合一下如下,在hashmap中計算桶索引的方法如下所示。

public static int index(Object key, Integer length) {
    int h;
    h = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    return (length - 1) & h;
}

假設當前hashmap桶個數即陣列長度為16,現在插入一個元素key

計算過程如上圖所示。得到了桶的索引位置。在上面計算過程中,只有一步是比較難以理解的。也就是為什麼不直接拿key.hashcode() & (n - 1) ,為什麼要用key.hashcode() ^ (key.hashcode() >>> 16為什麼要多一步呢?後面問題總結會詳細介紹。

四、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;
    //put1,懶載入,第一次put的時候初始化table(node陣列)
    if ((tab = table) == null || (n = tab.length) == 0)
        //resize中會進行table的初始化即hashmap陣列初始化。
        n = (tab = resize()).length;
    //put2,(n - 1) & hash:計算下標。// put3,判空,為空即沒hash碰撞。直接放入桶中
    if ((p = tab[i = (n - 1) & hash]) == null)
        //將資料放入桶中
        tab[i] = newNode(hash, key, value, null);
    else {//put4,有hash碰撞
        Node<K,V> e; K k;
        //如果key已經存在,覆蓋舊值
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //put4-3:如果是紅黑樹直接插入
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {//如果桶是連結串列,存在兩種情況,超過閾值轉換成紅黑樹,否則直接在連結串列後面追加
            for (int binCount = 0; ; ++binCount) {
                //put4-1:在連結串列尾部追加
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //put4-2:連結串列長度超過8,樹化(轉化成紅黑樹)
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    //如果key已經存在,覆蓋舊值
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //put5:當key已經存在,執行覆蓋舊值邏輯。
        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)//put6,當size > threshold,進行擴容。
        resize();
    afterNodeInsertion(evict);
    return null;
}

其實上面put的邏輯還算是比較清晰的。(吐槽一下JDK原始碼,可讀性真的不好,可讀性真的不如Spring。尤其是JDK中總是在if或者for中對變數進行賦值。可讀性真的差。但是邏輯是真的經典)
總結一下put的過程大致分為以下8步。

1、懶漢式,第一次`put`才初始化`table`桶陣列。(節省記憶體,時間換空間)
2、計算`hash`及桶下標。
3、未發生`hash`碰撞,直接放入桶中。
4、發生碰撞
    4.1、如果是連結串列,迭代插入到連結串列尾部。
    4.2、如果連結串列長度超過8,樹化即轉換為紅黑樹。(當陣列長度小於64時,進行擴容而不是樹化)
    4.3、如果是紅黑樹,插入到紅黑樹中。
5、如果在以上過程中發現`key`已經存在,覆蓋舊值。
6、如果`size > threshold`。進行擴容。

以上過程中,當連結串列長度超過8進行樹化,只是執行樹化方法treeifyBin(tab, hash);。但是在該方法中還有一步判斷,也就是當桶陣列長度<64。並不會進行樹化,而是進行擴容。你想想,假如容量為16,你就插入了9個元素,巧了,都在同一個桶裡面,如果這時進行樹化,樹化本身就是一個耗時的過程。時間複雜度會增加,效能下降,不如直接進行擴容,空間換時間。

看看這個方法

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)//如果容量 < 64則直接進行擴容;不轉紅黑樹。(你想想,假如容量為16,你就插入了9個元素,巧了,都在同一個桶裡面,如果這時進行樹化,時間複雜度會增加,效能下降,不如直接進行擴容,空間換時間)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            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);
    }
}

put邏輯中還有最重要的一個過程也就是擴容。

五、擴容

5.1、擴容

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)
            newThr = oldThr << 1; //threshold不在重新計算,同樣直接擴容為原來的兩倍
    }
    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;
    @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) {//一共oldCap個桶
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {//如果第j個桶沒元素就不管了
                oldTab[j] = null;
                //只有一個元素,直接移到新的桶中(為什麼不先判斷是不是TreeNode?
                //很簡單,因為TreeNode沒有next指標,在此一定為null,也能證明是一個元素。
                //對於大多數沒有hash衝突的桶,減少了判斷,處處充滿著智慧)
                if (e.next == null)
                    //計算桶下標,e.hash & (newCap - 1)是newCap哦
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // rehash  原始碼很經典
                    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 {//判斷不成立,說明該元素要移位到 (j + oldCap) 位置
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;//j 即oldIndex
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
                    }
                }
            }
        }
    }
    return newTab;
}

從以上原始碼總計一下擴容的過程:

1、建立一個兩倍於原來(`oldTab`)容量的陣列(`newTab`)。
2、遍歷`oldTab`
    2.1,如果當前桶沒有元素直接跳過。
    2.2,如果當前桶只有一個元素,直接移動到`newTab`中的索引位。(e.hash & (newCap - 1))
    2.3,如果當前桶為紅黑樹,在`split()`方法中進行元素的移動。
    2.4,如果當前桶為連結串列,執行連結串列的元素移動邏輯。

在以上過程中,我們著重介紹連結串列的元素移動。也就是上述程式碼中的39-68行。

首先,我們看其中

Node<K,V> loHead = null, loTail = null;//下標保持不變的桶
Node<K,V> hiHead = null, hiTail = null;//下標擴容兩倍後的桶

loHeadloTail分別對應經過rehash後下標保持不變的元素形成的連結串列頭和尾。
hiHeadhiTail分別對應經過rehash後下標變為原來(n + oldIndex)後的連結串列頭和尾。

經過上面變數,我們不難發現,桶中的資料只有兩個去向。(oldIndexn + oldIndex

接下來我們思考一個問題。為什麼經過rehash,一個桶中的元素只有兩個去向?

以下過程很燒腦,但是看懂了保證會收穫很多。更會體會到原始碼之美
大致畫一下圖,如下所示。

HashMap的容量總是2的n次方(n <= 32)。

假設擴容前桶個數為16。

看擴容前後的結果。觀察擴容前後可以發現,唯一影響索引位的是hash的低第5位。
所以分為兩種情況hash低第5位為0或者1。

當低第5位為0:newIndex = oldIndex
當低第5位為1:newIndex = oldIndex + oldCap

以上過程也就說明了為啥rehash後一個桶中的元素只有兩個去向。這個過程我看沒有部落格介紹過。為什麼在這裡詳細介紹這個呢?因為這個很重要,不懂這個就看不懂以上rehash程式碼,也很難體會到JDK原始碼的經典之處。給ConcurrentHashMap rehash時的鎖打一個基礎。

if ((e.hash & oldCap) == 0)

這個判斷成立,則說明該元素在rehash後下標不變,還在原來的索引位置的桶中。為什麼?

我們先看一下 (e.hash & oldCap)

看結果,如果判斷if((e.hash & oldCap) == 0)成立,也就是說hash的低第5位為0。
在上個問題我們推導桶中元素的兩個去向的時候,發現低第5位的兩種情況決定了該元素的去向。再觀察上面問題推導中的hash的第一種情況當*為0;

驚不驚喜,意不意外,神奇的發現,當hash低5位為0時,其新索引為依然為oldIndex。OK,你不得不佩服作者的腦子為何如此聰明。當然了這一切巧妙的設計都是建立在hashmap桶的數量總是2的n次方。
回到原始碼,將新的兩個連結串列分別放到newTaboldIndex位置和newIndex位置。正如我們上面推導的那樣

if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;//j 即oldIndex
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
}

以上resize過程就說完了。

留一個問題,以上resize過程效能還能不能進一步優化呢?有興趣的可以對比ConcurrentHashMap的這個rehash原始碼。你會神奇的發現JDK8的作者為了效能究竟有多拼。

當然resize過程在併發環境下還是存在一定問題的。接下來繼續往下看。

5.2 JDK7併發環境擴容問題——迴圈連結串列

先看原始碼

//將當前所有的雜湊表資料複製到新的雜湊表
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    //遍歷舊的雜湊表
    for (Entry<K,V> e : table) {
        while(null != e) {
            //儲存舊的雜湊表對應的連結串列頭的下一個結點
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            //因為雜湊表的長度變了,需要重新計算索引
            int i = indexFor(e.hash, newCapacity);
            //第一次迴圈的newTable[i]為空,賦值給當前結點的下一個元素,
            e.next = newTable[i];
            //將結點賦值到新的雜湊表
            newTable[i] = e;
            e = next;
        }
    }
}

JDK7 hashmap採用的是頭插法,也就是每put一個元素,總是插入到連結串列的頭部。相對於JDK8尾插法,插入操作時間複雜度更低。看上面transfer方法。假設擴容前陣列長度為2,擴容後即長度為4。過程如下。(以下幾張圖片來自慕課網課程)

第一步:處理節點5,resize後還在原來位置。
第二步:處理節點9,resize後還在原來位置。頭插,node(9).next = node(5);
第三步:處理節點11,resize後在索引位置3處。移動到新桶中。

併發環境下的問題

假設此時有兩個執行緒同時put並同時觸發resize操作。

執行緒1執行到,只改變了舊的連結串列的連結串列頭,使其指向下一個元素9。此時執行緒1因為分配的時間片已經用完了。
緊接著執行緒2完成了整個resize過程。
執行緒1再次獲得時間片,繼續執行。解釋下圖,因為節點本身是在堆區。兩個執行緒棧只是調整連結串列指標的指向問題。
當執行緒2執行結束後,table這個變數將不是我們關注的重點,因為table是兩個執行緒的共享變數,執行緒2已經將table中的變數搬運完了。但是由於執行緒1停止的時間如上,執行緒1的工作記憶體中依然有一個變數next是指向9節點的。明確了這一點繼續往下看。
當執行緒2執行結束。執行緒1繼續執行,newTable[1]位置是指向節點5的。如下圖。

如上圖執行緒1的第一次while迴圈結束後,注意 e = next 這行程式碼。經過第一次迴圈後,e指向9。如下圖所示。

按理來說此時如果執行緒1也結束了也沒啥事了,但是經過執行緒2的resize,9節點時指向5節點的,如上圖。所以執行緒1按照程式碼邏輯來說,依然沒有處理完。然後再將5節點插入到newTable中,5節點繼續指向9節點,這層迴圈因為5.next==null,所以迴圈結束(自己看程式碼邏輯哦,e是在while之外的,所以這裡不會死迴圈)。如下圖所示,迴圈連結串列形成。

然後在你下一次進行get的時候,會進入死迴圈。
最後想一下JDK7會出現死迴圈的根源在哪裡?很重要哦這個問題,根源就在於JDK7用的是頭插法,而resize又是從頭開始rehash,也就是在老的table中本來是頭的,到新table中便成為了尾,改變了節點的指向。`

5.3 JDK8的資料丟失問題

上面介紹了JDK7中迴圈連結串列的形成,然後想想JDK8中的resize程式碼,JDK8中的策略是將oldTab中的連結串列拆分成兩個連結串列然後再將兩個連結串列分別放到newTab中即新的陣列中。在JDK8會出現丟失資料的現象(很好理解,在這裡就不畫圖了,感興趣的自己畫一下),但是不會出現迴圈連結串列。丟資料總比形成死迴圈好吧。另外一點JDK8的這種策略也間接的保證了節點間的相對順序。好吧,還是說說JDK8的丟資料問題吧。

do {
    next = e.next;
    if ((e.hash & oldCap) == 0) {//判斷成立,說明該元素不用移動
        if (loTail == null)//尾空,頭插
            loHead = e;
        else//尾不空,尾插
            loTail.next = e;
        loTail = e;
    }
    else {//判斷不成立,說明該元素要移位到 (j + oldCap) 位置
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;//j 即oldIndex
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
}

假設兩個執行緒,根據程式碼邏輯,執行緒1執行了4次迴圈讓出時間片,如下圖所示。

此時連結串列table索引1位置的桶如下所示

如果此時執行緒2也進行resize。此時執行緒2看到的oldTab是如上圖所示的。很明顯,接下來執行緒1執行完成,並順利將兩個連結串列放到了newTab中。

此時執行緒2又獲取時間片並繼續執行以下操作相當於之前執行緒1的resize結果被執行緒2覆蓋了。此時就發生了資料的丟失。

終於介紹完了擴容過程,不容易啊。

六、get()方法原始碼

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;//get1,計算hash
}
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) {// get2,(n - 1) & hash 計算下標
        if (first.hash == hash && // always check first node //get3-1,首先檢查第一個元素(頭元素),如果是目標元素,直接返回
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)//get3-2,紅黑樹
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {//get3-3,連結串列
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

看完了put的原始碼,會發現get過程是何其簡單,大致過程如下

1、計算`hash`
2、計算下標
3、獲取桶的頭節點,如果頭結點`key`等於目標`key`直接返回。
    3.1,如果是連結串列,執行連結串列迭代邏輯,找到目標節點返回。
    3.2,如果是紅黑樹,執行紅黑樹迭代邏輯,找到目標節點返回。

關於remove方法,不介紹了,無非就是就是get過程+紅黑樹到連結串列的轉化過程。不介紹了。

七、問題總結

7.1 為什麼hashmap的容量必須是2的n次方

回顧一下計算下標的方法。即計算key在陣列中的索引位。

hash&(n - 1)

其中n就是hashmap的容量也就是陣列的長度。

假設n是奇數。則n-1就是偶數。偶數二進位制中最後一位一定是0。所以如上圖所示, hash&(n - 1) 最終結果二進位制中最後一位一定是0,也就意味著結果一定是偶數。這會導致陣列中只有偶數位被用了,而奇數位就白白浪費了。無形中浪費了記憶體,同樣也增加了hash碰撞的概率。
其中n是2的n次方保證了(兩個n不一樣哦,別較真)hash更加雜湊,節省了記憶體。

難道不能是偶數嗎?為啥偏偏是2的n次方?

2的n次方能保證(n - 1)低位都是1,能使hash低位的特徵得以更好的保留,也就是說當hash低位相同時兩個元素才能產生hash碰撞。換句話說就是使hash更雜湊。
呃。。。個人覺得2在程式中是個特殊的數字,通過上文resize中的關於二進位制的一堆分析也是建立在容量是2的n次方的基礎上的。雖然這個解釋有點牽強。如果大家有更好的解釋可以在下方留言。

兩層含義:

1,從奇偶數來解釋。
2,從hash低位的1能使得hash本身的特性更容易得到保護方面來說。(很類似原始碼中hash方法中 <<< 16的做法)

7.2 解決hash衝突的方法

hashmap中解決hash衝突採用的是鏈地址法,其實就是有衝突了,在陣列中將衝突的元素放到連結串列中。

一般有以下四種解決方案:

1 鏈地址法
2 開放地址法
3 再雜湊法
4 建立公共溢位區

7.3 HashMap、HashTable、ConcurrentHashMap區別

HashMap是不具備執行緒安全性的。
HashTable是通過Synchronized關鍵字修飾每一個方法達到執行緒安全的。效能很低,不建議使用。
ConcurrentHashMap很經典,Java程式設計師必精通。下篇文章就介紹ConcurrentHashMap。該類位於J.U.C併發包中,為併發而生。

7.4 如何保證HashMap的同步

Map map = Collections.synchronizedMap(new HashMap());其實其就是給HashMap的每一個方法加Synchronized關鍵字。
效能遠不如ConcurrentHashMap。不建議使用。

7.5 為什麼引入紅黑樹

這個問題很簡單,因為紅黑樹的時間複雜度表現更好為O(logN),而連結串列為O(N)。
為什麼紅黑樹這麼好還要用連結串列?
因為大多數情況下hash碰撞導致的單個桶中的元素不會太多,太多也擴容了。只是極端情況下,當連結串列太長會大大降低HashMap的效能。所以為了應付這種極端情況才引入的紅黑樹。當桶中元素很少比如小於8,維護一個紅黑樹是比較耗時的,因為紅黑樹需要左旋右旋等,也很耗時。在元素很少的情況下的表現不如連結串列。
一般的HashMap的時間複雜度用平均時間複雜度來分析。除了極端情況連結串列對HashMap整體時間複雜度的表現影響比較小。

7.6 為什麼樹轉連結串列和連結串列轉樹閾值不同

其實上文中已經介紹了,因為複雜度震盪。詳情請參考上文。

7.7 Capacity的計算

變相問一下這個問題就是當初始化hashMapinitialCapacity引數傳的是18,HashMap的容量是什麼?是32。

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

該方法大意:如果cap不是2的n次方則取大於cap的最小的2的n次方的值。當然這個值不能超過MAXIMUM_CAPACITY。具體請參考HashMap之tableSizeFor方法圖解

7.8 為什麼預設的負載因子loadFactor = 0.75

* Because TreeNodes are about twice the size of regular nodes, we
 * use them only when bins contain enough nodes to warrant use
 * (see TREEIFY_THRESHOLD). And when they become too small (due to
 * removal or resizing) they are converted back to plain bins.  In
 * usages with well-distributed user hashCodes, tree bins are
 * rarely used.  Ideally, under random hashCodes, the frequency of
 * nodes in bins follows a Poisson distribution
 * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
 * parameter of about 0.5 on average for the default resizing
 * threshold of 0.75, although with a large variance because of
 * resizing granularity. Ignoring variance, the expected
 * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
 * factorial(k)). The first values are:
 *
 * 0:    0.60653066
 * 1:    0.30326533
 * 2:    0.07581633
 * 3:    0.01263606
 * 4:    0.00157952
 * 5:    0.00015795
 * 6:    0.00001316
 * 7:    0.00000094
 * 8:    0.00000006
 * more: less than 1 in ten million

上文大意是說 : 因為TreeNodes是普通節點佔用空間的2倍,僅當有足夠的節點時才會適當地將普通節點轉為TreeNodes。當桶的元素變得很少時又轉回普通的Node。當hashCode離散型很好的時候,樹型bin很少概率被用到。因為資料均勻分佈在每個桶中,幾乎不會有bin中連結串列長度達到閾值。但是在隨機的hashCode的情況下,離散型可能會變差,然而jdk又不能阻止使用者實現這種不好的hash演算法,因此就可能導致不均勻的資料分佈。理想的情況下,隨機hashCode演算法下所有bin中的節點分佈頻率會遵循泊松分佈,從資料中可以看到,當一個bin中的連結串列長度達到8個元素時概率為0.00000006,幾乎是不可能事件。所以選8是根據概率統計決定的。

hashmap預設的loadFactor是0.75,官網解釋是說泊松分佈算出來的,其實不然,這裡泊松分佈算出來的樹出現的概率,當樹化的閾值是8,載入係數是0.75的時候出現樹化的概率為0.00000006,jdk開發設計hashmap的時候,為了平衡樹和連結串列的效能(樹比連結串列遍歷快,但是樹的結點是連結串列結點大小的兩倍,所以當樹出現的概率比較小的時候的價效比就高了,所以取載入係數的時候平衡了下效能取0.75)。平衡效能其實就是"空間利用率"和"時間複雜度"之間的折衷。

  • 原註釋的內容和目的都是為了解釋在java8 HashMap中引入Tree Bin(也就是放入資料的每個陣列bin從連結串列node轉換為red-black tree node)的原因
  • 原註釋:Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use(see TREEIFY_THRESHOLD).
  • TreeNode雖然改善了連結串列增刪改查的效能,但是其節點大小是連結串列節點的兩倍
  • 雖然引入TreeNode但是不會輕易轉變為TreeNode(如果存在大量轉換那麼資源代價比較大),根據泊松分佈來看轉變是小概率事件,價效比是值得的
  • 泊松分佈是二項分佈的極限形式,兩個重點:事件獨立、有且只有兩個相互對立的結果
  • 泊松分佈是指一段時間或空間中發生成功事件的數量的概率
  • 對HashMap table[]中任意一個bin來說,存入一個數據,要麼放入要麼不放入,這個動作滿足二項分佈的兩個重點概念
  • 對於HashMap.table[].length的空間來說,放入0.75*length個數據,某一個bin中放入節點數量的概率情況如上圖註釋中給出的資料(表示陣列某一個下標存放資料數量為0~8時的概率情況)
    • 舉個例子說明,HashMap預設的table[].length=16,在長度為16的HashMap中放入12(0.75*length)個數據,某一個bin中存放了8個節點的概率是0.00000006
    • 擴容一次,16*2=32,在長度為32的HashMap中放入24個數據,某一個bin中存放了8個節點的概率是0.00000006
    • 再擴容一次,32*2=64,在長度為64的HashMap中放入48個數據,某一個bin中存放了8個節點的概率是0.00000006

所以,當某一個bin的節點大於等於8個的時候,就可以從連結串列node轉換為treenode,其價效比是值得的。
具體可以參考HashMaploadFactor為什麼是0.75

7.9 HashMap中為什麼用位運算而不是取模運算

主要是位運算在底層計算速度更快。
簡單證明一下

long s1 = System.nanoTime();
System.out.println(2147483640 % 16);//8
long e1 = System.nanoTime();
long s2 = System.nanoTime();
System.out.println(2147483640 & 15);//8
long e2 = System.nanoTime();
System.out.println("取模時間:" + (e1 - s1));//取模時間:134200
System.out.println("與運算時間:" + (e2 - s2));//與運算時間:15800

題外話:還有一個刷leetcode題,二分法計算中心點。總結的經驗,用除法會導致部分演算法題超時。

long s1 = System.nanoTime();
System.out.println(1 + (2147483640 - 1) / 2);//1073741820
long e1 = System.nanoTime();
long s2 = System.nanoTime();
System.out.println(1 + (2147483640 - 1) >> 1);//1073741820
long e2 = System.nanoTime();
System.out.println("除法時間:" + (e1 - s1));//除法時間:20100
System.out.println("位運算時間:" + (e2 - s2));//位運算時間:15700

注意:一般二分法用left + (right - left)/2;因為如果用(right+left)/2;right + left容易>Integer.MAX_VALUE;

參考文章