Java 併發面試題解

OkidoGreen發表於2020-04-05

01

前言

大家好,我是 Eric,一名有多年網際網路經驗的專業技術從業人員。我最初在一家國際頂尖投行工作時,接觸過各個國家形形色色的開發人員。

我發現,不論是哪個國家,什麼背景的 Java 開發者,都對自己寫的併發程式相當自信,但也會在出問題時表現得很詫異甚至一籌莫展。

可見,Java 併發程式設計顯然不是一件能速成的能力,基礎搭得越好,越全面,在實踐中才會有更深刻的理解。

因此,大家不難發現 Java 併發問題一直是各個大廠面試的重點之一。我在平時的面試中,也發現很多候選人對一些基本的併發概念表示沒聽過,或原理不理解,可能知道一些卻又講不清楚,最終導致面試失敗。

本文會結合我實際中接觸到的一些面試題,重點來聊一聊 Java 併發中的相關知識點。

我會通過面試題一問一答的方式來闡述,因為我覺得這是最容易理解和引發思考的方式,讀者不妨在看答案之前先花 30 秒想一想,然後我們才能更好地一起探討。

另外,鑑於時間和精力極其有限,雖然本文我考察了不少專業技術書籍和部落格,但是錯誤在所難免,歡迎指正,期待共同進步。

02

Synchronized 相關問題

問題一:Synchronized 用過嗎,其原理是什麼?

這是一道 Java 面試中幾乎百分百會問到的問題,因為沒有任何寫過併發程式的開發者會沒聽說或者沒接觸過 Synchronized。

Synchronized 是由 JVM 實現的一種實現互斥同步的一種方式,如果你檢視被 Synchronized 修飾過的程式塊編譯後的位元組碼,會發現,被 Synchronized 修飾過的程式塊,在編譯前後被編譯器生成了 monitorenter 和 monitorexit 兩個位元組碼指令。

這兩個指令是什麼意思呢?

在虛擬機器執行到 monitorenter 指令時,首先要嘗試獲取物件的鎖:

如果這個物件沒有鎖定,或者當前執行緒已經擁有了這個物件的鎖,把鎖的計數器 +1;當執行 monitorexit 指令時將鎖計數器 -1;當計數器為 0 時,鎖就被釋放了。

如果獲取物件失敗了,那當前執行緒就要阻塞等待,直到物件鎖被另外一個執行緒釋放為止。

Java 中 Synchronize 通過在物件頭設定標記,達到了獲取鎖和釋放鎖的目的。

問題二:你剛才提到獲取物件的鎖,這個“鎖”到底是什麼?如何確定物件的鎖?

“鎖”的本質其實是 monitorenter 和 monitorexit 位元組碼指令的一個 Reference 型別的引數,即要鎖定和解鎖的物件。我們知道,使用 Synchronized 可以修飾不同的物件,因此,對應的物件鎖可以這麼確定。

若 Synchronized 修飾的方法為非靜態方法,表示此方法對應的物件為鎖物件;

若 Synchronized 修飾的方法為靜態方法,則表示此方法對應的類物件為鎖物件。

  1. 如果 Synchronized 明確指定了鎖物件,比如 Synchronized(變數名)、Synchronized(this) 等,說明加解鎖物件為該物件。

  2. 如果沒有明確指定:

注意,當一個物件被鎖住時,物件裡面所有用 Synchronized 修飾的方法都將產生堵塞,而物件裡非 Synchronized 修飾的方法可正常被呼叫,不受鎖影響。

問題三:什麼是可重入性,為什麼說 Synchronized 是可重入鎖?

可重入性是鎖的一個基本要求,是為了解決自己鎖死自己的情況。

比如下面的虛擬碼,一個類中的同步方法呼叫另一個同步方法,假如 Synchronized 不支援重入,進入 method2 方法時當前執行緒獲得鎖,method2 方法裡面執行 method1 時當前執行緒又要去嘗試獲取鎖,這時如果不支援重入,它就要等釋放,把自己阻塞,導致自己鎖死自己。

 

