天天看點

JDK1.8 HashMap源碼剖析

上篇我們介紹了JDK1.7版的HashMap,今天我們來講解下JDK1.8版的HashMap。

JDK1.7的實作大家看出有沒有需要優化的地方?

其實一個很明顯的地方就是:當 Hash 沖突嚴重時,在桶上形成的連結清單會變的越來越長,這樣在查詢時的效率就會越來越低;時間複雜度為 O(N)。

是以JDK1.8 中重點優化了這個查詢效率。

1、JDK1.8 HashMap 資料結構圖

JDK1.8 HashMap源碼剖析

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

注意:不是說變成了紅黑樹效率就一定提高了,隻有在連結清單的長度不小于8,而且數組的長度不小于64的時候才會将連結清單轉化為紅黑樹。

問題一:什麼是紅黑樹呢?

紅黑樹是一個自平衡的二叉查找樹,也就是說紅黑樹的查找效率是非常的高,查找效率會從連結清單的o(n)降低為o(logn)。如果之前沒有了解過紅黑樹的話,也沒關系,你就記住紅黑樹的查找效率很高就OK了。

問題二:為什麼不一下子把整個連結清單變為紅黑樹呢?

這個問題的意思是這樣的,就是說我們為什麼非要等到連結清單的長度大于等于8的時候,才轉變成紅黑樹?在這裡可以從兩方面來解釋

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

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

2、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的容量。 是以負載因子越大則散清單的裝填程度越高,也就是能容納更多的元素,元素多了,連結清單大了,是以此時索引效率就會降低。反之,負載因子越小則連結清單中的資料量就越稀疏,此時會對空間造成爛費,但是此時索引效率高。

3、put方法

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

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

在這裡HashMap<String, Integer>,第一個參數是鍵,第二個參數是值,合起來叫做鍵值對。存儲的時候隻需要調用put方法即可。那底層的實作原理是怎麼樣的呢?這裡還是先給出一個流程圖:

JDK1.8 HashMap源碼剖析

上面這個流程,不知道你能否看到,紅色字迹的是三個判斷框,也是轉折點,我們使用文字來梳理一下這個流程:

(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<K,V>[] tab中tab表示的就是數組。Node<K,V> p中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;
           

插入成功之後,還要判斷一下實際存在的鍵值對數量size是否大于門檻值threshold。如果大于那就開始擴容了。

4、resize方法

為什麼擴容呢?很明顯就是目前容量不夠,也就是put了太多的元素。為此我們還是先給出一個流程圖,再來進行分析。

JDK1.8 HashMap源碼剖析

這個擴容就比較簡單了,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值新增參與運算的位!=0,那麼元素在擴容後的位置=原始位置+擴容後的舊位置。

這裡面有一個非常好的設計理念,擴容後長度為原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表高位。

5、hash方法

Java 8中的散列值優化函數:

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

源碼中模運算就是把散列值和數組長度做一個"與"操作:

這也正好解釋了為什麼HashMap的數組長度要取2的整次幂,因為這樣(數組長度-1)正好相當于一個“低位掩碼”,“與”操作的結果就是散列值的高位全部歸零,隻保留低位值,用來做數組下标通路。

以初始長度16為例,16-1=15,2進制表示是00000000 00000000 00001111,和某散列值做“與”操作如下,結果就是截取了最低的四位值:

JDK1.8 HashMap源碼剖析

但這時候問題就來了:這樣就算我的散列值分布再松散,要是隻取最後幾位的話,碰撞也會很嚴重,這時候“擾動函數”的價值就展現出來了:

JDK1.8 HashMap源碼剖析

右位移16位,正好是32位一半,自己的高半區和低半區做異或,就是為了混合原始hashCode的高位和低位,以此來加大低位的随機性,而且混合後的低位摻雜了高位的部分特征,這樣高位的資訊也被變相保留下來,即降低了哈希沖突的風險又不會帶來太大的性能問題。這個設計很巧妙!

6、hash沖突

通過異或運算能夠是的計算出來的hash比較均勻,不容易出現沖突。但是偏偏出現了沖突現象,這時候該如何去解決呢?

在資料結構中,我們處理hash沖突常使用的方法有:開發定址法、再哈希法、鍊位址法、建立公共溢出區。而hashMap中處理hash沖突的方法就是鍊位址法。

這種方法的基本思想是将所有哈希位址為i的元素構成一個稱為同義詞鍊的單連結清單,并将單連結清單的頭指針存在哈希表的第i個單元中,因而查找、插入和删除主要在同義詞鍊中進行。鍊位址法适用于經常進行插入和删除的情況。

7、table數組用transient修飾

/**
 * The table, initialized on first use, and resized as
 * necessary. When allocated, length is always a power of two.
 * (We also tolerate length zero in some operations to allow
 * bootstrapping mechanics that are currently not needed.)
 */
transient Node<K,V>[] table;
           

從HashMap 的源碼,會發現桶數組 table 被申明為 transient。transient 表示易變的意思,在 Java 中,被該關鍵字修飾的變量不會被預設的序列化機制序列化。我們再回到源碼中,考慮一個問題:桶數組 table 是 HashMap 底層重要的資料結構,不序列化的話,别人還怎麼還原呢?

這裡簡單說明一下吧,HashMap 并沒有使用預設的序列化機制,而是通過實作readObject/writeObject兩個方法自定義了序列化的内容。這樣做是有原因的,試問一句,HashMap 中存儲的内容是什麼?不用說,大家也知道是鍵值對。是以隻要我們把鍵值對序列化了,我們就可以根據鍵值對資料重建 HashMap。有的朋友可能會想,序列化 table 不是可以一步到位,後面直接還原不就行了嗎?這樣一想,倒也是合理。但序列化 talbe 存在着兩個問題:

1)table 多數情況下是無法被存滿的,序列化未使用的部分,浪費空間。

