【面試系列】併發容器之ConcurrentHashMap

微信公衆號:放開我我還能學java

分享知識,共同進步!web

看你簡歷裏寫了 HashMap,那你說說它存在什麼缺點?數組

  1. 線程不安全
  2. 迭代時沒法修改值

那你有用過線程安全的 Map 嗎?安全

有,回答在哪用過。微信

沒有,不過我瞭解過。多線程

那你說說它們的實現。併發

Hashtableapp

Hashtable 自己比較低效,由於它的實現基本就是將 put、get、size 等各類方法加上 synchronized 鎖。這就致使了全部併發操做都要競爭同一把鎖,一個線程在進行同步操做時,其餘線程只能等待,大大下降了併發操做的效率。編輯器

Collections#SynchronizedMapide

同步包裝器 SynchronizedMap 雖然沒使用方法級別的 synchronized 鎖,可是使用了同步代碼塊的形式,本質上仍是沒有改進。

ConcurrentHashMap

首先 ConcurrentHashMap 在 JDK1.7 和 JDK1.8 的實現方式是不一樣的。

在 JDK1.7 中,ConcurrentHashMap 是由 Segment 數組結構和 HashEntry 數組結構組成。Segment 繼承了 ReentrantLock,是一種可重入鎖。HashEntry 則用於存儲鍵值對數據。一個 ConcurrentHashMap 裏包含一個 Segment 數組,一個 Segment 裏包含一個 HashEntry 數組 ,每一個 HashEntry 是一個鏈表結構的元素,所以 JDK1.7 的 ConcurrentHashMap 是一種數組+鏈表結構。當對 HashEntry 數組的數據進行修改時,必須首先得到與它對應的 Segment 鎖,這樣只要保證每一個 Segment 是線程安全的,也就實現了全局的線程安全(分段鎖)。

在 JDK1.8 中,ConcurrentHashMap 選擇了與 HashMap 相同的數組+鏈表+紅黑樹結構,在鎖的實現上,採用 CAS 操做和 synchronized 鎖實現更加低粒度的鎖,將鎖的級別控制在了更細粒度的 table 元素級別,也就是說只須要鎖住這個鏈表的首節點,並不會影響其餘的 table 元素的讀寫,大大提升了併發度。

那爲何 JDK1.8 要使用 synchronized 鎖而不是其餘鎖呢?

我認爲有如下兩個方面:

  • Java 開發人員從未放棄過 synchronized 關鍵字,並且一直在優化,在 JDK1.8 中,synchronized 鎖的性能獲得了很大的提升,而且 synchronized 有多種鎖狀態,會從無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖一步步轉換,所以並不是是咱們認爲的重量級鎖。
  • 在粗粒度加鎖中像 ReentrantLock 這種鎖能夠經過 Condition 來控制各個低粒度的邊界,更加的靈活。而在低粒度中,Condition 的優點就沒有了,此時 synchronized 不失爲一種更好的選擇。

你提到了 synchronized 的鎖狀態升級,能具體說下每種鎖狀態在什麼狀況下升級嗎?

無鎖

無鎖沒有對資源進行鎖定,全部的線程都能訪問並修改同一個資源,但同時只有一個線程能修改爲功。

偏向鎖

當一段同步代碼一直被同一個線程所訪問,無鎖就會升級爲偏向鎖,之後該線程訪問該同步代碼時會自動獲取鎖,下降了獲取鎖的代價。

輕量級鎖

當鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級爲輕量級鎖,其餘線程會經過自旋的形式嘗試獲取鎖,不會阻塞,從而提升性能。

重量級鎖

若當前只有一個等待線程,則該線程經過自旋進行等待。可是當自旋超過必定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級爲重量級鎖。

爲何 ConcurrentHashMap 的 key 和 value 不能爲 null?

這是由於當經過 get(k) 獲取對應的 value 時,若是獲取到的是 null 時,沒法判斷,它是 put(k,v) 的時候 value 爲 null,仍是這個 key 歷來沒有添加。

假如線程 1 調用 map.contains(key) 返回 true,當再次調用 map.get(key) 時,map 可能已經不一樣了。由於可能線程 2 在線程 1 調用 map.contains(key) 時,刪除了 key,這樣就會致使線程 1 獲得的結果不明確,產生多線程安全問題,所以,ConcurrentHashMap 的 key 和 value 不能爲 null。

其實這是一種安全失敗機制(fail-safe),這種機制會使你這次讀到的數據不必定是最新的數據。

那你談談快速失敗(fail-fast)和安全失敗(fail-safe)的區別。

快速失敗安全失敗都是 Java 集合中的一種機制。

若是採用快速失敗機制,那麼在使用迭代器對集合對象進行遍歷的時候,若是 A 線程正在對集合進行遍歷,此時 B 線程對集合進行增長、刪除、修改,或者 A 線程在遍歷過程當中對集合進行增長、刪除、修改,都會致使 A 線程拋出 ConcurrentModificationException 異常。

爲何在用迭代器遍歷時,修改集合就會拋異常時?

緣由是迭代器在遍歷時直接訪問集合中的內容,而且在遍歷過程當中使用一個 modCount 變量。集合在被遍歷期間若是內容發生變化,就會改變 modCount 的值。

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

java.util 包下的集合類都是快速失敗的。

若是採用安全失敗機制,那麼在遍歷時不是直接在集合內容上訪問,而是先複製原有集合內容,在拷貝的集合上進行遍歷。

因爲迭代時是對原集合的拷貝進行遍歷,因此在遍歷過程當中對原集合所做的修改並不能被迭代器檢測到,故不會拋 ConcurrentModificationException 異常。