對 Synchronized 來說,可重入性是顯而易見的,剛才提到,在執行 monitorenter 指令時,如果這個物件沒有鎖定,或者當前執行緒已經擁有了這個物件的鎖(而不是已擁有了鎖則不能繼續獲取),就把鎖的計數器 +1,其實本質上就通過這種方式實現了可重入性。

問題四:JVM 對 Java 的原生鎖做了哪些優化?

在 Java 6 之前,Monitor 的實現完全依賴底層作業系統的互斥鎖來實現,也就是我們剛才在問題二中所闡述的獲取/釋放鎖的邏輯。

由於 Java 層面的執行緒與作業系統的原生執行緒有對映關係,如果要將一個執行緒進行阻塞或喚起都需要作業系統的協助,這就需要從使用者態切換到核心態來執行,這種切換代價十分昂貴,很耗處理器時間,現代 JDK 中做了大量的優化。

一種優化是使用自旋鎖,即在把執行緒進行阻塞操作之前先讓執行緒自旋等待一段時間,可能在等待期間其他執行緒已經解鎖,這時就無需再讓執行緒執行阻塞操作,避免了使用者態到核心態的切換。

現代 JDK 中還提供了三種不同的 Monitor 實現,也就是三種不同的鎖:

  • 偏向鎖(Biased Locking)

  • 輕量級鎖

  • 重量級鎖

這三種鎖使得 JDK 得以優化 Synchronized 的執行,當 JVM 檢測到不同的競爭狀況時,會自動切換到適合的鎖實現,這就是鎖的升級、降級。

  • 當沒有競爭出現時,預設會使用偏向鎖。

JVM 會利用 CAS 操作,在物件頭上的 Mark Word 部分設定執行緒 ID,以表示這個物件偏向於當前執行緒,所以並不涉及真正的互斥鎖,因為在很多應用場景中,大部分物件生命週期中最多會被一個執行緒鎖定,使用偏斜鎖可以降低無競爭開銷。

  • 如果有另一執行緒試圖鎖定某個被偏斜過的物件,JVM 就撤銷偏斜鎖,切換到輕量級鎖實現。

  • 輕量級鎖依賴 CAS 操作 Mark Word 來試圖獲取鎖,如果重試成功,就使用普通的輕量級鎖;否則,進一步升級為重量級鎖。

問題五:為什麼說 Synchronized 是非公平鎖?

非公平主要表現在獲取鎖的行為上,並非是按照申請鎖的時間前後給等待執行緒分配鎖的,每當鎖被釋放後,任何一個執行緒都有機會競爭到鎖,這樣做的目的是為了提高執行效能,缺點是可能會產生執行緒飢餓現象。

問題六:什麼是鎖消除和鎖粗化?

  • 鎖消除:指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但被檢測到不可能存在共享資料競爭的鎖進行消除。主要根據逃逸分析。

程式設計師怎麼會在明知道不存在資料競爭的情況下使用同步呢?很多不是程式設計師自己加入的。

  • 鎖粗化:原則上,同步塊的作用範圍要儘量小。但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作在迴圈體內,頻繁地進行互斥同步操作也會導致不必要的效能損耗。

鎖粗化就是增大鎖的作用域。

問題七:為什麼說 Synchronized 是一個悲觀鎖?樂觀鎖的實現原理又是什麼?什麼是 CAS,它有什麼特性?

Synchronized 顯然是一個悲觀鎖,因為它的併發策略是悲觀的:

不管是否會產生競爭,任何的資料操作都必須要加鎖、使用者態核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要被喚醒等操作。

隨著硬體指令集的發展,我們可以使用基於衝突檢測的樂觀併發策略。先進行操作,如果沒有其他執行緒徵用資料,那操作就成功了;

如果共享資料有徵用,產生了衝突,那就再進行其他的補償措施。這種樂觀的併發策略的許多實現不需要執行緒掛起,所以被稱為非阻塞同步。

樂觀鎖的核心演算法是 CAS(Compareand Swap,比較並交換),它涉及到三個運算元:記憶體值、預期值、新值。當且僅當預期值和記憶體值相等時才將記憶體值修改為新值。

