面經梳理-java多執行緒其他

fattree發表於2024-06-17

題目

Threadlocal使用場景?原理?如何保證記憶體不洩露?

ThreadLocal使用場景

不加鎖的情況下,多執行緒安全訪問共享變數,每個執行緒保留共享變數的副本(執行緒特有物件),每個執行緒往這個ThreadLocal中讀寫是執行緒隔離。

ThreadLocal原理

Thread類有一個型別為ThreadLocal.ThreadLocalMap的例項變數threadLocals,每個執行緒都有一個自己的ThreadLocalMap。
ThreadLocal的get\set\remove方法均先找出當前執行緒的ThreadLocalMap,然後執行ThreadLocalMap的get\set\remove,每個執行緒呼叫ThreadLocal的get\set\remove方法時,均在本執行緒物件的ThreadLocalMap中操作,所以實現了執行緒隔離。

ThreadLocalMap不是傳統意義上的map,它其實是一個環形陣列,資料元素entry是ThreadLocal變數的弱引用,而這個entry中有個變數為value是ThreadLocal變數的實際引用值,這樣看起來是一個key-value的形式。
Entry便是ThreadLocalMap裡定義的節點,它繼承了WeakReference類,定義了一個型別為Object的value,用於存放塞到ThreadLocal裡的值。

static class Entry extends WeakReference<java.lang.ThreadLocal<?>> {
    // 往ThreadLocal裡實際塞入的值
    Object value;

