滲入Hashmap、Hash table及ConcurrentHashMap

目錄

 

前言

回手掏

正文

總結

常見問題

加分項


前言

作爲一個在互聯網公司面一次拿一次Offer的麪霸,打敗了無數競爭對手,每次都只能看到無數落寞的身影失望的離開,略感愧疚(請允許我使用一下誇張的修辭手法)。

於是在一個寂寞難耐的夜晚,我痛定思痛,決定開始寫互聯網技術棧面試相關的文章,希望能幫助各位讀者以後面試勢如破竹,對面試官進行360°的反擊,吊打問你的面試官,讓一同面試的同僚瞠目結舌,瘋狂收割大廠Offer!

所有文章的名字只是我的噱頭,我們應該有一顆謙遜的心,所以希望大家懷着空杯心態好好學,一起進步。

回手掏

上次面試呀,我發現面試官對我的幾個回答還是不夠滿意,覺得還是有點疑問,我就挑幾個回答一下。

16是2的冪,8也是,32也是,爲啥偏偏選了16?

我覺得就是一個經驗值,定義16沒有很特別的原因,只要是2次冪,其實用 8 和 32 都差不多。

用16只是因爲作者認爲16這個初始容量是能符合常用而已。

Hashmap中的鏈表大小超過八個時會自動轉化爲紅黑樹,當刪除小於六時重新變爲鏈表,爲啥呢?

根據泊松分佈,在負載因子默認爲0.75的時候,單個hash槽內元素個數爲8的概率小於百萬分之一,所以將7作爲一個分水嶺,等於7的時候不轉換,大於等於8的時候才進行轉換,小於等於6的時候就化爲鏈表。

正文

一個婀娜多姿,穿着襯衣的小姐姐,拿着一個精緻的小筆記本,徑直走過來坐在我的面前。

就在我口水要都要流出來的時候,小姐姐的話語打斷了我的YY。

 

喂小鬼,你養我啊!

呸呸呸,說錯了,上次的HashMap回答得不錯,最後因爲天色太晚了面試草草收場,這次可得好好安排你。

誒,面試官上次是在抱歉,因爲公司雙十二要值班,實在是沒辦法,不過這次不會了,我推掉了所有的事情準備全身心投入到今天的面試中,甚至推掉了隔壁王大爺的約會邀約。

這樣最好,上次我們最後聊到HashMap在多線程環境下存在線程安全問題,那你一般都是怎麼處理這種情況的?

美麗迷人的面試官您好,一般在多線程的場景,我都會使用好幾種不同的方式去代替:

  • 使用Collections.synchronizedMap(Map)創建線程安全的map集合;

  • Hashtable

  • ConcurrentHashMap

不過出於線程併發度的原因,我都會捨棄前兩者使用最後的ConcurrentHashMap,他的性能和效率明顯高於前兩者。

哦,Collections.synchronizedMap是怎麼實現線程安全的你有了解過麼?

臥*!不按照套路出牌呀,正常不都是問HashMap和ConcurrentHashMap麼,這次怎麼問了這個鬼東西,還好我飽讀詩書,經常看敖丙的《吊打面試官》系列,不然真的完了。

小姐姐您這個問題真好,別的面試官都沒問過,說真的您水平肯定是頂級技術專家吧。

別貧嘴,快回答我的問題!抿嘴一笑😁

在SynchronizedMap內部維護了一個普通對象Map,還有排斥鎖mutex,如圖

Collections.synchronizedMap(new HashMap<>(16));

我們在調用這個方法的時候就需要傳入一個Map,可以看到有兩個構造器,如果你傳入了mutex參數,則將對象排斥鎖賦值爲傳入的對象。

如果沒有,則將對象排斥鎖賦值爲this,即調用synchronizedMap的對象,就是上面的Map。

創建出synchronizedMap之後,再操作map的時候,就會對方法上鎖,如圖全是🔐

臥*,小夥子,秒啊,其實我早就忘了源碼了,就是瞎問一下,沒想到還是回答上來了,接下來就面對疾風吧。