java.util.concurrent 包下的併發容器都是安全失敗的。

ConcurrentHashMap 的 get 方法爲何不用加鎖,會不會出現數據讀寫不一致狀況呢?

不會出現讀寫不一致的狀況。

get 方法邏輯比較簡單,只須要將 key 經過 hash 以後定位到具體的 Segment ,再經過一次 hash 定位到具體的元素上。

因爲變量 value 是由 volatile 修飾的,根據 JMM 中的 happen before 規則保證了對於 volatile 修飾的變量始終是寫操做先於讀操做的,而且 volatile 的內存可見性保證修改完的數據能夠立刻更新到主存中,因此能保證在併發狀況下,讀出來的數據是最新的數據。

說下 ConcurrentHashMap 的 put 方法執行邏輯。

JDK1.7:

先嚐試自旋獲取鎖,若是自旋重試的次數超過 64 次,則改成阻塞獲取鎖。獲取到鎖後:

  1. 將當前 Segment 中的 table 經過 key 的 hashcode 定位到 HashEntry。
  2. 遍歷該 HashEntry,若是不爲空則判斷傳入的 key 和當前遍歷的 key 是否相等,相等則覆蓋舊的 value。
  3. 不爲空則須要新建一個 HashEntry 並加入到 Segment 中,同時會先判斷是否須要擴容。
  4. 釋放 Segment 的鎖

JDK1.8:

先定位到 Node,拿到首節點 first,判斷是否爲:

  1. 若是爲 null ,經過 CAS 的方式把數據 put 進去。
  2. 若是不爲 null ,而且 first.hash = MOVED = -1 ,說明其餘線程在擴容,參與一塊兒擴容。
  3. 若是不爲 null ,而且 first.hash != -1 ,synchronized 鎖住 first 節點,判斷是鏈表仍是紅黑樹,遍歷插入。

說下 ConcurrentHashMap 的 size 方法如何計算最終大小。

JDK1.7:

雖然 count 變量是被 volatile 修飾的,可是並非簡單的把全部 Segment 的 count 值相加。由於有可能在累加過程當中 count 值發生了改變,那麼此時結果就不正確了。可是也不能直接鎖住,這樣效率過低。所以在 JDK1.7 中的作法是先嚐試 2 次經過不鎖住 Segment 的方式來統計各個 Segment 大小,若是統計的過程當中,容器的 count 發生了變化,則再採用加鎖的方式來統計全部Segment 的大小。

那麼 ConcurrentHashMap 是如何判斷在統計的時候容器是否發生了變化呢?使用modCount變量,在putremove 方法裏操做元素前都會將變量 modCount 進行加 1,那麼在統計大小先後比較 modCount 是否發生變化,從而得知容器的大小是否發生變化。

JDK1.8:

因爲沒有 segment 的概念,因此只須要用一個 baseCount 變量來記錄 ConcurrentHashMap 當前節點的個數。

  1. 先嚐試經過CAS 更新 baseCount 計數。
  2. 若是多線程競爭激烈,某些線程 CAS 失敗,那就 CAS 嘗試將 cellsBusy 置 1,成功則能夠把 baseCount 變化的次數暫存到一個數組 counterCells 裏,後續數組 counterCells 的值會加到 baseCount 中。
  3. 若是 cellsBusy 置 1 失敗又會反覆進行 CAS baseCount 和 CAS counterCells 數組。

如何提升 ConcurrentHashMap 的插入效率?

主要從如下兩個方面入手:

  • 擴容操做。主要仍是要經過配置合理的容量大小和負載因子,儘量減小擴容事件的發生。
  • 鎖資源的爭奪,在 put 方法中會使用 synchonized 對首節點進行加鎖,而鎖自己也是分等級的,所以咱們的主要思路就是儘量的避免鎖升級。咱們能夠將數據經過 ConcurrentHashMap 的 spread 方法進行預處理,這樣咱們能夠將存在哈希衝突的數據放在一個桶裏面,每一個桶都使用單線程進行 put 操做,這樣的話能夠保證鎖僅停留在偏向鎖這個級別,不會升級,從而提高效率。

ConcurrentHashMap 是否存在線程不安全的狀況?若是存在的話,在什麼狀況下會出現?如何解決?

我想起來了,存在這種狀況,請看以下代碼

public class ConcurrentHashMapNotSafeDemo implements Runnable {
 private static ConcurrentHashMap<String, Integer> scores = new ConcurrentHashMap<>();  public static void main(String[] args) throws InterruptedException { scores.put("John", 0); Thread t1 = new Thread(new ConcurrentHashMapNotSafeDemo()); Thread t2 = new Thread(new ConcurrentHashMapNotSafeDemo());  t1.start(); t2.start();  t1.join(); t2.join();  System.out.println(scores);  }  @Override public void run() { for (int i = 0; i < 1000; i++) { Integer score = scores.get("John"); Integer newScore = score + 1; scores.put("John", newScore); } } } 複製代碼

結果輸出 {John=1323},很明顯發生了錯誤。

image-20200503162421296
image-20200503162421296

緣由就在於這三行在一塊兒不是線程安全的,雖然 get 方法和 put 方法是線程安全的,可是中間的又對獲取的值修改了,所以致使線程不安全。

解決方法是使用 replace 方法

image-20200503163230206
image-20200503163230206

能夠看到,replace 方法傳入三個值,分別是當前 key、舊值、新值

最終修改以下:

image-20200503163158037
image-20200503163158037

獲取更多最新文章,關注公衆號【放開我我還能學】

相關文章
相關標籤/搜索