    Entry(java.lang.ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

為什麼用弱引用?因為如果這裡使用普通的key-value形式來定義儲存結構,實質上就會造成節點的生命週期與執行緒強繫結,只要執行緒沒有銷燬,那麼節點在GC分析中一直處於可達狀態,沒辦法被回收,而程式本身也無法判斷是否可以清理節點。弱引用是Java中四檔引用的第三檔,比軟引用更加弱一些,如果一個物件沒有強引用鏈可達,那麼一般活不過下一次GC。當某個ThreadLocal已經沒有強引用可達,則隨著它被垃圾回收,在ThreadLocalMap裡對應的Entry的鍵值會失效,這為ThreadLocalMap本身的垃圾清理提供了便利。

/**
 * 重新分配表大小的閾值,預設為0
 */
private int threshold; 

/**
 * Entry表,大小必須為2的冪
 */
private Entry[] table;

/**
 * 設定resize閾值以維持最壞2/3的裝載因子
 */
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

/**
 * 環形意義的下一個索引
 */
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

/**
 * 環形意義的上一個索引
 */
private static int prevIndex(int i, int len) {
    return ((i - 1 >= 0) ? i - 1 : len - 1);
}

ThreadLocal需要維持一個最壞2/3的負載因子,ThreadLocal有兩個方法用於得到上一個/下一個索引,注意這裡實際上是環形意義下的上一個與下一個。由於ThreadLocalMap使用線性探測法來解決雜湊衝突,所以實際上Entry[]陣列在程式邏輯上是作為一個環形存在的。hreadLocalMap維護了Entry環形陣列,陣列中元素Entry的邏輯上的key為指向該ThreadLocal物件的弱引用,value為程式碼中該執行緒往該ThreadLoacl變數實際塞入的值。

執行ThreadLocalMap的get\set方法和擴容時,當發現有entry的弱引用為null時(因為entry是ThreadLocal的弱引用,所以如果沒有其他強引用使用時就會被清理),會將無效的entry去掉,順便做一些啟發式的清理,所以大部分失效的entry一般都是可以清理的。但是這樣清理終究是有漏網之魚的,所以需要使用remove方法來避免這種情況的發生,remove方法直接在table中找key,如果找到了,把弱引用斷掉並做一次段清理,這樣可以避免無效entry的殘留。

關於記憶體洩漏

之所以有關於記憶體洩露的討論是因為在有執行緒複用如執行緒池的場景中,一個執行緒的壽命很長,大物件長期不被回收影響系統執行效率與安全。如果執行緒不會複用,用完即銷燬了也不會有ThreadLocal引發記憶體洩露的問題。
只有在get的時候才會第一次建立初始值,所以用完後使用remove,可以將這個entry去掉,下次get還會重新載入,這樣避免了記憶體洩漏。
Get和set方法執行時,偶爾發現無效entry後做段清理,可能清理不完全,導致可能存在大物件滯留。
如果在使用的ThreadLocal的過程中,顯式地進行remove是個很好的編碼習慣,這樣是不會引起記憶體洩漏。

為什麼Entry陣列大小必須為2的冪?

這和hash函式相關,基於ThreadLocal特有的hash函式,可以使entry在Entry陣列上均勻分佈,減少hash衝突。

Hash衝突的處理

ThreadLocalMap使用線性探測法來解決雜湊衝突,所以實際上Entry[]陣列在程式邏輯上是作為一個環形存在的。注意如果刪除一個節點後,需要將後面的節點重新hash。

參考:
Thread local原理梳理
ThreadLocal原始碼解讀

瞭解死鎖麼?怎麼防止死鎖?

死鎖的產生條件

資源互斥,每個資源一次只能被一個執行緒持有
資源不可搶奪
佔用並等待資源,涉及的執行緒當前至少持有一個資源並申請其他資源,而這些資源恰好被其他執行緒持有
迴圈等待資源

死鎖的排查

Jstack、arthas、jvisualvm 直接檢查

如何解決

  • 已經產生

重啟

  • 修復

鎖粗法,用一個粗粒度的鎖代替原來多個細粒度的鎖,這樣每個執行緒只申請一個鎖,避免了死鎖。但是這個方法會導致資源浪費。避免了“迴圈等待資源”的必要條件。
鎖排序法,相關執行緒使用全域性統一的順序申請鎖,消除“迴圈等待資源”的必要條件,比如一個物件方法要申請兩個鎖,先申請hashcode值小的那個鎖,然後再申請hashcode值大的那個鎖。“迴圈等待資源”實際為每個物件使用區域性順序去申請鎖,如果依賴全域性統一的順序,即可消除“迴圈等待資源”的必要條件。
ReentrantLock.tryLock(long, TimeUnit),為申請鎖這個操作指定一個超時時間,避免了“佔用並等待資源”的必要條件。
不用鎖。

參考:
死鎖問題整理
《Java多執行緒程式設計實戰指南》黃文海

Java安全的阻塞佇列有哪些?分別提供了什麼功能?

什麼是阻塞佇列

多執行緒環境中,透過佇列可以很容易實現資料共享,比如經典的“生產者”和“消費者”模型中,透過佇列可以很便利地實現兩者之間的資料共享。假設我們有若干生產者執行緒,另外又有若干個消費者執行緒。如果生產者執行緒需要把準備好的資料共享給消費者執行緒,利用佇列的方式來傳遞資料,就可以很方便地解決他們之間的資料共享問題。但如果生產者和消費者在某個時間段內,萬一發生資料處理速度不匹配的情況呢?理想情況下,如果生產者產出資料的速度大於消費者消費的速度,並且當生產出來的資料累積到一定程度的時候,那麼生產者必須暫停等待一下(阻塞生產者執行緒),以便等待消費者執行緒把累積的資料處理完畢,反之亦然。然而,在concurrent包釋出以前,在多執行緒環境下,我們每個程式設計師都必須去自己控制這些細節,尤其還要兼顧效率和執行緒安全,而這會給我們的程式帶來不小的複雜度。好在此時concurrent包出現了,而他也給我們帶來了強大的BlockingQueue。

阻塞佇列的方法

核心方法如下:

  • 放入資料

(1)offer(anObject):表示如果可能的話,將anObject加到BlockingQueue裡,即如果BlockingQueue可以容納,則返回true,否則返回false.(本方法不阻塞當前執行方法的執行緒);
(2)offer(E o, long timeout, TimeUnit unit):可以設定等待的時間,如果在指定的時間內,還不能往佇列中加入BlockingQueue,則返回失敗。
(3)put(anObject):把anObject加到BlockingQueue裡,如果BlockQueue沒有空間,則呼叫此方法的執行緒被阻斷直到BlockingQueue裡面有空間再繼續.

  • 獲取資料

(1)poll(time):取走BlockingQueue裡排在首位的物件,若不能立即取出,則可以等time引數規定的時間,取不到時返回null;
(2)poll(long timeout, TimeUnit unit):從BlockingQueue取出一個隊首的物件,如果在指定時間內,佇列一旦有資料可取,則立即返回佇列中的資料。否則知道時間超時還沒有資料可取,返回失敗。
(3)take():取走BlockingQueue裡排在首位的物件,若BlockingQueue為空,阻斷進入等待狀態直到BlockingQueue有新的資料被加入;
(4)drainTo():一次性從BlockingQueue獲取所有可用的資料物件(還可以指定獲取資料的個數),透過該方法,可以提升獲取資料效率;不需要多次分批加鎖或釋放鎖。

常見阻塞佇列

  • ArrayBlockingQueue

基於陣列的阻塞佇列實現,在ArrayBlockingQueue內部,維護了一個定長陣列,以便快取佇列中的資料物件,這是一個常用的阻塞佇列,除了一個定長陣列外,ArrayBlockingQueue內部還儲存著兩個整形變數,分別標識著佇列的頭部和尾部在陣列中的位置。
ArrayBlockingQueue在生產者放入資料和消費者獲取資料,都是共用同一個鎖物件,由此也意味著兩者無法真正並行執行,這點尤其不同於LinkedBlockingQueue,可能導致鎖的高爭用,進而導致較多的上下文切換;
ArrayBlockingQueue和LinkedBlockingQueue間還有一個明顯的不同之處在於,前者在插入或刪除元素時不會產生或銷燬任何額外的物件例項,而後者則會生成一個額外的Node物件。ArrayBlockingQueue不會增加GC負擔,這在長時間內需要高效併發地處理大批次資料的系統中,其對於GC的影響還是存在一定的區別。而在建立ArrayBlockingQueue時,我們還可以控制物件的內部鎖是否採用公平鎖,預設採用非公平鎖。

  • LinkedBlockingQueue

基於連結串列的阻塞佇列,同ArrayListBlockingQueue類似,其內部也維持著一個資料緩衝佇列(該佇列由一個連結串列構成),當生產者往佇列中放入一個資料時,佇列會從生產者手中獲取資料,並快取在佇列內部,而生產者立即返回;只有當佇列緩衝區達到最大值快取容量時(LinkedBlockingQueue可以透過建構函式指定該值),才會阻塞生產者佇列,直到消費者從佇列中消費掉一份資料,生產者執行緒會被喚醒,反之對於消費者這端的處理也基於同樣的原理。而LinkedBlockingQueue之所以能夠高效的處理併發資料,還因為其對於生產者端和消費者端分別採用了獨立的鎖來控制資料同步,這也意味著在高併發的情況下生產者和消費者可以並行地操作佇列中的資料,以此來提高整個佇列的併發效能。
作為開發者,我們需要注意的是,如果構造一個LinkedBlockingQueue物件,而沒有指定其容量大小,LinkedBlockingQueue會預設一個類似無限大小的容量(Integer.MAX_VALUE),這樣的話,如果生產者的速度一旦大於消費者的速度,也許還沒有等到佇列滿阻塞產生,系統記憶體就有可能已被消耗殆盡了。

  • SynchronousQueue

一種無緩衝的等待佇列,類似於無中介的直接交易,有點像原始社會中的生產者和消費者,生產者拿著產品去集市銷售給產品的最終消費者,而消費者必須親自去集市找到所要商品的直接生產者,如果一方沒有找到合適的目標,那麼對不起,大家都在集市等待。相對於有緩衝的BlockingQueue來說,少了一箇中間經銷商的環節(緩衝區),如果有經銷商,生產者直接把產品批發給經銷商,而無需在意經銷商最終會將這些產品賣給那些消費者,由於經銷商可以庫存一部分商品,因此相對於直接交易模式,總體來說採用中間經銷商的模式會吞吐量高一些(可以批次買賣);但另一方面,又因為經銷商的引入,使得產品從生產者到消費者中間增加了額外的交易環節,單個產品的及時響應效能可能會降低。
宣告一個SynchronousQueue有兩種不同的方式,它們之間有著不太一樣的行為。公平模式和非公平模式的區別:
如果採用公平模式:SynchronousQueue會採用公平鎖,並配合一個FIFO佇列來阻塞多餘的生產者和消費者,從而體系整體的公平策略;
但如果是非公平模式(SynchronousQueue預設):SynchronousQueue採用非公平鎖,同時配合一個LIFO佇列來管理多餘的生產者和消費者,而後一種模式,如果生產者和消費者的處理速度有差距,則很容易出現飢渴的情況,即可能有某些生產者或者是消費者的資料永遠都得不到處理。

  • DelayQueue

DelayQueue中的元素只有當其指定的延遲時間到了,才能夠從佇列中獲取到該元素。DelayQueue是一個沒有大小限制的佇列,因此往佇列中插入資料的操作(生產者)永遠不會被阻塞,而只有獲取資料的操作(消費者)才會被阻塞。
使用場景:DelayQueue使用場景較少,但都相當巧妙,常見的例子比如使用一個DelayQueue來管理一個超時未響應的連線佇列。

  • PriorityBlockingQueue

基於優先順序的阻塞佇列(優先順序的判斷透過建構函式傳入的Compator物件來決定),但需要注意的是PriorityBlockingQueue並不會阻塞資料生產者,而只會在沒有可消費的資料時,阻塞資料的消費者。因此使用的時候要特別注意,生產者生產資料的速度絕對不能快於消費者消費資料的速度,否則時間一長,會最終耗盡所有的可用堆記憶體空間。在實現PriorityBlockingQueue時,內部控制執行緒同步的鎖採用的是公平鎖。

  • 對比

ArrayBlockingQueue和LinkedBlockingQueue是兩個最普通也是最常用的阻塞佇列。
是否有界:
ArrayBlockingQueue是有界佇列, LinkedBlockingQueue既可以有界也可以無界
排程:
LinkedBlockingQueue僅支援非公平排程
ArrayBlockingQueue和SynchronousQueue支援公平和非公平排程
適用場景:
LinkedBlockingQueue適合生產者和消費者執行緒併發程度較大的場景;
ArrayBlockingQueue適合生產者和消費者執行緒併發程度較低的場景;
SynchronousQueue適合生產者和消費者處理能力相差不大的場景。

參考:
阻塞佇列、執行緒池、非同步
BlockingQueue(阻塞佇列)詳解

執行緒池

執行緒工廠

執行緒工廠可以統一執行緒生成的樣式,增加執行緒異常處理物件、定製執行緒名稱等。

為什麼用執行緒池

執行緒啟動會產生相應的執行緒排程開銷,執行緒的銷燬也有開銷,透過執行緒池來使用執行緒更加有效,避免不必要的反覆建立執行緒的開銷,同時可以方便實現任務提交與任務排程執行的功能分離。
執行緒池預先建立一定數目的工作者執行緒,客戶端不需要向執行緒池借用執行緒而是將其需要執行的任務作為一個物件提交給執行緒池,執行緒池可能將這些任務快取在佇列之中,而執行緒池內部的各個工作者執行緒則不斷取出任務並執行之。因此,執行緒池可以看做基於生產者—消費者模式的一種服務。

基本引數和原理

ThreadPoolExecutor類是一個常用的執行緒池,客戶端可以呼叫ThreadPoolExecutor.submit方法提交任務。Task如果是一個Runnable例項,沒有返回結果,Task如果是Callable例項,可以由返回結果。

Public Future<?> submit(Runnable task); 
Public Future< T > submit(Callable<T> task);

透過submit向執行緒池提交Runnable 或 Callable 任務後,任務都會被轉化為FutureTask然後提交給execute方法。

關於執行緒池執行緒數量有三個概念,當前執行緒池大小表示執行緒池中實際工作者執行緒的數量;最大執行緒池大小表示執行緒池中允許存在的工作者執行緒的數量上限;核心執行緒大小表示一個不大於最大執行緒池大小的工作者執行緒數量上限。
三個執行緒池執行緒概念的關係如下:

當前執行緒池大小<=核心執行緒大小<=最大執行緒池大小
或者
核心執行緒大小<=當前執行緒池大小<=最大執行緒池大小

ThreadPoolExecutor最詳盡的建構函式如下(還有很多簡化的建構函式,部分入參可以採用預設值):

public ThreadPoolExecutor(int corePoolSize,
                      int maximumPoolSize,
                      long keepAliveTime,
                      TimeUnit unit,
                      BlockingQueue<Runnable> workQueue,
                      ThreadFactory threadFactory,
                      RejectedExecutionHandler handler) {
  //...
}

corePoolSize:執行緒池核心大小
maximumPoolSize:最大執行緒池大小
keepAliveTime和unit指定執行緒池中空閒執行緒的最大存活時間
workQueue: 稱為工作佇列的阻塞佇列
threadFactory:指定建立工作者執行緒的執行緒工廠
handler:執行緒拒絕策略

初始狀態下,客戶端每提交一個任務執行緒池就建立一個工作者執行緒來處理任務。隨著任務的提交,當前執行緒池大小相應增加,在當前執行緒池大小達到核心執行緒池大小是,新來的任務被存入到工作佇列之中。這些快取的任務由執行緒池種所有的工作者執行緒負責取出進行執行。執行緒池將任務放入工作佇列的時候呼叫的是BlockingQueue的非阻塞方法offer(E e),所以當工作佇列滿的時候不會使提交任務的客戶端執行緒暫停。當工作佇列滿的時候,執行緒池會繼續建立新的工作者執行緒,直到當前執行緒池大小達到最大執行緒池大小。

在當前執行緒池大小超過核心執行緒池大小的時候,超過核心執行緒池大小部分的工作者執行緒空閒(即工作者佇列中沒有待處理的任務)時間達到了keepAliveTime所指定的時間後就會被清理掉,即這些工作者執行緒會自動終止並被從執行緒池中被移除,需要謹慎設定,否則造成執行緒反覆建立。

執行緒池是透過threadFactory的newThread方法來建立工作者執行緒的。如果在建立執行緒池的時候沒有指定執行緒工廠(呼叫了ThreadPoolExecutor的其他構造器),那麼ThreadPoolExecutor會使用Executord.defaultThreadFactory()所返回的預設執行緒工廠。

當執行緒池飽和的時候,即工作佇列滿且當前執行緒池大小達到最大執行緒池大小的情況下,客戶端試圖提交的任務就會被拒絕。RejectExecutionHandler介面用於封裝被拒絕的任務的處理策略,ThreadPoolExecutor提供幾個現成的RejectExecutionHandler的實現類,其中ThreadPoolExecutor.AbortPolicy是ThreadPoolExecutor使用的預設RejectExecutionHandler。如果預設的AbortPolicy無法滿足可以優先考慮ThreadPoolExecutor提供的其他RejectExecutionHandler,其次考慮自行實現RejectExecutionHandler。
以下為拒絕策略
AbortPolicy:直接丟擲異常,預設策略;
CallerRunsPolicy:用呼叫者所在的執行緒來執行任務;
DiscardOldestPolicy:丟棄阻塞佇列中靠最前的任務,並執行當前任務;
DiscardPolicy:直接丟棄任務;

執行緒池怎麼設計核心執行緒數和最大執行緒數

暫時沒有了解到最合理的執行緒設定原則。對於cpu核數為N的機器一般原則如下:cpu密集型程式可以設定核心執行緒為N+1,IO密集型程式可以設定核心執行緒為N*2。也可以參照《Java多執行緒程式設計實戰指南》中的一些公式來計算核心執行緒數,可以設定最大執行緒數為核心執行緒數的2倍。

對於快速響應使用者請求的需求,一般設定緩衝佇列為同步佇列,即不快取任務,儘可能調高核心執行緒和最大執行緒,實現快速響應。對於批次處理的需求,一般會設定一定容量的緩衝佇列,過多的執行緒可能導致頻繁上下文切換,影響程式執行的吞吐量。

美團技術部落格中也提到了傳統執行緒池執行緒設定的困難,所以開發了一個執行緒池監控配置平臺,用來實時監控執行緒池的負載,同時提供了實時變更執行緒池引數的介面。

拒絕策略怎麼選擇?

沒有最佳實踐,只有更適合自身業務的策略。現在我們聊聊各種策略的適用場景。

AbortPolicy 中止策略,執行緒池預設的拒絕策略,也是我們最常用的拒絕策略。當系統執行緒池滿載的時候,可以透過異常的形式告知使用方,交由使用方自行處理。一般出現此異常時,我們可以提示使用者稍後再試,或者我們把未執行的任務記錄下來,等到適當時機再次執行。

DiscardPolicy 丟棄策略,一般我們都不會選擇它,因為它直接就把任務丟棄掉了,我們毫無感知。如果任務不重要,丟棄掉也沒有沒關係,就可以使用它。還有一種情況,我們也可以使用它,我們事後知道哪些任務沒有執行,說明任務是被丟棄了,需要重新執行。

DiscardOldestPolicy 丟棄最老任務策略,如果有這種業務場景:需要淘汰等待時間最長任務,就可以適用該策略。

CallerRunsPolicy 呼叫者執行策略。為了保證所有任務都能執行,可以使用該策略。但是它也隱藏著非常大的風險。

比如,我們在SpringWeb專案中,有一個web請求過來需要處理一個非同步任務,正常情況下,我們是交由執行緒池來處理任務的,但是由於執行緒池滿了,我們使用了CallerRunsPolicy策略,該非同步任務就由web請求執行緒來處理。
看起來好像沒有什麼問題,但實際情況是,web請求已經使用了tomcat的執行緒池中的執行緒來處理的了,非同步任務也交由該執行緒處理,此時的執行緒資源就被此次的web請求長久佔用了。如果這樣的web請求有很多,Tomcat的可用執行緒將會變得很少,這導致整個伺服器的qps大大降低,甚至系統奔潰。
所以使用CallerRunsPolicy策略時,要站在更高的角度來評估,這會不會給系統帶來其他問題!

優雅關閉執行緒池

用shutdown + awaitTermination關閉執行緒池,如果檢測執行緒池在指定時間範圍內沒有關閉,可以使用shutdownNow()來主動中斷所有子執行緒。

    public static <T> void executeCallableCommand(Callable<T> callable) {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5,10,15,
                TimeUnit.SECONDS, new ArrayBlockingQueue<>(5));
        Future<T> submit = threadPoolExecutor.submit(callable);
        threadPoolExecutor.shutdown();
        try {
            if(!threadPoolExecutor.awaitTermination(60, TimeUnit.SECONDS)){
                // 超時的時候向執行緒池中所有的執行緒發出中斷(interrupted)。
                threadPoolExecutor.shutdownNow();
            }
            System.out.println("AwaitTermination Finished");
        } catch (InterruptedException ignore) {
            threadPoolExecutor.shutdownNow();
        }
    }

參考:
Java執行緒池的正確關閉方法,awaitTermination還不夠
Java執行緒池實現原理及其在美團業務中的實踐
執行緒池拒絕策略最佳實踐
《Java多執行緒程式設計實戰指南》黃文海

ConcurrenthashMap的put、get方法

jdk1.7的ConcurrenthashMap

jdk1.7的ConcurrenthashMap由多個Segment組合而成,Segment相當於一個HashMap物件。同HashMap一樣,Segment包含一個HashEntry陣列,陣列中的每一個HashEntry既是一個鍵值對,也是一個連結串列的頭節點。可以說,ConcurrentHashMap是一個二級雜湊表。在一個總的雜湊表下面,有若干個子雜湊表。

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

Segment繼承了ReentrantLock,所以在put中加鎖的時候是以Segment為單元進行加鎖的。