回答得不錯,能跟我聊一下Hashtable麼?

這個我就等着你問呢嘿嘿!

跟HashMap相比Hashtable是線程安全的,適合在多線程的情況下使用,但是效率可不太樂觀。

哦,你能說說他效率低的原因麼?

嗯嗯面試官,我看過他的源碼,他在對數據操作的時候都會上鎖,所以效率比較低下。

除了這個你還能說出一些Hashtable 跟HashMap不一樣點麼?

!吶呢?這叫什麼問題嘛?這個又是知識盲區呀!

呃,面試官我從來沒使用過他,你容我想想區別的點,說完便開始抓頭髮,這次不是裝的,是真的!

Hashtable 是不允許鍵或值爲 null 的,HashMap 的鍵值則都可以爲 null。

呃我能打斷你一下麼?爲啥 Hashtable 是不允許 KEY 和 VALUE 爲 null, 而 HashMap 則可以呢?

尼*,我這個時候怎麼覺得面前的人不好看了,甚至像個魔鬼,看着對自己面試官心裏想到。

因爲Hashtable在我們put 空值的時候會直接拋空指針異常,但是HashMap卻做了特殊處理。

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

但是你還是沒說爲啥Hashtable 是不允許鍵或值爲 null 的,HashMap 的鍵值則都可以爲 null?

這是因爲Hashtable使用的是安全失敗機制(fail-safe),這種機制會使你此次讀到的數據不一定是最新的數據。

如果你使用null值,就會使得其無法判斷對應的key是不存在還是爲空,因爲你無法再調用一次contain(key)來對key是否存在進行判斷,ConcurrentHashMap同理。

好的你繼續說不同點吧。

  • 實現方式不同:Hashtable 繼承了 Dictionary類,而 HashMap 繼承的是 AbstractMap 類。

    Dictionary 是 JDK 1.0 添加的,貌似沒人用過這個,我也沒用過。

  • 初始化容量不同:HashMap 的初始容量爲:16,Hashtable 初始容量爲:11,兩者的負載因子默認都是:0.75。

  • 擴容機制不同:當現有容量大於總容量 * 負載因子時,HashMap 擴容規則爲當前容量翻倍,Hashtable 擴容規則爲當前容量翻倍 + 1。

  • 迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。

    所以,當其他線程改變了HashMap 的結構,如:增加、刪除元素,將會拋出ConcurrentModificationException 異常,而 Hashtable 則不會。

fail-fast是啥?

臥*,你自己不知道麼?爲啥問我!!!還好我會!

快速失敗(fail—fast)是java集合中的一種機制, 在用迭代器遍歷一個集合對象時,如果遍歷過程中對集合對象的內容進行了修改(增加、刪除、修改),則會拋出Concurrent Modification Exception。

他的原理是啥?

迭代器在遍歷時直接訪問集合中的內容,並且在遍歷過程中使用一個 modCount 變量。

集合在被遍歷期間如果內容發生變化,就會改變modCount的值。

每當迭代器使用hashNext()/next()遍歷下一個元素之前,都會檢測modCount變量是否爲expectedmodCount值,是的話就返回遍歷;否則拋出異常,終止遍歷。

Tip:這裏異常的拋出條件是檢測到 modCount!=expectedmodCount 這個條件。如果集合發生變化時修改modCount值剛好又設置爲了expectedmodCount值,則異常不會拋出。

因此,不能依賴於這個異常是否拋出而進行併發操作的編程,這個異常只建議用於檢測併發修改的bug。

說說他的場景?

java.util包下的集合類都是快速失敗的,不能在多線程下發生併發修改(迭代過程中被修改)算是一種安全機制吧。

Tip安全失敗(fail—safe)大家也可以瞭解下,java.util.concurrent包下的容器都是安全失敗,可以在多線程下併發使用,併發修改。

嗯?這個小鬼這麼有東西的嘛?居然把不同點幾乎都說出來了,被人遺忘的Hashtable都能說得頭頭是道,看來不簡單,不知道接下來的ConcurrentHashMap連環炮能不能頂得住了。