這樣處理的邏輯是,首先檢查某塊記憶體的值是否跟之前我讀取時的一樣,如不一樣則表示期間此記憶體值已經被別的執行緒更改過,捨棄本次操作,否則說明期間沒有其他執行緒對此記憶體值操作,可以把新值設定給此塊記憶體。

CAS 具有原子性,它的原子性由 CPU 硬體指令實現保證,即使用 JNI 呼叫 Native 方法呼叫由 C++ 編寫的硬體級別指令,JDK 中提供了 Unsafe 類執行這些操作。

問題八:樂觀鎖一定就是好的嗎?

樂觀鎖避免了悲觀鎖獨佔物件的現象,同時也提高了併發效能,但它也有缺點:

  1. 樂觀鎖只能保證一個共享變數的原子操作。如果多一個或幾個變數,樂觀鎖將變得力不從心,但互斥鎖能輕易解決,不管物件數量多少及物件顆粒度大小。

  2. 長時間自旋可能導致開銷大。假如 CAS 長時間不成功而一直自旋,會給 CPU 帶來很大的開銷。

  3. ABA 問題。CAS 的核心思想是通過比對記憶體值與預期值是否一樣而判斷記憶體值是否被改過,但這個判斷邏輯不嚴謹,假如記憶體值原來是 A,後來被一條執行緒改為 B,最後又被改成了 A,則 CAS 認為此記憶體值並沒有發生改變,但實際上是有被其他執行緒改過的,這種情況對依賴過程值的情景的運算結果影響很大。解決的思路是引入版本號,每次變數更新都把版本號加一。

03

可重入鎖 ReentrantLock 及其他顯式鎖相關問題

問題一:跟 Synchronized 相比,可重入鎖 ReentrantLock 其實現原理有什麼不同?

其實,鎖的實現原理基本是為了達到一個目的:

讓所有的執行緒都能看到某種標記。

Synchronized 通過在物件頭中設定標記實現了這一目的,是一種 JVM 原生的鎖實現方式,而 ReentrantLock 以及所有的基於 Lock 介面的實現類,都是通過用一個 volitile 修飾的 int 型變數,並保證每個執行緒都能擁有對該 int 的可見性和原子修改,其本質是基於所謂的 AQS 框架。

問題二:那麼請談談 AQS 框架是怎麼回事兒?

AQS(AbstractQueuedSynchronizer 類)是一個用來構建鎖和同步器的框架,各種 Lock 包中的鎖(常用的有 ReentrantLock、ReadWriteLock),以及其他如 Semaphore、CountDownLatch,甚至是早期的 FutureTask 等,都是基於 AQS 來構建。

  1. AQS 在內部定義了一個 volatile int state 變數,表示同步狀態:當執行緒呼叫 lock 方法時 ,如果 state=0,說明沒有任何執行緒佔有共享資源的鎖,可以獲得鎖並將 state=1;如果 state=1,則說明有執行緒目前正在使用共享變數,其他執行緒必須加入同步佇列進行等待。

  2. AQS 通過 Node 內部類構成的一個雙向連結串列結構的同步佇列,來完成執行緒獲取鎖的排隊工作,當有執行緒獲取鎖失敗後,就被新增到佇列末尾。

    • Node 類是對要訪問同步程式碼的執行緒的封裝,包含了執行緒本身及其狀態叫 waitStatus(有五種不同 取值,分別表示是否被阻塞,是否等待喚醒,是否已經被取消等),每個 Node 結點關聯其 prev 結點和 next 結點,方便執行緒釋放鎖後快速喚醒下一個在等待的執行緒,是一個 FIFO 的過程。

    • Node 類有兩個常量,SHARED 和 EXCLUSIVE,分別代表共享模式和獨佔模式。所謂共享模式是一個鎖允許多條執行緒同時操作(訊號量 Semaphore 就是基於 AQS 的共享模式實現的),獨佔模式是同一個時間段只能有一個執行緒對共享資源進行操作,多餘的請求執行緒需要排隊等待(如 ReentranLock)。

  3. AQS 通過內部類 ConditionObject 構建等待佇列(可有多個),當 Condition 呼叫 wait() 方法後,執行緒將會加入等待佇列中,而當 Condition 呼叫 signal() 方法後,執行緒將從等待佇列轉移動同步佇列中進行鎖競爭。

  4. AQS 和 Condition 各自維護了不同的佇列,在使用 Lock 和 Condition 的時候,其實就是兩個佇列的互相移動。