  • put操作

1、透過key首先定位到Segment,然後在Segment中進行put;
2、加鎖操作(首先tryLock,如果沒成功就自旋tryLock,如果還獲取不到就lock阻塞獲取);
3、定位到Segment中特定的位置的HashEntry;
4、遍歷該HashEntry,如果不為空則判斷傳入的key和當前遍歷的key是否相等,相等則覆蓋舊的value;
5、為空則需要新建一個HashEntry並加入到Segment中,同時會先判斷是否需要擴容;
6、釋放鎖;

  • get操作

1、Key透過Hash之後定位到具體的Segment;
2、再透過一次Hash定位到具體的元素上;
3、遍歷HashEntry,如果找到則返回對應的value,否則返回null。由於HashEntry中的value屬性是用volatile關鍵詞修飾的,保證了記憶體可見性,所以每次獲取時都是最新值。

jdk1.8的ConcurrenthashMap

jdk1.8的ConcurrenthashMap的結構變得和jdk1.8的hashMap類似,取消了Segment結構,hash定位直接定位到某個Node上(1.8的元素由HashEntry改為了Node),在加鎖的時候直接以Node為monitor加鎖,加鎖的粒度更細,且也有了長連結串列轉紅黑樹的最佳化。

  • put操作

1、求出hash值
2、是否已經初始化陣列,如果沒有初始化,直接初始化
3、是否該位置為空,空的話直接cas設定第一個節點
4、判斷是否正在擴容,如果正在擴容,就加入一起進行擴容
5、如果不為空的話,鎖住頭結點,開始進行插入操作,如果是連結串列,就遍歷是否相同,如果是紅黑樹就直接新增
6、新增完成之後,判斷是否需要擴容,如果超過閾值就擴容。