都說了他的併發度不夠,性能很低,這個時候你都怎麼處理的?

他來了他來了,他終於還是來了,等了這麼久,就是等你問我這個點,你還是掉入了我的陷阱啊,我早有準備,在HashMap埋下他線程不安全的種子,就是爲了在ConcurrentHashMap開花結果!

小姐姐:這樣的場景,我們在開發過程中都是使用ConcurrentHashMap,他的併發的相比前兩者好很多。

哦?那你跟我說說他的數據結構吧,以及爲啥他併發度這麼高?

HashMap 底層是基於 數組 + 鏈表 組成的,不過在 jdk1.7 和 1.8 中具體實現稍有不同。

我先說一下他在1.7中的數據結構吧:

如圖所示,是由 Segment 數組、HashEntry 組成,和 HashMap 一樣,仍然是數組加鏈表

Segment 是 ConcurrentHashMap 的一個內部類,主要的組成如下:

static final class Segment<K,V> extends ReentrantLock implements Serializable {

    private static final long serialVersionUID = 2249069246763182397L;

    // 和 HashMap 中的 HashEntry 作用一樣,真正存放數據的桶
    transient volatile HashEntry<K,V>[] table;

    transient int count;
        // 記得快速失敗(fail—fast)麼?
    transient int modCount;
        // 大小
    transient int threshold;
        // 負載因子
    final float loadFactor;

}

HashEntry跟HashMap差不多的,但是不同點是,他使用volatile去修飾了他的數據Value還有下一個節點next。

volatile的特性是啥?

  • 保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。(實現可見性

  • 禁止進行指令重排序。(實現有序性

  • volatile 只能保證對單次讀/寫的原子性。i++ 這種操作不能保證原子性

我就不大篇幅介紹了,多線程章節我會說到的,大家知道用了之後安全了就對了。

那你能說說他併發度高的原因麼?

原理上來說,ConcurrentHashMap 採用了分段鎖技術,其中 Segment 繼承於 ReentrantLock。

不會像 HashTable 那樣不管是 put 還是 get 操作都需要做同步處理,理論上 ConcurrentHashMap 支持 CurrencyLevel (Segment 數組數量)的線程併發。

每當一個線程佔用鎖訪問一個 Segment 時,不會影響到其他的 Segment。

就是說如果容量大小是16他的併發度就是16,可以同時允許16個線程操作16個Segment而且還是線程安全的。

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();//這就是爲啥他不可以put null值的原因
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          
         (segments, (j << SSHIFT) + SBASE)) == null) 
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

他先定位到Segment,然後再進行put操作。

我們看看他的put源代碼,你就知道他是怎麼做到線程安全的了,關鍵句子我註釋了。

        final V put(K key, int hash, V value, boolean onlyIfAbsent) {
          // 將當前 Segment 中的 table 通過 key 的 hashcode 定位到 HashEntry
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
 // 遍歷該 HashEntry,如果不爲空則判斷傳入的 key 和當前遍歷的 key 是否相等,相等則覆蓋舊的 value。
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                 // 不爲空則需要新建一個 HashEntry 並加入到 Segment 中,同時會先判斷是否需要擴容。
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
               //釋放鎖
                unlock();
            }
            return oldValue;
        }

首先第一步的時候會嘗試獲取鎖,如果獲取失敗肯定就有其他線程存在競爭,則利用 scanAndLockForPut() 自旋獲取鎖。

  1. 嘗試自旋獲取鎖。

  2. 如果重試的次數達到了 MAX_SCAN_RETRIES 則改爲阻塞鎖獲取,保證能獲取成功。

那他get的邏輯呢?

get 邏輯比較簡單,只需要將 Key 通過 Hash 之後定位到具體的 Segment ,再通過一次 Hash 定位到具體的元素上。

由於 HashEntry 中的 value 屬性是用 volatile 關鍵詞修飾的,保證了內存可見性,所以每次獲取時都是最新值。

ConcurrentHashMap 的 get 方法是非常高效的,因爲整個過程都不需要加鎖

