HashMap

原文鏈接:https://mp.weixin.qq.com/s?__biz=MzAwNDcyODk5NQ==&mid=2647598226&idx=1&sn=a31576ea0f6e4d5169b3b7acba15b557&chksm=831cf55eb46b7c48a5956dde2930a97fc079ee1c8c14f142c80f1029fb61ae5628abe226d53b&scene=21#wechat_redirect

原作者:馮鼕鼕

現在的面試當中凡是那些大廠,基本上都會問到一些關於HashMap的問題了,而且這個集合在開發中也經常會使用到。於是花費了大量的時間去研究分析寫了這篇文章。本文是基於jdk1.8來分析的。篇幅較長,但是都是循序漸進的。耐心讀完相信你會有所收穫。

一、帶着問題分析

這篇文章,希望能解決以下問題。

(1)HashMap的底層數據結構是什麼?

(2)HashMap中增刪改查操作的底部實現原理是什麼?

(3)HashMap是如何實現擴容的?

(4)HashMap是如何解決hash衝突的?

(7)HashMap爲什麼是非線程安全的?

下面我們就帶着這些問題,揭開HashMap的面紗。

二、認識HashMap

HashMap最早是在jdk1.2中開始出現的,一直到jdk1.7一直沒有太大的變化。但是到了jdk1.8突然進行了一個很大的改動。其中一個最顯著的改動就是:

之前jdk1.7的存儲結構是數組+鏈表,到了jdk1.8變成了數組+鏈表+紅黑樹。

另外,HashMap是非線程安全的,也就是說在多個線程同時對HashMap中的某個元素進行增刪改操作的時候,是不能保證數據的一致性的。

下面我們就開始一步一步的分析。

三、深入分析HashMap

1、底層數據結構

爲了進行一個對比分析,我們先給出一個jdk1.7的存儲結構圖
在這裏插入圖片描述
從上圖我們可以看到,在jdk1.7中,首先是把元素放在一個個數組裏面,後來存放的數據元素越來越多,於是就出現了鏈表,對於數組中的每一個元素,都可以有一條鏈表來存儲元素。這就是有名的“拉鍊式”存儲方法。

就這樣用了幾年,後來存儲的元素越來越多,鏈表也越來越長,在查找一個元素時候效率不僅沒有提高(鏈表不適合查找,適合增刪),反倒是下降了不少,於是就對這條鏈表進行了一個改進。如何改進呢?就是把這條鏈表變成一個適合查找的樹形結構,沒錯就是紅黑樹。於是HashMap的存儲數據結構就變成了下面的這種。
在這裏插入圖片描述

我們會發現優化的部分就是把鏈表結構變成了紅黑樹。原來jdk1.7的優點是增刪效率高,於是在jdk1.8的時候,不僅僅增刪效率高,而且查找效率也提升了。

注意:不是說變成了紅黑樹效率就一定提高了,只有在鏈表的長度不小於8,而且數組的長度不小於64的時候纔會將鏈表轉化爲紅黑樹,

問題一:什麼是紅黑樹呢?
紅黑樹是一個自平衡的二叉查找樹,也就是說紅黑樹的查找效率是非常的高,查找效率會從鏈表的o(n)降低爲o(logn)。如果之前沒有了解過紅黑樹的話,也沒關係,你就記住紅黑樹的查找效率很高就OK了。

問題二:爲什麼不一下子把整個鏈表變爲紅黑樹呢?
這個問題的意思是這樣的,就是說我們爲什麼非要等到鏈表的長度大於等於8的時候,才轉變成紅黑樹?在這裏可以從兩方面來解釋

(1)構造紅黑樹要比構造鏈表複雜,在鏈表的節點不多的時候,從整體的性能看來, 數組+鏈表+紅黑樹的結構可能不一定比數組+鏈表的結構性能高。就好比殺雞焉用牛刀的意思。