問題三:請儘可能詳盡地對比下 Synchronized 和 ReentrantLock 的異同。

ReentrantLock 是 Lock 的實現類,是一個互斥的同步鎖。

從功能角度,ReentrantLock 比 Synchronized 的同步操作更精細(因為可以像普通物件一樣使用),甚至實現 Synchronized 沒有的高階功能,如:

  • 等待可中斷:當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,對處理執行時間非常長的同步塊很有用。

  • 帶超時的獲取鎖嘗試:在指定的時間範圍內獲取鎖,如果時間到了仍然無法獲取則返回。

  • 可以判斷是否有執行緒在排隊等待獲取鎖。

  • 可以響應中斷請求:與 Synchronized 不同,當獲取到鎖的執行緒被中斷時,能夠響應中斷,中斷異常將會被丟擲,同時鎖會被釋放。

  • 可以實現公平鎖。

從鎖釋放角度,Synchronized 在 JVM 層面上實現的,不但可以通過一些監控工具監控 Synchronized 的鎖定,而且在程式碼執行出現異常時,JVM 會自動釋放鎖定;但是使用 Lock 則不行,Lock 是通過程式碼實現的,要保證鎖定一定會被釋放,就必須將 unLock() 放到 finally{} 中。

從效能角度,Synchronized 早期實現比較低效,對比 ReentrantLock,大多數場景效能都相差較大。

但是在 Java 6 中對其進行了非常多的改進,在競爭不激烈時,Synchronized 的效能要優於 ReetrantLock;在高競爭情況下,Synchronized 的效能會下降幾十倍,但是 ReetrantLock 的效能能維持常態。

問題四:ReentrantLock 是如何實現可重入性的?

ReentrantLock 內部自定義了同步器 Sync(Sync 既實現了 AQS,又實現了 AOS,而 AOS 提供了一種互斥鎖持有的方式),其實就是加鎖的時候通過 CAS 演算法,將執行緒物件放到一個雙向連結串列中,每次獲取鎖的時候,看下當前維護的那個執行緒 ID 和當前請求的執行緒 ID 是否一樣,一樣就可重入了。

問題五:除了 ReetrantLock,你還接觸過 JUC 中的哪些併發工具?

通常所說的併發包(JUC)也就是 java.util.concurrent 及其子包,集中了 Java 併發的各種基礎工具類,具體主要包括幾個方面:

  • 提供了 CountDownLatch、CyclicBarrier、Semaphore 等,比 Synchronized 更加高階,可以實現更加豐富多執行緒操作的同步結構。

  • 提供了 ConcurrentHashMap、有序的 ConcunrrentSkipListMap,或者通過類似快照機制實現執行緒安全的動態陣列 CopyOnWriteArrayList 等,各種執行緒安全的容器。

  • 提供了 ArrayBlockingQueue、SynchorousQueue 或針對特定場景的 PriorityBlockingQueue 等,各種併發佇列實現。

  • 強大的 Executor 框架,可以建立各種不同型別的執行緒池,排程任務執行等。

問題六:請談談 ReadWriteLock 和 StampedLock。

雖然 ReentrantLock 和 Synchronized 簡單實用,但是行為上有一定侷限性,要麼不佔,要麼獨佔。實際應用場景中,有時候不需要大量競爭的寫操作,而是以併發讀取為主,為了進一步優化併發操作的粒度,Java 提供了讀寫鎖。