你有沒有發現1.7雖然可以支持每個Segment併發訪問,但是還是存在一些問題?

是的,因爲基本上還是數組加鏈表的方式,我們去查詢的時候,還得遍歷鏈表,會導致效率很低,這個跟jdk1.7的HashMap是存在的一樣問題,所以他在jdk1.8完全優化了。

那你再跟我聊聊jdk1.8他的數據結構是怎麼樣子的呢?

其中拋棄了原有的 Segment 分段鎖,而採用了 CAS + synchronized 來保證併發安全性。

跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不變,把值和next採用了volatile去修飾,保證了可見性,並且也引入了紅黑樹,在鏈表大於一定值的時候會轉換(默認是8)。

同樣的,你能跟我聊一下他值的存取操作麼?以及是怎麼保證線程安全的?

ConcurrentHashMap在進行put操作的還是比較複雜的,大致可以分爲以下步驟:

  1. 根據 key 計算出 hashcode 。

  2. 判斷是否需要進行初始化。

  3. 即爲當前 key 定位出的 Node,如果爲空表示當前位置可以寫入數據,利用 CAS 嘗試寫入,失敗則自旋保證成功。

  4. 如果當前位置的 hashcode == MOVED == -1,則需要進行擴容。

  5. 如果都不滿足,則利用 synchronized 鎖寫入數據。

  6. 如果數量大於 TREEIFY_THRESHOLD 則要轉換爲紅黑樹。

你在上面提到CAS是什麼?自旋又是什麼?

CAS 是樂觀鎖的一種實現方式,是一種輕量級鎖,JUC 中很多工具類的實現就是基於 CAS 的。

CAS 操作的流程如下圖所示,線程在讀取數據時不進行加鎖,在準備寫回數據時,比較原值是否修改,若未被其他線程修改則寫回,若已被修改,則重新執行讀取流程。

這是一種樂觀策略,認爲併發操作並不總會發生。

還是不明白?那我再說明下,樂觀鎖在實際開發場景中非常常見,大家還是要去理解。

就比如我現在要修改數據庫的一條數據,修改之前我先拿到他原來的值,然後在SQL裏面還會加個判斷,原來的值和我手上拿到的他的原來的值是否一樣,一樣我們就可以去修改了,不一樣就證明被別的線程修改了你就return錯誤就好了。

SQL僞代碼大概如下:

update a set value = newValue where value = #{oldValue}//oldValue就是我們執行前查詢出來的值 

CAS就一定能保證數據沒被別的線程修改過麼?

並不是的,比如很經典的ABA問題,CAS就無法判斷了。

什麼是ABA?

就是說來了一個線程把值改回了B,又來了一個線程把值又改回了A,對於這個時候判斷的線程,就發現他的值還是A,所以他就不知道這個值到底有沒有被人改過,其實很多場景如果只追求最後結果正確,這是沒關係的。

但是實際過程中還是需要記錄修改過程的,比如資金修改什麼的,你每次修改的都應該有記錄,方便回溯。

那怎麼解決ABA問題?

用版本號去保證就好了,就比如說,我在修改前去查詢他原來的值的時候再帶一個版本號,每次判斷就連值和版本號一起判斷,判斷成功就給版本號加1。

update a set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision} // 判斷原來的值和版本號是否匹配,中間有別的線程修改,值可能相等,但是版本號100%不一樣

牛*,有點東西,除了版本號還有別的方法保證麼?

其實有很多方式,比如時間戳也可以,查詢的時候把時間戳一起查出來,對的上才修改並且更新值的時候一起修改更新時間,這樣也能保證,方法很多但是跟版本號都是異曲同工之妙,看場景大家想怎麼設計吧。

CAS性能很高,但是我知道synchronized性能可不咋地,爲啥jdk1.8升級之後反而多了synchronized?

synchronized之前一直都是重量級的鎖,但是後來java官方是對他進行過升級的,他現在採用的是鎖升級的方式去做的。

