Java 併發進階常見面試題總結
Java 併發進階常見面試題總結
1. synchronized 關鍵字
1.1. 說一說自己對於 synchronized 關鍵字的瞭解
synchronized關鍵字解決的是多個執行緒之間訪問資源的同步性,synchronized關鍵字可以保證被它修飾的方法或者程式碼塊在任意時刻只能有一個執行緒執行。
另外,在 Java 早期版本中,synchronized屬於重量級鎖,效率低下,因為監視器鎖(monitor)是依賴於底層的作業系統的 Mutex Lock 來實現的,Java 的執行緒是對映到作業系統的原生執行緒之上的。如果要掛起或者喚醒一個執行緒,都需要作業系統幫忙完成,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是為什麼早期的 synchronized 效率低的原因。慶幸的是在 Java 6 之後 Java 官方對從 JVM 層面對synchronized 較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。JDK1.6對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。
1.2. 說說自己是怎麼使用 synchronized 關鍵字,在專案中用到了嗎
synchronized關鍵字最主要的三種使用方式:
- 修飾例項方法: 作用於當前物件例項加鎖,進入同步程式碼前要獲得當前物件例項的鎖
- 修飾靜態方法: 也就是給當前類加鎖,會作用於類的所有物件例項,因為靜態成員不屬於任何一個例項物件,是類成員( static 表明這是該類的一個靜態資源,不管new了多少個物件,只有一份)。所以如果一個執行緒A呼叫一個例項物件的非靜態 synchronized 方法,而執行緒B需要呼叫這個例項物件所屬類的靜態 synchronized 方法,是允許的,不會發生互斥現象,因為訪問靜態 synchronized 方法佔用的鎖是當前類的鎖,而訪問非靜態 synchronized 方法佔用的鎖是當前例項物件鎖。
- 修飾程式碼塊: 指定加鎖物件,對給定物件加鎖,進入同步程式碼庫前要獲得給定物件的鎖。
總結: synchronized 關鍵字加到 static 靜態方法和 synchronized(class)程式碼塊上都是是給 Class 類上鎖。synchronized 關鍵字加到例項方法上是給物件例項上鎖。儘量不要使用 synchronized(String a) 因為JVM中,字串常量池具有快取功能!
下面我以一個常見的面試題為例講解一下 synchronized 關鍵字的具體使用。
面試中面試官經常會說:“單例模式瞭解嗎?來給我手寫一下!給我解釋一下雙重檢驗鎖方式實現單例模式的原理唄!”
雙重校驗鎖實現物件單例(執行緒安全)
public class Singleton { private volatile static Singleton uniqueInstance; private Singleton() { } public static Singleton getUniqueInstance() { //先判斷物件是否已經例項過,沒有例項化過才進入加鎖程式碼 if (uniqueInstance == null) { //類物件加鎖 synchronized (Singleton.class) { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } } } return uniqueInstance; } }
另外,需要注意 uniqueInstance 採用 volatile 關鍵字修飾也是很有必要。
uniqueInstance 採用 volatile 關鍵字修飾也是很有必要的, uniqueInstance = new Singleton(); 這段程式碼其實是分為三步執行:
- 為 uniqueInstance 分配記憶體空間
- 初始化 uniqueInstance
- 將 uniqueInstance 指向分配的記憶體地址
但是由於 JVM 具有指令重排的特性,執行順序有可能變成 1->3->2。指令重排在單執行緒環境下不會出現問題,但是在多執行緒環境下會導致一個執行緒獲得還沒有初始化的例項。例如,執行緒 T1 執行了 1 和 3,此時 T2 呼叫 getUniqueInstance() 後發現 uniqueInstance 不為空,因此返回 uniqueInstance,但此時 uniqueInstance 還未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保證在多執行緒環境下也能正常執行。
1.3. 講一下 synchronized 關鍵字的底層原理
synchronized 關鍵字底層原理屬於 JVM 層面。
① synchronized 同步語句塊的情況
public class SynchronizedDemo { public void method() { synchronized (this) { System.out.println("synchronized 程式碼塊"); } } }
通過 JDK 自帶的 javap 命令檢視 SynchronizedDemo 類的相關位元組碼資訊:首先切換到類的對應目錄執行 javac SynchronizedDemo.java
命令生成編譯後的 .class 檔案,然後執行javap -c -s -v -l SynchronizedDemo.class
。
從上面我們可以看出:
synchronized 同步語句塊的實現使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步程式碼塊的開始位置,monitorexit 指令則指明同步程式碼塊的結束位置。 當執行 monitorenter 指令時,執行緒試圖獲取鎖也就是獲取 monitor(monitor物件存在於每個Java物件的物件頭中,synchronized 鎖便是通過這種方式獲取鎖的,也是為什麼Java中任意物件可以作為鎖的原因) 的持有權。當計數器為0則可以成功獲取,獲取後將鎖計數器設為1也就是加1。相應的在執行 monitorexit 指令後,將鎖計數器設為0,表明鎖被釋放。如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到鎖被另外一個執行緒釋放為止。
② synchronized 修飾方法的的情況
public class SynchronizedDemo2 { public synchronized void method() { System.out.println("synchronized 方法"); } }
synchronized 修飾的方法並沒有 monitorenter 指令和 monitorexit 指令,取得代之的確實是 ACC_SYNCHRONIZED 標識,該標識指明瞭該方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標誌來辨別一個方法是否宣告為同步方法,從而執行相應的同步呼叫。
1.4. 說說 JDK1.6 之後的synchronized 關鍵字底層做了哪些優化,可以詳細介紹一下這些優化嗎
JDK1.6 對鎖的實現引入了大量的優化,如偏向鎖、輕量級鎖、自旋鎖、適應性自旋鎖、鎖消除、鎖粗化等技術來減少鎖操作的開銷。
鎖主要存在四種狀態,依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,他們會隨著競爭的激烈而逐漸升級。注意鎖可以升級不可降級,這種策略是為了提高獲得鎖和釋放鎖的效率。
關於這幾種優化的詳細資訊可以檢視筆主的這篇文章:https://gitee.com/SnailClimb/JavaGuide/blob/master/docs/java/Multithread/synchronized.md
1.5. 談談 synchronized和ReentrantLock 的區別
① 兩者都是可重入鎖
兩者都是可重入鎖。“可重入鎖”概念是:自己可以再次獲取自己的內部鎖。比如一個執行緒獲得了某個物件的鎖,此時這個物件鎖還沒有釋放,當其再次想要獲取這個物件的鎖的時候還是可以獲取的,如果不可鎖重入的話,就會造成死鎖。同一個執行緒每次獲取鎖,鎖的計數器都自增1,所以要等到鎖的計數器下降為0時才能釋放鎖。
② synchronized 依賴於 JVM 而 ReentrantLock 依賴於 API
synchronized 是依賴於 JVM 實現的,前面我們也講到了 虛擬機器團隊在 JDK1.6 為 synchronized 關鍵字進行了很多優化,但是這些優化都是在虛擬機器層面實現的,並沒有直接暴露給我們。ReentrantLock 是 JDK 層面實現的(也就是 API 層面,需要 lock() 和 unlock() 方法配合 try/finally 語句塊來完成),所以我們可以通過檢視它的原始碼,來看它是如何實現的。
③ ReentrantLock 比 synchronized 增加了一些高階功能
相比synchronized,ReentrantLock增加了一些高階功能。主要來說主要有三點:①等待可中斷;②可實現公平鎖;③可實現選擇性通知(鎖可以繫結多個條件)
- ReentrantLock提供了一種能夠中斷等待鎖的執行緒的機制,通過lock.lockInterruptibly()來實現這個機制。也就是說正在等待的執行緒可以選擇放棄等待,改為處理其他事情。
- ReentrantLock可以指定是公平鎖還是非公平鎖。而synchronized只能是非公平鎖。所謂的公平鎖就是先等待的執行緒先獲得鎖。 ReentrantLock預設情況是非公平的,可以通過 ReentrantLock類的
ReentrantLock(boolean fair)
構造方法來制定是否是公平的。 - synchronized關鍵字與wait()和notify()/notifyAll()方法相結合可以實現等待/通知機制,ReentrantLock類當然也可以實現,但是需要藉助於Condition介面與newCondition() 方法。Condition是JDK1.5之後才有的,它具有很好的靈活性,比如可以實現多路通知功能也就是在一個Lock物件中可以建立多個Condition例項(即物件監視器),執行緒物件可以註冊在指定的Condition中,從而可以有選擇性的進行執行緒通知,在排程執行緒上更加靈活。 在使用notify()/notifyAll()方法進行通知時,被通知的執行緒是由 JVM 選擇的,用ReentrantLock類結合Condition例項可以實現“選擇性通知” ,這個功能非常重要,而且是Condition介面預設提供的。而synchronized關鍵字就相當於整個Lock物件中只有一個Condition例項,所有的執行緒都註冊在它一個身上。如果執行notifyAll()方法的話就會通知所有處於等待狀態的執行緒這樣會造成很大的效率問題,而Condition例項的signalAll()方法 只會喚醒註冊在該Condition例項中的所有等待執行緒。
如果你想使用上述功能,那麼選擇ReentrantLock是一個不錯的選擇。
④ 效能已不是選擇標準
2. volatile關鍵字
2.1. 講一下Java記憶體模型
在 JDK1.2 之前,Java的記憶體模型實現總是從主存(即共享記憶體)讀取變數,是不需要進行特別的注意的。而在當前的 Java 記憶體模型下,執行緒可以把變數儲存本地記憶體(比如機器的暫存器)中,而不是直接在主存中進行讀寫。這就可能造成一個執行緒在主存中修改了一個變數的值,而另外一個執行緒還繼續使用它在暫存器中的變數值的拷貝,造成資料的不一致。
要解決這個問題,就需要把變數宣告為volatile,這就指示 JVM,這個變數是不穩定的,每次使用它都到主存中進行讀取。
說白了, volatile 關鍵字的主要作用就是保證變數的可見性然後還有一個作用是防止指令重排序。
2.2. 說說 synchronized 關鍵字和 volatile 關鍵字的區別
synchronized關鍵字和volatile關鍵字比較
- volatile關鍵字是執行緒同步的輕量級實現,所以volatile效能肯定比synchronized關鍵字要好。但是volatile關鍵字只能用於變數而synchronized關鍵字可以修飾方法以及程式碼塊。synchronized關鍵字在JavaSE1.6之後進行了主要包括為了減少獲得鎖和釋放鎖帶來的效能消耗而引入的偏向鎖和輕量級鎖以及其它各種優化之後執行效率有了顯著提升,實際開發中使用 synchronized 關鍵字的場景還是更多一些。
- 多執行緒訪問volatile關鍵字不會發生阻塞,而synchronized關鍵字可能會發生阻塞
- volatile關鍵字能保證資料的可見性,但不能保證資料的原子性。synchronized關鍵字兩者都能保證。
- volatile關鍵字主要用於解決變數在多個執行緒之間的可見性,而 synchronized關鍵字解決的是多個執行緒之間訪問資源的同步性。
3. ThreadLocal
3.1. ThreadLocal簡介
通常情況下,我們建立的變數是可以被任何一個執行緒訪問並修改的。如果想實現每一個執行緒都有自己的專屬本地變數該如何解決呢? JDK中提供的ThreadLocal
類正是為了解決這樣的問題。 ThreadLocal
類主要解決的就是讓每個執行緒繫結自己的值,可以將ThreadLocal
類形象的比喻成存放資料的盒子,盒子中可以儲存每個執行緒的私有資料。
如果你建立了一個ThreadLocal
變數,那麼訪問這個變數的每個執行緒都會有這個變數的本地副本,這也是ThreadLocal
變數名的由來。他們可以使用 get()
和 set()
方法來獲取預設值或將其值更改為當前執行緒所存的副本的值,從而避免了執行緒安全問題。
再舉個簡單的例子:
比如有兩個人去寶屋收集寶物,這兩個共用一個袋子的話肯定會產生爭執,但是給他們兩個人每個人分配一個袋子的話就不會出現這樣的問題。如果把這兩個人比作執行緒的話,那麼ThreadLocal就是用來避免這兩個執行緒競爭的。
3.2. ThreadLocal示例
相信看了上面的解釋,大家已經搞懂 ThreadLocal 類是個什麼東西了。
import java.text.SimpleDateFormat; import java.util.Random; public class ThreadLocalExample implements Runnable{ // SimpleDateFormat 不是執行緒安全的,所以每個執行緒都要有自己獨立的副本 private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm")); public static void main(String[] args) throws InterruptedException { ThreadLocalExample obj = new ThreadLocalExample(); for(int i=0 ; i<10; i++){ Thread t = new Thread(obj, ""+i); Thread.sleep(new Random().nextInt(1000)); t.start(); } } @Override public void run() { System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern()); try { Thread.sleep(new Random().nextInt(1000)); } catch (InterruptedException e) { e.printStackTrace(); } //formatter pattern is changed here by thread, but it won't reflect to other threads formatter.set(new SimpleDateFormat()); System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern()); } }
Output:
Thread Name= 0 default Formatter = yyyyMMdd HHmm Thread Name= 0 formatter = yy-M-d ah:mm Thread Name= 1 default Formatter = yyyyMMdd HHmm Thread Name= 2 default Formatter = yyyyMMdd HHmm Thread Name= 1 formatter = yy-M-d ah:mm Thread Name= 3 default Formatter = yyyyMMdd HHmm Thread Name= 2 formatter = yy-M-d ah:mm Thread Name= 4 default Formatter = yyyyMMdd HHmm Thread Name= 3 formatter = yy-M-d ah:mm Thread Name= 4 formatter = yy-M-d ah:mm Thread Name= 5 default Formatter = yyyyMMdd HHmm Thread Name= 5 formatter = yy-M-d ah:mm Thread Name= 6 default Formatter = yyyyMMdd HHmm Thread Name= 6 formatter = yy-M-d ah:mm Thread Name= 7 default Formatter = yyyyMMdd HHmm Thread Name= 7 formatter = yy-M-d ah:mm Thread Name= 8 default Formatter = yyyyMMdd HHmm Thread Name= 9 default Formatter = yyyyMMdd HHmm Thread Name= 8 formatter = yy-M-d ah:mm Thread Name= 9 formatter = yy-M-d ah:mm
從輸出中可以看出,Thread-0已經改變了formatter的值,但仍然是thread-2預設格式化程式與初始化值相同,其他執行緒也一樣。
上面有一段程式碼用到了建立 ThreadLocal
變數的那段程式碼用到了 Java8 的知識,它等於下面這段程式碼,如果你寫了下面這段程式碼的話,IDEA會提示你轉換為Java8的格式(IDEA真的不錯!)。因為ThreadLocal類在Java 8中擴充套件,使用一個新的方法withInitial()
,將Supplier功能介面作為引數。
private static final ThreadLocal<SimpleDateFormat> formatter = new ThreadLocal<SimpleDateFormat>(){ @Override protected SimpleDateFormat initialValue() { return new SimpleDateFormat("yyyyMMdd HHmm"); } };
3.3. ThreadLocal原理
從 Thread
類原始碼入手。
public class Thread implements Runnable { ...... //與此執行緒有關的ThreadLocal值。由ThreadLocal類維護 ThreadLocal.ThreadLocalMap threadLocals = null; //與此執行緒有關的InheritableThreadLocal值。由InheritableThreadLocal類維護 ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; ...... }
從上面Thread
類 原始碼可以看出Thread
類中有一個 threadLocals
和 一個 inheritableThreadLocals
變數,它們都是 ThreadLocalMap
型別的變數,我們可以把 ThreadLocalMap
理解為ThreadLocal
類實現的定製化的 HashMap
。預設情況下這兩個變數都是null,只有當前執行緒呼叫 ThreadLocal
類的 set
或get
方法時才建立它們,實際上呼叫這兩個方法的時候,我們呼叫的是ThreadLocalMap
類對應的 get()
、set()
方法。
ThreadLocal
類的set()
方法
public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; }
通過上面這些內容,我們足以通過猜測得出結論:最終的變數是放在了當前執行緒的 ThreadLocalMap
中,並不是存在 ThreadLocal
上,ThreadLocal
可以理解為只是ThreadLocalMap
的封裝,傳遞了變數值。 ThrealLocal
類中可以通過Thread.currentThread()
獲取到當前執行緒物件後,直接通過getMap(Thread t)
可以訪問到該執行緒的ThreadLocalMap
物件。
每個Thread
中都具備一個ThreadLocalMap
,而ThreadLocalMap
可以儲存以ThreadLocal
為key的鍵值對。 比如我們在同一個執行緒中聲明瞭兩個 ThreadLocal
物件的話,會使用 Thread
內部都是使用僅有那個ThreadLocalMap
存放資料的,ThreadLocalMap
的 key 就是 ThreadLocal
物件,value 就是 ThreadLocal
物件呼叫set
方法設定的值。ThreadLocal
是 map結構是為了讓每個執行緒可以關聯多個 ThreadLocal
變數。這也就解釋了 ThreadLocal 宣告的變數為什麼在每一個執行緒都有自己的專屬本地變數。
ThreadLocalMap
是ThreadLocal
的靜態內部類。
3.4. ThreadLocal 記憶體洩露問題
ThreadLocalMap
中使用的 key 為 ThreadLocal
的弱引用,而 value 是強引用。所以,如果 ThreadLocal
沒有被外部強引用的情況下,在垃圾回收的時候,key 會被清理掉,而 value 不會被清理掉。這樣一來,ThreadLocalMap
中就會出現key為null的Entry。假如我們不做任何措施的話,value 永遠無法被GC 回收,這個時候就可能會產生記憶體洩露。ThreadLocalMap實現中已經考慮了這種情況,在呼叫 set()
、get()
、remove()
方法的時候,會清理掉 key 為 null 的記錄。使用完 ThreadLocal
方法後 最好手動呼叫remove()
方法
static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }
弱引用介紹:
如果一個物件只具有弱引用,那就類似於可有可無的生活用品。弱引用與軟引用的區別在於:只具有弱引用的物件擁有更短暫的生命週期。在垃圾回收器執行緒掃描它 所管轄的記憶體區域的過程中,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會回收它的記憶體。不過,由於垃圾回收器是一個優先順序很低的執行緒, 因此不一定會很快發現那些只具有弱引用的物件。
弱引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果弱引用所引用的物件被垃圾回收,Java虛擬機器就會把這個弱引用加入到與之關聯的引用佇列中。
4. 執行緒池
4.1. 為什麼要用執行緒池?
池化技術相比大家已經屢見不鮮了,執行緒池、資料庫連線池、Http 連線池等等都是對這個思想的應用。池化技術的思想主要是為了減少每次獲取資源的消耗,提高對資源的利用率。
執行緒池提供了一種限制和管理資源(包括執行一個任務)。 每個執行緒池還維護一些基本統計資訊,例如已完成任務的數量。
這裡借用《Java 併發程式設計的藝術》提到的來說一下使用執行緒池的好處:
- 降低資源消耗。通過重複利用已建立的執行緒降低執行緒建立和銷燬造成的消耗。
- 提高響應速度。當任務到達時,任務可以不需要的等到執行緒建立就能立即執行。
- 提高執行緒的可管理性。執行緒是稀缺資源,如果無限制的建立,不僅會消耗系統資源,還會降低系統的穩定性,使用執行緒池可以進行統一的分配,調優和監控。
4.2. 實現Runnable介面和Callable介面的區別
Runnable
自Java 1.0以來一直存在,但Callable
僅在Java 1.5中引入,目的就是為了來處理Runnable
不支援的用例。Runnable
介面不會返回結果或丟擲檢查異常,但是**Callable
介面**可以。所以,如果任務不需要返回結果或丟擲異常推薦使用 Runnable
介面,這樣程式碼看起來會更加簡潔。
工具類 Executors
可以實現 Runnable
物件和 Callable
物件之間的相互轉換。(Executors.callable(Runnable task
)或 Executors.callable(Runnable task,Object resule)
)。
Runnable.java
@FunctionalInterface public interface Runnable { /** * 被執行緒執行,沒有返回值也無法丟擲異常 */ public abstract void run(); }
Callable.java
@FunctionalInterface public interface Callable<V> { /** * 計算結果,或在無法這樣做時丟擲異常。 * @return 計算得出的結果 * @throws 如果無法計算結果,則丟擲異常 */ V call() throws Exception; }
4.3. 執行execute()方法和submit()方法的區別是什麼呢?
execute()
方法用於提交不需要返回值的任務,所以無法判斷任務是否被執行緒池執行成功與否;submit()
方法用於提交需要返回值的任務。執行緒池會返回一個Future
型別的物件,通過這個Future
物件可以判斷任務是否執行成功,並且可以通過Future
的get()
方法來獲取返回值,get()
方法會阻塞當前執行緒直到任務完成,而使用get(long timeout,TimeUnit unit)
方法則會阻塞當前執行緒一段時間後立即返回,這時候有可能任務沒有執行完。
我們以**AbstractExecutorService
**介面中的一個 submit
方法為例子來看看原始碼:
public Future<?> submit(Runnable task) { if (task == null) throw new NullPointerException(); RunnableFuture<Void> ftask = newTaskFor(task, null); execute(ftask); return ftask; }
上面方法呼叫的 newTaskFor
方法返回了一個 FutureTask
物件。
protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) { return new FutureTask<T>(runnable, value); }
我們再來看看execute()
方法:
public void execute(Runnable command) { ... }
4.4. 如何建立執行緒池
《阿里巴巴Java開發手冊》中強制執行緒池不允許使用 Executors 去建立,而是通過 ThreadPoolExecutor 的方式,這樣的處理方式讓寫的同學更加明確執行緒池的執行規則,規避資源耗盡的風險
Executors 返回執行緒池物件的弊端如下:
- FixedThreadPool 和 SingleThreadExecutor : 允許請求的佇列長度為 Integer.MAX_VALUE ,可能堆積大量的請求,從而導致OOM。
- CachedThreadPool 和 ScheduledThreadPool : 允許建立的執行緒數量為 Integer.MAX_VALUE ,可能會建立大量執行緒,從而導致OOM。
方式一:通過構造方法實現 方式二:通過Executor 框架的工具類Executors來實現 我們可以建立三種類型的ThreadPoolExecutor:
- FixedThreadPool : 該方法返回一個固定執行緒數量的執行緒池。該執行緒池中的執行緒數量始終不變。當有一個新的任務提交時,執行緒池中若有空閒執行緒,則立即執行。若沒有,則新的任務會被暫存在一個任務佇列中,待有執行緒空閒時,便處理在任務佇列中的任務。
- SingleThreadExecutor: 方法返回一個只有一個執行緒的執行緒池。若多餘一個任務被提交到該執行緒池,任務會被儲存在一個任務佇列中,待執行緒空閒,按先入先出的順序執行佇列中的任務。
- CachedThreadPool: 該方法返回一個可根據實際情況調整執行緒數量的執行緒池。執行緒池的執行緒數量不確定,但若有空閒執行緒可以複用,則會優先使用可複用的執行緒。若所有執行緒均在工作,又有新的任務提交,則會建立新的執行緒處理任務。所有執行緒在當前任務執行完畢後,將返回執行緒池進行復用。
對應Executors工具類中的方法如圖所示:
4.5 ThreadPoolExecutor 類分析
ThreadPoolExecutor
類中提供的四個構造方法。我們來看最長的那個,其餘三個都是在這個構造方法的基礎上產生(其他幾個構造方法說白點都是給定某些預設引數的構造方法比如預設制定拒絕策略是什麼),這裡就不貼程式碼講了,比較簡單。
/** * 用給定的初始引數建立一個新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { if (corePoolSize < 0 || maximumPoolSize <= 0 || maximumPoolSize < corePoolSize || keepAliveTime < 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; }
下面這些對建立 非常重要,在後面使用執行緒池的過程中你一定會用到!所以,務必拿著小本本記清楚。
4.5.1 ThreadPoolExecutor
建構函式重要引數分析
ThreadPoolExecutor
3 個最重要的引數:
corePoolSize
: 核心執行緒數執行緒數定義了最小可以同時執行的執行緒數量。maximumPoolSize
: 當佇列中存放的任務達到佇列容量的時候,當前可以同時執行的執行緒數量變為最大執行緒數。workQueue
: 當新任務來的時候會先判斷當前執行的執行緒數量是否達到核心執行緒數,如果達到的話,新任務就會被存放在佇列中。
ThreadPoolExecutor
其他常見引數:
keepAliveTime
:當執行緒池中的執行緒數量大於corePoolSize
的時候,如果這時沒有新的任務提交,核心執行緒外的執行緒不會立即銷燬,而是會等待,直到等待的時間超過了keepAliveTime
才會被回收銷燬;unit
:keepAliveTime
引數的時間單位。threadFactory
:executor 建立新執行緒的時候會用到。handler
:飽和策略。關於飽和策略下面單獨介紹一下。
4.5.2 ThreadPoolExecutor
飽和策略
ThreadPoolExecutor
飽和策略定義:
如果當前同時執行的執行緒數量達到最大執行緒數量並且佇列也已經被放滿了任時,ThreadPoolTaskExecutor
定義一些策略:
ThreadPoolExecutor.AbortPolicy
:丟擲RejectedExecutionException
來拒絕新任務的處理。ThreadPoolExecutor.CallerRunsPolicy
:呼叫執行自己的執行緒執行任務。您不會任務請求。但是這種策略會降低對於新任務提交速度,影響程式的整體效能。另外,這個策略喜歡增加佇列容量。如果您的應用程式可以承受此延遲並且你不能任務丟棄任何一個任務請求的話,你可以選擇這個策略。ThreadPoolExecutor.DiscardPolicy
: 不處理新任務,直接丟棄掉。ThreadPoolExecutor.DiscardOldestPolicy
: 此策略將丟棄最早的未處理的任務請求。
舉個例子: Spring 通過 ThreadPoolTaskExecutor
或者我們直接通過 ThreadPoolExecutor
的建構函式建立執行緒池的時候,當我們不指定 RejectedExecutionHandler
飽和策略的話來配置執行緒池的時候預設使用的是 ThreadPoolExecutor.AbortPolicy
。在預設情況下,ThreadPoolExecutor
將丟擲 RejectedExecutionException
來拒絕新來的任務 ,這代表你將丟失對這個任務的處理。 對於可伸縮的應用程式,建議使用 ThreadPoolExecutor.CallerRunsPolicy
。當最大池被填滿時,此策略為我們提供可伸縮佇列。(這個直接檢視 ThreadPoolExecutor
的建構函式原始碼就可以看出,比較簡單的原因,這裡就不貼程式碼了)
4.6 一個簡單的執行緒池Demo:Runnable
+ThreadPoolExecutor
為了讓大家更清楚上面的面試題中的一些概念,我寫了一個簡單的執行緒池 Demo。
首先建立一個 Runnable
介面的實現類(當然也可以是 Callable
介面,我們上面也說了兩者的區別。)
MyRunnable.java
import java.util.Date; /** * 這是一個簡單的Runnable類,需要大約5秒鐘來執行其任務。 * @author shuang.kou */ public class MyRunnable implements Runnable { private String command; public MyRunnable(String s) { this.command = s; } @Override public void run() { System.out.println(Thread.currentThread().getName() + " Start. Time = " + new Date()); processCommand(); System.out.println(Thread.currentThread().getName() + " End. Time = " + new Date()); } private void processCommand() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public String toString() { return this.command; } }
編寫測試程式,我們這裡以阿里巴巴推薦的使用 ThreadPoolExecutor
建構函式自定義引數的方式來建立執行緒池。
ThreadPoolExecutorDemo.java
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolExecutorDemo { private static final int CORE_POOL_SIZE = 5; private static final int MAX_POOL_SIZE = 10; private static final int QUEUE_CAPACITY = 100; private static final Long KEEP_ALIVE_TIME = 1L; public static void main(String[] args) { //使用阿里巴巴推薦的建立執行緒池的方式 //通過ThreadPoolExecutor建構函式自定義引數建立 ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue<>(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); for (int i = 0; i < 10; i++) { //建立WorkerThread物件(WorkerThread類實現了Runnable 介面) Runnable worker = new MyRunnable("" + i); //執行Runnable executor.execute(worker); } //終止執行緒池 executor.shutdown(); while (!executor.isTerminated()) { } System.out.println("Finished all threads"); } }
可以看到我們上面的程式碼指定了:
corePoolSize
: 核心執行緒數為 5。maximumPoolSize
:最大執行緒數 10keepAliveTime
: 等待時間為 1L。unit
: 等待時間的單位為 TimeUnit.SECONDS。workQueue
:任務佇列為ArrayBlockingQueue
,並且容量為 100;handler
:飽和策略為CallerRunsPolicy
。
Output:
pool-1-thread-2 Start. Time = Tue Nov 12 20:59:44 CST 2019 pool-1-thread-5 Start. Time = Tue Nov 12 20:59:44 CST 2019 pool-1-thread-4 Start. Time = Tue Nov 12 20:59:44 CST 2019 pool-1-thread-1 Start. Time = Tue Nov 12 20:59:44 CST 2019 pool-1-thread-3 Start. Time = Tue Nov 12 20:59:44 CST 2019 pool-1-thread-5 End. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-3 End. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-2 End. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-4 End. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-1 End. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-2 Start. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-1 Start. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-4 Start. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-3 Start. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-5 Start. Time = Tue Nov 12 20:59:49 CST 2019 pool-1-thread-2 End. Time = Tue Nov 12 20:59:54 CST 2019 pool-1-thread-3 End. Time = Tue Nov 12 20:59:54 CST 2019 pool-1-thread-4 End. Time = Tue Nov 12 20:59:54 CST 2019 pool-1-thread-5 End. Time = Tue Nov 12 20:59:54 CST 2019 pool-1-thread-1 End. Time = Tue Nov 12 20:59:54 CST 2019
4.7 執行緒池原理分析
承接 4.6 節,我們通過程式碼輸出結果可以看出:執行緒池每次會同時執行 5 個任務,這 5 個任務執行完之後,剩餘的 5 個任務才會被執行。 大家可以先通過上面講解的內容,分析一下到底是咋回事?(自己獨立思考一會)
現在,我們就分析上面的輸出內容來簡單分析一下執行緒池原理。
**為了搞懂執行緒池的原理,我們需要首先分析一下 execute
方法。**在 4.6 節中的 Demo 中我們使用 executor.execute(worker)
來提交一個任務到執行緒池中去,這個方法非常重要,下面我們來看看它的原始碼:
// 存放執行緒池的執行狀態 (runState) 和執行緒池內有效執行緒的數量 (workerCount) private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); private static int workerCountOf(int c) { return c & CAPACITY; } private final BlockingQueue<Runnable> workQueue; public void execute(Runnable command) { // 如果任務為null,則丟擲異常。 if (command == null) throw new NullPointerException(); // ctl 中儲存的執行緒池當前的一些狀態資訊 int c = ctl.get(); // 下面會涉及到 3 步 操作 // 1.首先判斷當前執行緒池中之行的任務數量是否小於 corePoolSize // 如果小於的話,通過addWorker(command, true)新建一個執行緒,並將任務(command)新增到該執行緒中;然後,啟動該執行緒從而執行任務。 if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 2.如果當前之行的任務數量大於等於 corePoolSize 的時候就會走到這裡 // 通過 isRunning 方法判斷執行緒池狀態,執行緒池處於 RUNNING 狀態才會被並且佇列可以加入任務,該任務才會被加入進去 if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); // 再次獲取執行緒池狀態,如果執行緒池狀態不是 RUNNING 狀態就需要從任務佇列中移除任務,並嘗試判斷執行緒是否全部執行完畢。同時執行拒絕策略。 if (!isRunning(recheck) && remove(command)) reject(command); // 如果當前執行緒池為空就新建立一個執行緒並執行。 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //3. 通過addWorker(command, false)新建一個執行緒,並將任務(command)新增到該執行緒中;然後,啟動該執行緒從而執行任務。 //如果addWorker(command, false)執行失敗,則通過reject()執行相應的拒絕策略的內容。 else if (!addWorker(command, false)) reject(command); }
通過下圖可以更好的對上面這 3 步做一個展示,下圖是我為了省事直接從網上找到,原地址不明。
現在,讓我們在回到 4.6 節我們寫的 Demo, 現在應該是不是很容易就可以搞懂它的原理了呢?
沒搞懂的話,也沒關係,可以看看我的分析:
我們在程式碼中模擬了 10 個任務,我們配置的核心執行緒數為 5 、等待佇列容量為 100 ,所以每次只可能存在 5 個任務同時執行,剩下的 5 個任務會被放到等待佇列中去。當前的 5 個任務之行完成後,才會之行剩下的 5 個任務。
5. Atomic 原子類
5.1. 介紹一下Atomic 原子類
Atomic 翻譯成中文是原子的意思。在化學上,我們知道原子是構成一般物質的最小單位,在化學反應中是不可分割的。在我們這裡 Atomic 是指一個操作是不可中斷的。即使是在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒干擾。
所以,所謂原子類說簡單點就是具有原子/原子操作特徵的類。
併發包 java.util.concurrent
的原子類都存放在java.util.concurrent.atomic
下,如下圖所示。
5.2. JUC 包中的原子類是哪4類?
基本型別
使用原子的方式更新基本型別
- AtomicInteger:整形原子類
- AtomicLong:長整型原子類
- AtomicBoolean:布林型原子類
陣列型別
使用原子的方式更新數組裡的某個元素
- AtomicIntegerArray:整形陣列原子類
- AtomicLongArray:長整形陣列原子類
- AtomicReferenceArray:引用型別陣列原子類
引用型別
- AtomicReference:引用型別原子類
- AtomicStampedReference:原子更新引用型別裡的欄位原子類
- AtomicMarkableReference :原子更新帶有標記位的引用型別
物件的屬性修改型別
- AtomicIntegerFieldUpdater:原子更新整形欄位的更新器
- AtomicLongFieldUpdater:原子更新長整形欄位的更新器
- AtomicStampedReference:原子更新帶有版本號的引用型別。該類將整數值與引用關聯起來,可用於解決原子的更新資料和資料的版本號,可以解決使用 CAS 進行原子更新時可能出現的 ABA 問題。
5.3. 講講 AtomicInteger 的使用
AtomicInteger 類常用方法
public final int get() //獲取當前的值 public final int getAndSet(int newValue)//獲取當前的值,並設定新的值 public final int getAndIncrement()//獲取當前的值,並自增 public final int getAndDecrement() //獲取當前的值,並自減 public final int getAndAdd(int delta) //獲取當前的值,並加上預期的值 boolean compareAndSet(int expect, int update) //如果輸入的數值等於預期值,則以原子方式將該值設定為輸入值(update) public final void lazySet(int newValue)//最終設定為newValue,使用 lazySet 設定之後可能導致其他執行緒在之後的一小段時間內還是可以讀到舊的值。
AtomicInteger 類的使用示例
使用 AtomicInteger 之後,不用對 increment() 方法加鎖也可以保證執行緒安全。
class AtomicIntegerTest { private AtomicInteger count = new AtomicInteger(); //使用AtomicInteger之後,不需要對該方法加鎖,也可以實現執行緒安全。 public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }
5.4. 能不能給我簡單介紹一下 AtomicInteger 類的原理
AtomicInteger 執行緒安全原理簡單分析
AtomicInteger 類的部分原始碼:
// setup to use Unsafe.compareAndSwapInt for updates(更新操作時提供“比較並替換”的作用) private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } private volatile int value;
AtomicInteger 類主要利用 CAS (compare and swap) + volatile 和 native 方法來保證原子操作,從而避免 synchronized 的高開銷,執行效率大為提升。
CAS的原理是拿期望的值和原本的一個值作比較,如果相同則更新成新的值。UnSafe 類的 objectFieldOffset() 方法是一個本地方法,這個方法是用來拿到“原來的值”的記憶體地址,返回值是 valueOffset。另外 value 是一個volatile變數,在記憶體中可見,因此 JVM 可以保證任何時刻任何執行緒總能拿到該變數的最新值。
關於 Atomic 原子類這部分更多內容可以檢視我的這篇文章:併發程式設計面試必備:JUC 中的 Atomic 原子類總結
6. AQS
6.1. AQS 介紹
AQS的全稱為(AbstractQueuedSynchronizer),這個類在java.util.concurrent.locks包下面。
AQS是一個用來構建鎖和同步器的框架,使用AQS能簡單且高效地構造出應用廣泛的大量的同步器,比如我們提到的ReentrantLock,Semaphore,其他的諸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基於AQS的。當然,我們自己也能利用AQS非常輕鬆容易地構造出符合我們自己需求的同步器。
6.2. AQS 原理分析
AQS 原理這部分參考了部分部落格,在5.2節末尾放了連結。
在面試中被問到併發知識的時候,大多都會被問到“請你說一下自己對於AQS原理的理解”。下面給大家一個示例供大家參加,面試不是背題,大家一定要加入自己的思想,即使加入不了自己的思想也要保證自己能夠通俗的講出來而不是背出來。
下面大部分內容其實在AQS類註釋上已經給出了,不過是英語看著比較吃力一點,感興趣的話可以看看原始碼。
6.2.1. AQS 原理概覽
AQS核心思想是,如果被請求的共享資源空閒,則將當前請求資源的執行緒設定為有效的工作執行緒,並且將共享資源設定為鎖定狀態。如果被請求的共享資源被佔用,那麼就需要一套執行緒阻塞等待以及被喚醒時鎖分配的機制,這個機制AQS是用CLH佇列鎖實現的,即將暫時獲取不到鎖的執行緒加入到佇列中。
CLH(Craig,Landin,and Hagersten)佇列是一個虛擬的雙向佇列(虛擬的雙向佇列即不存在佇列例項,僅存在結點之間的關聯關係)。AQS是將每條請求共享資源的執行緒封裝成一個CLH鎖佇列的一個結點(Node)來實現鎖的分配。
看個AQS(AbstractQueuedSynchronizer)原理圖:
AQS使用一個int成員變數來表示同步狀態,通過內建的FIFO佇列來完成獲取資源執行緒的排隊工作。AQS使用CAS對該同步狀態進行原子操作實現對其值的修改。
private volatile int state;//共享變數,使用volatile修飾保證執行緒可見性
狀態資訊通過protected型別的getState,setState,compareAndSetState進行操作
//返回同步狀態的當前值 protected final int getState() { return state; } // 設定同步狀態的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)將同步狀態值設定為給定值update如果當前同步狀態的值等於expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }
6.2.2. AQS 對資源的共享方式
AQS定義兩種資源共享方式
- Exclusive(獨佔):只有一個執行緒能執行,如ReentrantLock。又可分為公平鎖和非公平鎖:
- 公平鎖:按照執行緒在佇列中的排隊順序,先到者先拿到鎖
- 非公平鎖:當執行緒要獲取鎖時,無視佇列順序直接去搶鎖,誰搶到就是誰的
- Share(共享):多個執行緒可同時執行,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 我們都會在後面講到。
ReentrantReadWriteLock 可以看成是組合式,因為ReentrantReadWriteLock也就是讀寫鎖允許多個執行緒同時對某一資源進行讀。
不同的自定義同步器爭用共享資源的方式也不同。自定義同步器在實現時只需要實現共享資源 state 的獲取與釋放方式即可,至於具體執行緒等待佇列的維護(如獲取資源失敗入隊/喚醒出隊等),AQS已經在頂層實現好了。
6.2.3. AQS底層使用了模板方法模式
同步器的設計是基於模板方法模式的,如果需要自定義同步器一般的方式是這樣(模板方法模式很經典的一個應用):
- 使用者繼承AbstractQueuedSynchronizer並重寫指定的方法。(這些重寫方法很簡單,無非是對於共享資源state的獲取和釋放)
- 將AQS組合在自定義同步元件的實現中,並呼叫其模板方法,而這些模板方法會呼叫使用者重寫的方法。
這和我們以往通過實現介面的方式有很大區別,這是模板方法模式很經典的一個運用。
AQS使用了模板方法模式,自定義同步器時需要重寫下面幾個AQS提供的模板方法:
isHeldExclusively()//該執行緒是否正在獨佔資源。只有用到condition才需要去實現它。 tryAcquire(int)//獨佔方式。嘗試獲取資源,成功則返回true,失敗則返回false。 tryRelease(int)//獨佔方式。嘗試釋放資源,成功則返回true,失敗則返回false。 tryAcquireShared(int)//共享方式。嘗試獲取資源。負數表示失敗;0表示成功,但沒有剩餘可用資源;正數表示成功,且有剩餘資源。 tryReleaseShared(int)//共享方式。嘗試釋放資源,成功則返回true,失敗則返回false。
預設情況下,每個方法都丟擲 UnsupportedOperationException
。 這些方法的實現必須是內部執行緒安全的,並且通常應該簡短而不是阻塞。AQS類中的其他方法都是final ,所以無法被其他類使用,只有這幾個方法可以被其他類使用。
以ReentrantLock為例,state初始化為0,表示未鎖定狀態。A執行緒lock()時,會呼叫tryAcquire()獨佔該鎖並將state+1。此後,其他執行緒再tryAcquire()時就會失敗,直到A執行緒unlock()到state=0(即釋放鎖)為止,其它執行緒才有機會獲取該鎖。當然,釋放鎖之前,A執行緒自己是可以重複獲取此鎖的(state會累加),這就是可重入的概念。但要注意,獲取多少次就要釋放多麼次,這樣才能保證state是能回到零態的。
再以CountDownLatch以例,任務分為N個子執行緒去執行,state也初始化為N(注意N要與執行緒個數一致)。這N個子執行緒是並行執行的,每個子執行緒執行完後countDown()一次,state會CAS(Compare and Swap)減1。等到所有子執行緒都執行完後(即state=0),會unpark()主呼叫執行緒,然後主呼叫執行緒就會從await()函式返回,繼續後餘動作。
一般來說,自定義同步器要麼是獨佔方法,要麼是共享方式,他們也只需實現tryAcquire-tryRelease
、tryAcquireShared-tryReleaseShared
中的一種即可。但AQS也支援自定義同步器同時實現獨佔和共享兩種方式,如ReentrantReadWriteLock
。
推薦兩篇 AQS 原理和相關原始碼分析的文章:
- http://www.cnblogs.com/waterystone/p/4920797.html
- https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html
6.3. AQS 元件總結
- Semaphore(訊號量)-允許多個執行緒同時訪問: synchronized 和 ReentrantLock 都是一次只允許一個執行緒訪問某個資源,Semaphore(訊號量)可以指定多個執行緒同時訪問某個資源。
- CountDownLatch (倒計時器): CountDownLatch是一個同步工具類,用來協調多個執行緒之間的同步。這個工具通常用來控制執行緒等待,它可以讓某一個執行緒等待直到倒計時結束,再開始執行。
- CyclicBarrier(迴圈柵欄): CyclicBarrier 和 CountDownLatch 非常類似,它也可以實現執行緒間的技術等待,但是它的功能比 CountDownLatch 更加複雜和強大。主要應用場景和 CountDownLatch 類似。CyclicBarrier 的字面意思是可迴圈使用(Cyclic)的屏障(Barrier)。它要做的事情是,讓一組執行緒到達一個屏障(也可以叫同步點)時被阻塞,直到最後一個執行緒到達屏障時,屏障才會開門,所有被屏障攔截的執行緒才會繼續幹活。CyclicBarrier預設的構造方法是 CyclicBarrier(int parties),其引數表示屏障攔截的執行緒數量,每個執行緒呼叫await()方法告訴 CyclicBarrier 我已經到達了屏障,然後當前執行緒被阻塞。
7 Reference
- 《深入理解 Java 虛擬機器》
- 《實戰 Java 高併發程式設計》
- 《Java併發程式設計的藝術》
- http://www.cnblogs.com/waterystone/p/4920797.html
- https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html
- https://www.journaldev.com/1076/java-threadlocal-example