讀寫鎖基於的原理是多個讀操作不需要互斥,如果讀鎖試圖鎖定時,寫鎖是被某個執行緒持有,讀鎖將無法獲得,而只好等待對方操作結束,這樣就可以自動保證不會讀取到有爭議的資料。

ReadWriteLock 代表了一對鎖,下面是一個基於讀寫鎖實現的資料結構,當資料量較大,併發讀多、併發寫少的時候,能夠比純同步版本凸顯出優勢:

 

讀寫鎖看起來比 Synchronized 的粒度似乎細一些,但在實際應用中,其表現也並不盡如人意,主要還是因為相對比較大的開銷。

所以,JDK 在後期引入了 StampedLock,在提供類似讀寫鎖的同時,還支援優化讀模式。優化讀基於假設,大多數情況下讀操作並不會和寫操作衝突,其邏輯是先試著修改,然後通過 validate 方法確認是否進入了寫模式,如果沒有進入,就成功避免了開銷;如果進入,則嘗試獲取讀鎖。

 

問題七:如何讓 Java 的執行緒彼此同步?你瞭解過哪些同步器?請分別介紹下。

JUC 中的同步器三個主要的成員:CountDownLatch、CyclicBarrier 和 Semaphore,通過它們可以方便地實現很多執行緒之間協作的功能。

CountDownLatch 叫倒計數,允許一個或多個執行緒等待某些操作完成。看幾個場景:

  • 跑步比賽,裁判需要等到所有的運動員(“其他執行緒”)都跑到終點(達到目標),才能去算排名和頒獎。

  • 模擬併發,我需要啟動 100 個執行緒去同時訪問某一個地址,我希望它們能同時併發,而不是一個一個的去執行。

用法:CountDownLatch 構造方法指明計數數量,被等待執行緒呼叫 countDown 將計數器減 1,等待執行緒使用 await 進行執行緒等待。一個簡單的例子:

 

CyclicBarrier 叫迴圈柵欄,它實現讓一組執行緒等待至某個狀態之後再全部同時執行,而且當所有等待執行緒被釋放後,CyclicBarrier 可以被重複使用。CyclicBarrier 的典型應用場景是用來等待併發執行緒結束。

CyclicBarrier 的主要方法是 await(),await() 每被呼叫一次,計數便會減少 1,並阻塞住當前執行緒。當計數減至 0 時,阻塞解除,所有在此 CyclicBarrier 上面阻塞的執行緒開始執行。

在這之後,如果再次呼叫 await(),計數就又會變成 N-1,新一輪重新開始,這便是 Cyclic 的含義所在。CyclicBarrier.await() 帶有返回值,用來表示當前執行緒是第幾個到達這個 Barrier 的執行緒。

舉例說明如下:

 

Semaphore,Java 版本的訊號量實現,用於控制同時訪問的執行緒個數,來達到限制通用資源訪問的目的,其原理是通過 acquire() 獲取一個許可,如果沒有就等待,而 release() 釋放一個許可。

 

如果 Semaphore 的數值被初始化為 1,那麼一個執行緒就可以通過 acquire 進入互斥狀態,本質上和互斥鎖是非常相似的。但是區別也非常明顯,比如互斥鎖是有持有者的,而對於 Semaphore 這種計數器結構,雖然有類似功能,但其實不存在真正意義的持有者,除非我們進行擴充套件包裝。

問題八:CyclicBarrier 和 CountDownLatch 看起來很相似,請對比下呢?

它們的行為有一定相似度,區別主要在於:

  • CountDownLatch 是不可以重置的,所以無法重用,CyclicBarrier 沒有這種限制,可以重用。

  • CountDownLatch 的基本操作組合是 countDown/await,呼叫 await 的執行緒阻塞等待 countDown 足夠的次數,不管你是在一個執行緒還是多個執行緒裡 countDown,只要次數足夠即可。 CyclicBarrier 的基本操作組合就是 await,當所有的夥伴都呼叫了 await,才會繼續進行任務,並自動進行重置。