針對 synchronized 獲取鎖的方式,JVM 使用了鎖升級的優化方式,就是先使用偏向鎖優先同一線程然後再次獲取鎖,如果失敗,就升級爲 CAS 輕量級鎖,如果失敗就會短暫自旋,防止線程被系統掛起。最後如果以上都失敗就升級爲重量級鎖

所以是一步步升級上去的,最初也是通過很多輕量級的方式鎖定的。

🐂,那我們迴歸正題,ConcurrentHashMap的get操作又是怎麼樣子的呢?

  • 根據計算出來的 hashcode 尋址,如果就在桶上那麼直接返回值。

  • 如果是紅黑樹那就按照樹的方式獲取值。

  • 就不滿足那就按照鏈表的方式遍歷獲取值。

小結:1.8 在 1.7 的數據結構上做了大的改動,採用紅黑樹之後可以保證查詢效率(O(logn)),甚至取消了 ReentrantLock 改爲了 synchronized,這樣可以看出在新版的 JDK 中對 synchronized 優化是很到位的。

總結

Hashtable&ConcurrentHashMap跟HashMap基本上就是一套連環組合,我在面試的時候經常能吹上很久,經常被面試官說:好了好了,我們繼續下一個話題吧哈哈。

是的因爲提到HashMap你肯定會聊到他的線程安全性這一點,那你總不能加鎖一句話就搞定了吧,java的作者們也不想,所以人家寫開發了對應的替代品,那就是線程安全的Hashtable&ConcurrentHashMap。

兩者都有特點,但是線程安全場景還是後者用得多一點,原因我在文中已經大篇幅全方位的介紹了,這裏就不再過多贅述了。

你們發現了面試就是一個個的坑,你說到啥面試官可能就懟到你啥,別問我爲啥知道嘿嘿。

你知道不確定能不能爲這場面試加分,但是不知道肯定是減分的,文中的快速失敗(fail—fast)問到,那對應的安全失敗(fail—safe)也是有可能知道的,我想讀者很多都不知道吧,因爲我問過很多仔哈哈。

還有提到CAS樂觀鎖,你要知道ABA,你要知道解決方案,因爲在實際的開發場景真的不要太常用了,sync的鎖升級你也要知道。

我沒過多描述線程安全的太多東西,因爲我都寫了,以後更啥?對吧哈哈。

常見問題

  • 談談你理解的 Hashtable,講講其中的 get put 過程。ConcurrentHashMap同問。

  • 1.8 做了什麼優化?

  • 線程安全怎麼做的?

  • 不安全會導致哪些問題?

  • 如何解決?有沒有線程安全的併發容器?

  • ConcurrentHashMap 是如何實現的?

  • ConcurrentHashMap併發度爲啥好這麼多?

  • 1.7、1.8 實現有何不同?爲什麼這麼做?

  • CAS是啥?

  • ABA是啥?場景有哪些,怎麼解決?

  • synchronized底層原理是啥?

  • synchronized鎖升級策略

  • 快速失敗(fail—fast)是啥,應用場景有哪些?安全失敗(fail—safe)同問。

  • ……

加分項

在回答Hashtable和ConcurrentHashMap相關的面試題的時候,一定要知道他們是怎麼保證線程安全的,那線程不安全一般都是發生在存取的過程中的,那get、put你肯定要知道。

HashMap是必問的那種,這兩個經常會作爲替補問題,不過也經常問,他們本身的機制其實都比較簡單,特別是ConcurrentHashMap跟HashMap是很像的,只是是否線程安全這點不同。

提到線程安全那你就要知道相關的知識點了,比如說到CAS你一定要知道ABA的問題,提到synchronized那你要知道他的原理,他鎖對象,方法、代碼塊,在底層是怎麼實現的。

synchronized你還需要知道他的鎖升級機制,以及他的兄弟ReentantLock,兩者一個是jvm層面的一個是jdk層面的,還是有很大的區別的。

那提到他們兩個你是不是又需要知道juc這個包下面的所有的常用類,以及他們的底層原理了?

參考:https://juejin.im/post/5df8d7346fb9a015ff64eaf9 

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