2)同一個鍵值對在不同 JVM 下,所處的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能會發生錯誤。

以上兩個問題中,第一個問題比較好了解,第二個問題解釋一下。HashMap 的get/put/remove等方法第一步就是根據 hash 找到鍵所在的桶位置,但如果鍵沒有覆寫 hashCode 方法,計算 hash 時最終調用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能會有不同的實作,産生的 hash 可能也是不一樣的。也就是說同一個鍵在不同平台下可能會産生不同的 hash,此時再對在同一個 table 繼續操作,就會出現問題。

綜上所述,大家應該能明白 HashMap 不序列化 table 的原因了,下面是HashMap自定義的序列化代碼:

private void writeObject(java.io.ObjectOutputStream s)
    throws IOException {
    int buckets = capacity();
    // Write out the threshold, loadfactor, and any hidden stuff
    // 寫入一些屬性值,待反序列化時用到
    s.defaultWriteObject();
    s.writeInt(buckets);
    s.writeInt(size);
    internalWriteEntries(s);
}

 // Called only from writeObject, to ensure compatible ordering.
    void internalWriteEntries(java.io.ObjectOutputStream s) throws IOException {
   Node<K,V>[] tab;
    if (size > 0 && (tab = table) != null) {
        for (int i = 0; i < tab.length; ++i) {
            for (Node<K,V> e = tab[i]; e != null; e = e.next) {
            	//寫入鍵值對
                s.writeObject(e.key);
                s.writeObject(e.value);
            }
        }
    }
}

private void readObject(java.io.ObjectInputStream s)
        throws IOException, ClassNotFoundException {
    // Read in the threshold (ignored), loadfactor, and any hidden stuff
    s.defaultReadObject();
    reinitialize();
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new InvalidObjectException("Illegal load factor: " +
                                         loadFactor);
    s.readInt();                // Read and ignore number of buckets
    int mappings = s.readInt(); // Read number of mappings (size)
    if (mappings < 0)
        throw new InvalidObjectException("Illegal mappings count: " +
                                         mappings);
    else if (mappings > 0) { // (if zero, use defaults)
        // Size the table using given load factor only if within
        // range of 0.25...4.0
        float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
        float fc = (float)mappings / lf + 1.0f;
        int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
                   DEFAULT_INITIAL_CAPACITY :
                   (fc >= MAXIMUM_CAPACITY) ?
                   MAXIMUM_CAPACITY :
                   tableSizeFor((int)fc));
        float ft = (float)cap * lf;
        threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                     (int)ft : Integer.MAX_VALUE);
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
        table = tab;

        // Read the keys and values, and put the mappings in the HashMap
        // 讀出鍵值對,放入hashMap
        for (int i = 0; i < mappings; i++) {
            @SuppressWarnings("unchecked")
                K key = (K) s.readObject();
            @SuppressWarnings("unchecked")
                V value = (V) s.readObject();
            putVal(hash(key), key, value, false, false);
        }
    }
}
           

8、HashMap非線程安全

HashMap源碼裡面方法是沒有synchronized或lock處理的,無法保證線程安全。于是出現了線程安全的ConcurrentHashMap,這個我們後續講解。

歡迎小夥伴們留言交流~~

浏覽更多文章可關注微信公衆号:diggkr