CountDownLatch 目的是讓一個執行緒等待其他 N 個執行緒達到某個條件後,自己再去做某個事(通過 CyclicBarrier 的第二個構造方法 public CyclicBarrier(int parties, Runnable barrierAction),在新執行緒裡做事可以達到同樣的效果)。而 CyclicBarrier 的目的是讓 N 多執行緒互相等待直到所有的都達到某個狀態,然後這 N 個執行緒再繼續執行各自後續(通過 CountDownLatch 在某些場合也能完成類似的效果)。

04

Java 執行緒池相關問題

問題一:Java 中的執行緒池是如何實現的?

  • 在 Java 中,所謂的執行緒池中的“執行緒”,其實是被抽象為了一個靜態內部類 Worker,它基於 AQS 實現,存放線上程池的 HashSet<Worker> workers 成員變數中;

  • 而需要執行的任務則存放在成員變數 workQueue(BlockingQueue<Runnable> workQueue)中。

這樣,整個執行緒池實現的基本思想就是:從 workQueue 中不斷取出需要執行的任務,放在 Workers 中進行處理。

問題二:建立執行緒池的幾個核心構造引數?

Java 中的執行緒池的建立其實非常靈活,我們可以通過配置不同的引數,建立出行為不同的執行緒池,這幾個引數包括:

  • corePoolSize:執行緒池的核心執行緒數。

  • maximumPoolSize:執行緒池允許的最大執行緒數。

  • keepAliveTime:超過核心執行緒數時閒置執行緒的存活時間。

  • workQueue:任務執行前儲存任務的佇列,儲存由 execute 方法提交的 Runnable 任務。

問題三:執行緒池中的執行緒是怎麼建立的?是一開始就隨著執行緒池的啟動建立好的嗎?

顯然不是的。執行緒池預設初始化後不啟動 Worker,等待有請求時才啟動。

每當我們呼叫 execute() 方法新增一個任務時,執行緒池會做如下判斷:

  • 如果正在執行的執行緒數量小於 corePoolSize,那麼馬上建立執行緒執行這個任務;

  • 如果正在執行的執行緒數量大於或等於 corePoolSize,那麼將這個任務放入佇列;

  • 如果這時候佇列滿了,而且正在執行的執行緒數量小於 maximumPoolSize,那麼還是要建立非核心執行緒立刻執行這個任務;

  • 如果佇列滿了,而且正在執行的執行緒數量大於或等於 maximumPoolSize,那麼執行緒池會丟擲異常 RejectExecutionException。

當一個執行緒完成任務時,它會從佇列中取下一個任務來執行。 當一個執行緒無事可做,超過一定的時間(keepAliveTime)時,執行緒池會判斷。

如果當前執行的執行緒數大於 corePoolSize,那麼這個執行緒就被停掉。所以執行緒池的所有任務完成後,它最終會收縮到 corePoolSize 的大小。

問題四:既然提到可以通過配置不同引數建立出不同的執行緒池,那麼 Java 中預設實現好的執行緒池又有哪些呢?請比較它們的異同。

1. SingleThreadExecutor 執行緒池

這個執行緒池只有一個核心執行緒在工作,也就是相當於單執行緒序列執行所有任務。如果這個唯一的執行緒因為異常結束,那麼會有一個新的執行緒來替代它。此執行緒池保證所有任務的執行順序按照任務的提交順序執行。

  • corePoolSize:1,只有一個核心執行緒在工作。

  • maximumPoolSize:1。

  • keepAliveTime:0L。

  • workQueue:new LinkedBlockingQueue<Runnable>(),其緩衝佇列是無界的。

2. FixedThreadPool 執行緒池

FixedThreadPool 是固定大小的執行緒池,只有核心執行緒。每次提交一個任務就建立一個執行緒,直到執行緒達到執行緒池的最大大小。執行緒池的大小一旦達到最大值就會保持不變,如果某個執行緒因為執行異常而結束,那麼執行緒池會補充一個新執行緒。

FixedThreadPool 多數針對一些很穩定很固定的正規併發執行緒,多用於伺服器。

  • corePoolSize:nThreads

  • maximumPoolSize:nThreads

  • keepAliveTime:0L

  • workQueue:new LinkedBlockingQueue<Runnable>(),其緩衝佇列是無界的。