(2)HashMap頻繁的擴容,會造成底部紅黑樹不斷的進行拆分和重組,這是非常耗時的。因此,也就是鏈表長度比較長的時候轉變成紅黑樹纔會顯著提高效率。

OK,到這裏相信我們對hashMap的底層數據結構有了一個認識。現在帶着上面的結構圖,看一下如何存儲一個元素。

2、存儲元素

我們在存儲一個元素的時候,大多是使用下面的這種方式。

public class Test {
    public static void main(String[] args) {
        HashMap<String, Integer> map= new HashMap<>();
        //存儲一個元素
        map.put("張三", 20);
    }
}

在這裏HashMap,第一個參數是鍵,第二個參數是值,合起來叫做鍵值對。存儲的時候只需要調用put方法即可。那底層的實現原理是怎麼樣的呢?這裏還是先給出一個流程圖
在這裏插入圖片描述
上面這個流程,不知道你能否看到,紅色字跡的是三個判斷框,也是轉折點,我們使用文字來梳理一下這個流程:

(1)第一步:調用put方法傳入鍵值對

(2)第二步:使用hash算法計算hash值

(3)第三步:根據hash值確定存放的位置,判斷是否和其他鍵值對位置發生了衝突

(4)第四步:若沒有發生衝突,直接存放在數組中即可

(5)第五步:若發生了衝突,還要判斷此時的數據結構是什麼?

(6)第六步:若此時的數據結構是紅黑樹,那就直接插入紅黑樹中

(7)第七步:若此時的數據結構是鏈表,判斷插入之後是否大於等於8

(8)第八步:插入之後大於8了,就要先調整爲紅黑樹,在插入

(9)第九步:插入之後不大於8,那麼就直接插入到鏈表尾部即可。

上面就是插入數據的整個流程,光看流程還不行,我們還需要深入到源碼中去看看底部是如何按照這個流程寫代碼的。

鼠標聚焦在put方法上面,按一下F3,我們就能進入put的源碼。來看一下:

public V put(K key, V value) {
     return putVal(hash(key), key, value, false, true);
}

也就是說,put方法其實調用的是putVal方法。putVal方法有5個參數:

(1)第一個參數hash:調用了hash方法計算hash值

(2)第二個參數key:就是我們傳入的key值,也就是例子中的張三

(3)第三個參數value:就是我們傳入的value值,也就是例子中的20

(4)第四個參數onlyIfAbsent:也就是當鍵相同時,不修改已存在的值

(5)第五個參數evict :如果爲false,那麼數組就處於創建模式中,所以一般爲true。

知道了這5個參數的含義,我們就進入到這個putVal方法中。

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)
            n = (tab = resize()).length;
        //第二部分
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //第三部分
        else {
            Node<K,V> e; K k;
            //第三部分第一小節
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //第三部分第二小節
            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);
                        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;
    }

乍一看,這代碼完全沒有讀下去的慾望,第一次看的時候真實噁心到想吐,但是結合上一開始畫的流程圖再來分析,相信就會好很多。我們把代碼進行拆分(整體分了四大部分):

(1)Node[] tab中tab表示的就是數組。Nodep中p表示的就是當前插入的節點

(2)第一部分:

if ((tab = table) == null || (n = tab.length) == 0)
       n = (tab = resize()).length;

這一部分表示的意思是如果數組是空的,那麼就通過resize方法來創建一個新的數組。在這裏resize方法先不說明,在下一小節擴容的時候會提到。

(3)第二部分:

if ((p = tab[i = (n - 1) & hash]) == null)
      tab[i] = newNode(hash, key, value, null);

i表示在數組中插入的位置,計算的方式爲(n - 1) & hash。在這裏需要判斷插入的位置是否是衝突的,如果不衝突就直接newNode,插入到數組中即可,這就和流程圖中第一個判斷框對應了。

如果插入的hash值衝突了,那就轉到第三部分,處理衝突

(4)第三部分:

else {
            Node<K,V> e; K k;
            //第三部分a
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //第三部分b
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //第三部分c
            else {
                for (int binCount = 0; ; ++binCount) {
                    //第三小節第一段
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        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;
                }
            }
            //第三部分d
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }

我們會看到,處理衝突還真是麻煩,好在我們對這一部分又進行了劃分

a)第三部分第一小節:

if (p.hash == hash 
     &&((k = p.key) == key || (key != null && key.equals(k))))
     e = p;

在這裏判斷table[i]中的元素是否與插入的key一樣,若相同那就直接使用插入的值p替換掉舊的值e。

b)第三部分第二小節:

else if (p instanceof TreeNode)
       e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

判斷插入的數據結構是紅黑樹還是鏈表,在這裏表示如果是紅黑樹,那就直接putTreeVal到紅黑樹中。這就和流程圖裏面的第二個判斷框對應了。

c)第三部分第三小節:

//第三部分c
else {
     for (int binCount = 0; ; ++binCount) {
        //第三小節第一段
         if ((e = p.next) == null) {
              p.next = newNode(hash, key, value, null);
              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;
    }
}

如果數據結構是鏈表,首先要遍歷table數組是否存在,如果不存在直接newNode(hash, key, value, null)。如果存在了直接使用新的value替換掉舊的。

注意一點:不存在並且在鏈表末尾插入元素的時候,會判斷binCount >= TREEIFY_THRESHOLD - 1。也就是判斷當前鏈表的長度是否大於閾值8,如果大於那就會把當前鏈表轉變成紅黑樹,方法是treeifyBin。這也就和流程圖中第三個判斷框對應了。

(5)第四部分:

if (++size > threshold)
        resize();
afterNodeInsertion(evict);
return null;

3、擴容

在這裏插入圖片描述
這個擴容就比較簡單了,HaspMap擴容就是就是先計算 新的hash表容量和新的容量閥值,然後初始化一個新的hash表,將舊的鍵值對重新映射在新的hash表裏。如果在舊的hash表裏涉及到紅黑樹,那麼在映射到新的hash表中還涉及到紅黑樹的拆分。整個流程也符合我們正常擴容一個容量的過程,我們根據流程圖結合代碼來分析:

 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; // double 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) {
                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);
                        //鏈表1存於原索引
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //鏈表2存於原索引加上原hash桶長度的偏移量
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

這代碼量同樣讓人噁心,不過我們還是分段來分析:

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

根據代碼也能看明白:首先如果超過了數組的最大容量,那麼就直接將閾值設置爲整數最大值,然後如果沒有超過,那就擴容爲原來的2倍,這裏要注意是oldThr << 1,移位操作來實現的。
(2)第二部分:

//第二部分:設置閾值
else if (oldThr > 0) //閾值已經初始化了,就直接使用
      newCap = oldThr;
