你知道的越多,你不知道的越多
點贊再看,養成習慣
本文 GitHub https://github.com/JavaFamily 已收錄,有一線大廠面試點思維導圖,也整理了很多我的文檔,歡迎Star和完善,大家面試可以參照考點復習,希望我們一起有點東西。
前言
作為一個在互聯網公司面一次拿一次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,他的並發的相比前兩者好很多。
哦?那你跟我說說他的數據結構吧,以及為啥他並發度這么高?
ConcurrentHashMap 底層是基於 數組 + 鏈表
組成的,不過在 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()
自旋獲取鎖。
- 嘗試自旋獲取鎖。
- 如果重試的次數達到了
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操作的還是比較復雜的,大致可以分為以下步驟:
- 根據 key 計算出 hashcode 。
- 判斷是否需要進行初始化。
- 即為當前 key 定位出的 Node,如果為空表示當前位置可以寫入數據,利用 CAS 嘗試寫入,失敗則自旋保證成功。
- 如果當前位置的
hashcode == MOVED == -1
,則需要進行擴容。 - 如果都不滿足,則利用 synchronized 鎖寫入數據。
- 如果數量大於
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這個包下面的所有的常用類,以及他們的底層原理了?
那提到……
點關注,不迷路
好了各位,以上就是這篇文章的全部內容了,能看到這里的人呀,都是人才。
我后面會每周都更新幾篇一線互聯網大廠面試和常用技術棧相關的文章,非常感謝人才們能看到這里,如果這個文章寫得還不錯,覺得「敖丙」我有點東西的話 求點贊👍 求關注❤️ 求分享👥 對暖男我來說真的 非常有用!!!
白嫖不好,創作不易,各位的支持和認可,就是我創作的最大動力,我們下篇文章見!
敖丙 | 文 【原創】
如果本篇博客有任何錯誤,請批評指教,不勝感激 !
文章每周持續更新,可以微信搜索「 三太子敖丙 」第一時間閱讀和催更(比博客早一到兩篇喲),本文 GitHub https://github.com/JavaFamily 已經收錄,有一線大廠面試點思維導圖,也整理了很多我的文檔,歡迎Star和完善,大家面試可以參照考點復習,希望我們一起有點東西。