3. CachedThreadPool 執行緒池

CachedThreadPool 是無界執行緒池,如果執行緒池的大小超過了處理任務所需要的執行緒,那麼就會回收部分空閒(60 秒不執行任務)執行緒,當任務數增加時,此執行緒池又可以智慧的新增新執行緒來處理任務。

執行緒池大小完全依賴於作業系統(或者說 JVM)能夠建立的最大執行緒大小。SynchronousQueue 是一個是緩衝區為 1 的阻塞佇列。

快取型池子通常用於執行一些生存期很短的非同步型任務,因此在一些面向連線的 daemon 型 SERVER 中用得不多。但對於生存期短的非同步任務,它是 Executor 的首選。

  • corePoolSize:0

  • maximumPoolSize:Integer.MAX_VALUE

  • keepAliveTime:60L

  • workQueue:new SynchronousQueue<Runnable>(),一個是緩衝區為 1 的阻塞佇列。

4. ScheduledThreadPool 執行緒池

ScheduledThreadPool:核心執行緒池固定,大小無限的執行緒池。此執行緒池支援定時以及週期性執行任務的需求。建立一個週期性執行任務的執行緒池。如果閒置,非核心執行緒池會在 DEFAULT_KEEPALIVEMILLIS 時間內回收。

  • corePoolSize:corePoolSize

  • maximumPoolSize:Integer.MAX_VALUE

  • keepAliveTime:DEFAULT_KEEPALIVE_MILLIS

  • workQueue:new DelayedWorkQueue()

問題六:如何在 Java 執行緒池中提交執行緒?

執行緒池最常用的提交任務的方法有兩種:

1. execute():ExecutorService.execute 方法接收一個 Runable 例項,它用來執行一個任務:

 

2. submit():ExecutorService.submit() 方法返回的是 Future 物件。可以用 isDone() 來查詢 Future 是否已經完成,當任務完成時,它具有一個結果,可以呼叫 get() 來獲取結果。也可以不用 isDone() 進行檢查就直接呼叫 get(),在這種情況下,get() 將阻塞,直至結果準備就緒。

 

05

Java 記憶體模型相關問題

問題一:什麼是 Java 的記憶體模型,Java 中各個執行緒是怎麼彼此看到對方的變數的?

Java 的記憶體模型定義了程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出這樣的底層細節。

此處的變數包括例項欄位、靜態欄位和構成陣列物件的元素,但是不包括區域性變數和方法引數,因為這些是執行緒私有的,不會被共享,所以不存在競爭問題。

Java 中各個執行緒是怎麼彼此看到對方的變數的呢?Java 中定義了主記憶體與工作記憶體的概念:

所有的變數都儲存在主記憶體,每條執行緒還有自己的工作記憶體,儲存了被該執行緒使用到的變數的主記憶體副本拷貝。

執行緒對變數的所有操作(讀取、賦值)都必須在工作記憶體中進行,不能直接讀寫主記憶體的變數。不同的執行緒之間也無法直接訪問對方工作記憶體的變數,執行緒間變數值的傳遞需要通過主記憶體。

問題二:請談談 volatile 有什麼特點,為什麼它能保證變數對所有執行緒的可見性?

關鍵字 volatile 是 Java 虛擬機器提供的最輕量級的同步機制。當一個變數被定義成 volatile 之後,具備兩種特性:

  1. 保證此變數對所有執行緒的可見性。當一條執行緒修改了這個變數的值,新值對於其他執行緒是可以立即得知的。而普通變數做不到這一點。

  2. 禁止指令重排序優化。普通變數僅僅能保證在該方法執行過程中,得到正確結果,但是不保證程式程式碼的執行順序。

Java 的記憶體模型定義了 8 種記憶體間操作:

lock 和 unlock

  • 把一個變數標識為一條執行緒獨佔的狀態。

  • 把一個處於鎖定狀態的變數釋放出來,釋放之後的變數才能被其他執行緒鎖定。