  • get操作

1、獲取hash值
2、如果是第一個節點,直接返回
3、如果不是,判斷是否正在擴容或者是紅黑樹,那就呼叫find方法
4、如果不是,那就是連結串列結構,直接while尋找。同樣,node中的val是volatile,我們每次取出來的是最新的值,這裡使用的是volatile的可見性。

  • jdk1.8的ConcurrentHashMap是怎麼保證執行緒安全的?

1、CAS運算元據:sizectl的修改,擴容數值等修改使用cas保證資料修改的原子性。
2、synchronized互斥鎖:put和擴容過程,使用synchronized保證執行緒只有一個操作,保證執行緒安全。
3、volatile修飾變數:table、sizeCtl等變數用volatile修飾,保證可見性

參考:
java 面試--concurrentHashMap
深入淺出ConcurrentHashMap詳解

簡述一下JMM,as-if-serial語義、happens-before模型?

JMM

《Java虛擬機器規範》中曾試圖定義一種“Java記憶體模型”(Java Memory Model,JMM)來遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。在此之前,主流程式語言(如C和C++等)直接使用物理硬體和作業系統的記憶體模型。因此,由於不同平臺上記憶體模型的差異,有可能導致程式在一套平臺上併發完全正常,而在另外一套平臺上併發訪問卻經常出錯,所以在某些場景下必須針對不同的平臺來編寫程式。

happens-before模型

Happens-before模型描述了兩個操作的執行順序,happens-before模型保障JMM中的可見性和有序性問題。
從JDK 5開始,Java使用新的JSR-133記憶體模型,JSR-133使用happens-before的概念來闡述操作之間的記憶體可見性:在JMM中,如果一個操作執行的結果需要對另一個操作可見(兩個操作既可以是在一個執行緒之內,也可以是在不同執行緒之間),那麼這兩個操作之間必須要存在happens-before關係:
程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。
監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
執行緒啟動規則:呼叫一個執行緒的start方法happens-before被啟動的這個執行緒中的任意一個動作。
傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。

as-if-serial語義

java單執行緒程式執行時並不是完全按照編碼的程式碼順序執行的,中間可能涉及到很多重排序等調整,但是最終的執行結果是和按照編碼的程式碼順序執行的一樣,所以稱為貌似序列語義。貌似序列語義只是從單執行緒程式的角度保證重排序後的執行結果不影響程式的正確性,它並不保證多執行緒環境下程式的正確性。

參考:
《Java多執行緒程式設計實戰指南》黃文海
java 面試--多執行緒基本概念

快取的一致性協議是什麼?

由於cpu的執行速度遠遠大於IO的速度,為了減少和IO的互動,增高效率,cpu內部會有快取記憶體(cache)。當程式執行的時候,快取記憶體中會從主存中儲存一份副本資料。每次讀寫都在快取記憶體中操作,這個在單執行緒下是沒有問題的,但是多執行緒下會導致資料不一致的情況(其他執行緒中的資料沒有同步該執行緒修改的資料)。為了解決這個問題(其實就是上圖中可見性描述),通常有兩種方案進行解決:鎖和快取一致性協議。由於在匯流排上加鎖的機制導致效率低下,所以快取一致性協議就變得關鍵了。具體原理是:當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。

參考:
java 面試--多執行緒基本概念

相關文章