else {    // 沒有初始化閾值那就初始化一個默認的容量和閾值
      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;

首先第一個else if表示如果閾值已經初始化過了,那就直接使用舊的閾值。然後第二個else表示如果沒有初始化,那就初始化一個新的數組容量和新的閾值。

(3)第三部分

第三部分同樣也很複雜,就是把舊數據複製到新數組裏面。這裏面需要注意的有下面幾種情況:

A:擴容後,若hash值新增參與運算的位=0,那麼元素在擴容後的位置=原始位置

B:擴容後,若hash值新增參與運算的位=1,那麼元素在擴容後的位置=原始位置+擴容後的舊位置。

hash值新增參與運算的位是什麼呢?我們把hash值轉變成二進制數字,新增參與運算的位就是倒數第五位。

這裏面有一個非常好的設計理念,擴容後長度爲原hash表的2倍,於是把hash表分爲兩半,分爲低位和高位,如果能把原鏈表的鍵值對, 一半放在低位,一半放在高位,而且是通過e.hash & oldCap == 0來判斷,這個判斷有什麼優點呢?

舉個例子:n = 16,二進制爲10000,第5位爲1,e.hash & oldCap 是否等於0就取決於e.hash第5 位是0還是1,這就相當於有50%的概率放在新hash表低位,50%的概率放在新hash表高位。

OK,到這一步基本上就算是把擴容這一部分講完了,還有一個問題沒有解決,也就是說存儲的原理講明白了,存儲的元素多瞭如何擴容也明白了,擴容之後出現了地址衝突怎麼辦呢?

4、解決hash衝突

解決地址衝突的前提是計算的hash值出現了重複,我們就先來看看HashMap中,是如何計算hash值的。

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

代碼是超級簡單,hash值其實就是通過hashcode與16異或計算的來的,爲什麼要使用異或運算呢?畫一張圖你就明白了:
在這裏插入圖片描述
也就是說,通過異或運算能夠是的計算出來的hash比較均勻,不容易出現衝突。但是偏偏出現了衝突現象,這時候該如何去解決呢?

在數據結構中,我們處理hash衝突常使用的方法有:開發定址法、再哈希法、鏈地址法、建立公共溢出區。而hashMap中處理hash衝突的方法就是鏈地址法。

這種方法的基本思想是將所有哈希地址爲i的元素構成一個稱爲同義詞鏈的單鏈表,並將單鏈表的頭指針存在哈希表的第i個單元中,因而查找、插入和刪除主要在同義詞鏈中進行。鏈地址法適用於經常進行插入和刪除的情況。
在這裏插入圖片描述
相信大家都能看明白,出現地址衝突的時候,一個接一個排成一條鏈就OK了。正好與HashMap底層的數據結構相呼應。

5、構造一個HashMap

上面可能出現的問題,我們都已經說明了,關於他的構造方法卻姍姍來遲。下面我們好好說一下他的構造方法:

他的構造方法一共有四個:

第一個:

public HashMap() {
     this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

第二個:

public HashMap(int initialCapacity) {
     this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

第三個:

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

第四個:

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

這四個構造方法很明顯第四個最麻煩,我們就來分析一下第四個構造方法,其他三個自然而然也就明白了。上面出現了兩個新的名詞:loadFactor和initialCapacity。我們一個一個來分析:

(1)initialCapacity初始容量

官方要求我們要輸入一個2的N次冪的值,比如說2、4、8、16等等這些,但是我們忽然一個不小心,輸入了一個20怎麼辦?沒關係,虛擬機會根據你輸入的值,找一個離20最近的2的N次冪的值,比如說16離他最近,就取16爲初始容量。

(2)loadFactor負載因子

負載因子,默認值是0.75。負載因子表示一個散列表的空間的使用程度,有這樣一個公式:initailCapacity*loadFactor=HashMap的容量。
所以負載因子越大則散列表的裝填程度越高,也就是能容納更多的元素,元素多了,鏈表大了,所以此時索引效率就會降低。反之,負載因子越小則鏈表中的數據量就越稀疏,此時會對空間造成爛費,但是此時索引效率高。

爲什麼默認值會是0.75呢?我們截取一段jdk文檔:
在這裏插入圖片描述
在這裏插入圖片描述
英語不好的人看的我真是一臉懵逼,不過好在大概意思還能明白。看第三行Poisson_distribution這不就是泊淞分佈嘛。而且最關鍵的就是

當桶中元素到達8個的時候,概率已經變得非常小,也就是說用0.75作爲加載因子,每個碰撞位置的鏈表長度超過8個是幾乎不可能的。當桶中元素到達8個的時候,概率已經變得非常小,也就是說用0.75作爲加載因子,每個碰撞位置的鏈表長度超過8個是幾乎不可能的。

6、HashMap爲什麼是非線程安全的?

想要解決這個問題,答案很簡單,因爲源碼裏面方法全部都是非線程安全的呀,你根本找不到synchronized這樣的關鍵字。保證不了線程安全。於是出現了ConcurrentHashMap。

寫到這裏終於算是把一些核心的內容寫完了。當然HashMap裏面涉及到的面試題這些很多,不能能面面俱到。如有遺漏問題,會在今後補充。歡迎批評指正。

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