read 和 write

  • 把一個變數值從主記憶體傳輸到執行緒的工作記憶體,以便 load。

  • 把 store 操作從工作記憶體得到的變數的值,放入主記憶體的變數中。

load 和 store

  • 把 read 操作從主記憶體得到的變數值放入工作記憶體的變數副本中。

  • 把工作記憶體的變數值傳送到主記憶體,以便 write。

use 和 assgin

  • 把工作記憶體變數值傳遞給執行引擎。

  • 將執行引擎值傳遞給工作記憶體變數值。

volatile 的實現基於這 8 種記憶體間操作,保證了一個執行緒對某個 volatile 變數的修改,一定會被另一個執行緒看見,即保證了可見性。

問題三:既然 volatile 能夠保證執行緒間的變數可見性,是不是就意味著基於 volatile 變數的運算就是併發安全的?

顯然不是的。基於 volatile 變數的運算在併發下不一定是安全的。volatile 變數在各個執行緒的工作記憶體,不存在一致性問題(各個執行緒的工作記憶體中 volatile 變數,每次使用前都要重新整理到主記憶體)。

但是 Java 裡面的運算並非原子操作,導致 volatile 變數的運算在併發下一樣是不安全的。

問題四:請對比下 volatile 對比 Synchronized 的異同。

Synchronized 既能保證可見性,又能保證原子性,而 volatile 只能保證可見性,無法保證原子性。

ThreadLocal 和 Synchonized 都用於解決多執行緒併發訪問,防止任務在共享資源上產生衝突。但是 ThreadLocal 與 Synchronized 有本質的區別。

Synchronized 用於實現同步機制,是利用鎖的機制使變數或程式碼塊在某一時該只能被一個執行緒訪問,是一種 “以時間換空間” 的方式。

而 ThreadLocal 為每一個執行緒都提供了變數的副本,使得每個執行緒在某一時間訪問到的並不是同一個物件,根除了對變數的共享,是一種 “以空間換時間” 的方式。

問題五:請談談 ThreadLocal 是怎麼解決併發安全的?

ThreadLocal 這是 Java 提供的一種儲存執行緒私有資訊的機制,因為其在整個執行緒生命週期內有效,所以可以方便地在一個執行緒關聯的不同業務模組之間傳遞資訊,比如事務 ID、Cookie 等上下文相關資訊。

ThreadLocal 為每一個執行緒維護變數的副本,把共享資料的可見範圍限制在同一個執行緒之內,其實現原理是,在 ThreadLocal 類中有一個 Map,用於儲存每一個執行緒的變數的副本。

問題六:很多人都說要慎用 ThreadLocal,談談你的理解,使用 ThreadLocal 需要注意些什麼?

使用 ThreadLocal 要注意 remove!

ThreadLocal 的實現是基於一個所謂的 ThreadLocalMap,在 ThreadLocalMap 中,它的 key 是一個弱引用。

通常弱引用都會和引用佇列配合清理機制使用,但是 ThreadLocal 是個例外,它並沒有這麼做。

這意味著,廢棄專案的回收依賴於顯式地觸發,否則就要等待執行緒結束,進而回收相應 ThreadLocalMap!這就是很多 OOM 的來源,所以通常都會建議,應用一定要自己負責 remove,並且不要和執行緒池配合,因為 worker 執行緒往往是不會退出的。

06

結語

本文旨在拋磚引玉,但鑑於筆者水平有限,且時間倉促,工作繁忙,雖然儘可能地將自己在學習和實踐中的所學、所得總結了出來,並且翻閱了大量書籍、文件和部落格,仍然不可避免會存在疏漏,敬請廣大讀者海涵。

參考資料:《深入理解 Java 虛擬機器 JVM 高階特性與最佳實踐》(周志明著)

作者簡介:

Eric Chen, 國內 c9 高校計算機系本碩畢業,曾工作於某國際頂尖投行,是其風險系統的主要開發者,屬於業內排名第一的系統;現工作於某國際知名電商,是其廣告推薦系統的技術主程,擁有豐富的實戰經驗。

相關文章