Java修煉之道--併發程式設計
前言
在本文將總結多執行緒併發程式設計中的常見面試題,主要核心執行緒生命週期、執行緒通訊、併發包部分。主要分成 “併發程式設計” 和 “面試指南” 兩 部分,在面試指南中將討論併發相關面經。
參考資料:
- 《Java併發程式設計實戰》
第一部分:併發程式設計
1. 執行緒狀態轉換
這裡筆者也繪製了一張中文版的圖,點選檢視
新建(New)
建立後尚未啟動。
可執行(Runnable)
可能正在執行,也可能正在等待 CPU 時間片。
包含了作業系統執行緒狀態中的 執行(Running ) 和 就緒(Ready)。
阻塞(Blocking)
這個狀態下,是在多個執行緒有同步操作的場景,比如正在等待另一個執行緒的 synchronized 塊的執行釋放,或者可重入的 synchronized 塊裡別人呼叫 wait() 方法,也就是執行緒在等待進入臨界區。
阻塞可以分為:等待阻塞,同步阻塞,其他阻塞
無限期等待(Waiting)
等待其它執行緒顯式地喚醒,否則不會被分配 CPU 時間片。
進入方法 | 退出方法 |
---|---|
沒有設定 Timeout 引數的 Object.wait() 方法 | Object.notify() / Object.notifyAll() |
沒有設定 Timeout 引數的 Thread.join() 方法 | 被呼叫的執行緒執行完畢 |
LockSupport.park() 方法 | - |
限期等待(Timed Waiting)
無需等待其它執行緒顯式地喚醒,在一定時間之後會被系統自動喚醒。
呼叫 Thread.sleep() 方法使執行緒進入限期等待狀態時,常常用 “使一個執行緒睡眠
呼叫 Object.wait() 方法使執行緒進入限期等待或者無限期等待時,常常用 “掛起一個執行緒” 進行描述。
睡眠和掛起是用來描述行為,而阻塞和等待用來描述狀態。
阻塞和等待的區別在於,阻塞是被動的,它是在等待獲取一個排它鎖。而等待是主動的,通過呼叫 Thread.sleep() 和 Object.wait() 等方法進入。
進入方法 | 退出方法 |
---|---|
Thread.sleep() 方法 | 時間結束 |
設定了 Timeout 引數的 Object.wait() 方法 | 時間結束 / Object.notify() / Object.notifyAll() |
設定了 Timeout 引數的 Thread.join() 方法 | 時間結束 / 被呼叫的執行緒執行完畢 |
LockSupport.parkNanos() 方法 | - |
LockSupport.parkUntil() 方法 | - |
死亡(Terminated)
- 執行緒因為 run 方法正常退出而自然死亡
- 因為一個沒有捕獲的異常終止了 run 方法而意外死亡
2. Java實現多執行緒的方式及三種方式的區別
有三種使用執行緒的方法:
- 實現 Runnable 介面;
- 實現 Callable 介面;
- 繼承 Thread 類。
實現 Runnable 和 Callable 介面的類只能當做一個可以線上程中執行的任務,不是真正意義上的執行緒,因此最後還需要通過 Thread 來呼叫。可以說任務是通過執行緒驅動從而執行的。
實現 Runnable 介面
需要實現 run() 方法。
通過 Thread 呼叫 start() 方法來啟動執行緒。
public class MyRunnable implements Runnable {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}
實現 Callable 介面
與 Runnable 相比,Callable 可以有返回值,返回值通過 FutureTask 進行封裝。
public class MyCallable implements Callable<Integer> {
public Integer call() {
return 123;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}
繼承 Thread 類
同樣也是需要實現 run() 方法,因為 Thread 類也實現了 Runable 介面。
public class MyThread extends Thread {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
}
實現介面 VS 繼承 Thread
實現介面會更好一些,因為:
- Java 不支援多重繼承,因此繼承了 Thread 類就無法繼承其它類,但是可以實現多個介面;
- 類可能只要求可執行就行,繼承整個 Thread 類開銷過大。
三種方式的區別
- 實現 Runnable 介面可以避免 Java 單繼承特性而帶來的侷限;增強程式的健壯性,程式碼能夠被多個執行緒共享,程式碼與資料是獨立的;適合多個相同程式程式碼的執行緒區處理同一資源的情況。
- 繼承 Thread 類和實現 Runnable 方法啟動執行緒都是使用 start() 方法,然後 JVM 虛擬機器將此執行緒放到就緒佇列中,如果有處理機可用,則執行 run() 方法。
- 實現 Callable 介面要實現 call() 方法,並且執行緒執行完畢後會有返回值。其他的兩種都是重寫 run() 方法,沒有返回值。
3. 基礎執行緒機制
Executor
Executor 管理多個非同步任務的執行,而無需程式設計師顯式地管理執行緒的生命週期。這裡的非同步是指多個任務的執行互不干擾,不需要進行同步操作。
主要有三種 Executor:
- CachedThreadPool:一個任務建立一個執行緒;
- FixedThreadPool:所有任務只能使用固定大小的執行緒;
- SingleThreadExecutor:相當於大小為 1 的 FixedThreadPool。
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
executorService.execute(new MyRunnable());
}
executorService.shutdown();
}
為什麼引入Executor執行緒池框架?
new Thread() 的缺點
- 每次 new Thread() 耗費效能
- 呼叫 new Thread() 建立的執行緒缺乏管理,被稱為野執行緒,而且可以無限制建立,之間相互競爭,會導致過多佔用系統資源導致系統癱瘓。
- 不利於擴充套件,比如如定時執行、定期執行、執行緒中斷
採用執行緒池的優點
- 重用存在的執行緒,減少物件建立、消亡的開銷,效能佳
- 可有效控制最大併發執行緒數,提高系統資源的使用率,同時避免過多資源競爭,避免堵塞
- 提供定時執行、定期執行、單執行緒、併發數控制等功能
Daemon(守護執行緒)
Java 中有兩類執行緒:User Thread (使用者執行緒)、Daemon Thread (守護執行緒)
使用者執行緒即執行在前臺的執行緒,而守護執行緒是執行在後臺的執行緒。 守護執行緒作用是為其他前臺執行緒的執行提供便利服務,而且僅在普通、非守護執行緒仍然執行時才需要,比如垃圾回收執行緒就是一個守護執行緒。當 JVM 檢測僅剩一個守護執行緒,而使用者執行緒都已經退出執行時,JVM 就會退出,因為沒有如果沒有了被守護這,也就沒有繼續執行程式的必要了。如果有非守護執行緒仍然存活,JVM 就不會退出。
守護執行緒並非只有虛擬機器內部提供,使用者在編寫程式時也可以自己設定守護執行緒。使用者可以用 Thread 的 setDaemon(true) 方法設定當前執行緒為守護執行緒。
雖然守護執行緒可能非常有用,但必須小心確保其他所有非守護執行緒消亡時,不會由於它的終止而產生任何危害。因為你不可能知道在所有的使用者執行緒退出執行前,守護執行緒是否已經完成了預期的服務任務。一旦所有的使用者執行緒退出了,虛擬機器也就退出執行了。 因此,不要在守護執行緒中執行業務邏輯操作(比如對資料的讀寫等)。
另外有幾點需要注意:
- setDaemon(true) 必須在呼叫執行緒的 start() 方法之前設定,否則會跑出 IllegalThreadStateException 異常。
- 在守護執行緒中產生的新執行緒也是守護執行緒。
- 不要認為所有的應用都可以分配給守護執行緒來進行服務,比如讀寫操作或者計算邏輯。
守護執行緒是程式執行時在後臺提供服務的執行緒,不屬於程式中不可或缺的部分。
當所有非守護執行緒結束時,程式也就終止,同時會殺死所有守護執行緒。
main() 屬於非守護執行緒。
使用 setDaemon() 方法將一個執行緒設定為守護執行緒。
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.setDaemon(true);
}
sleep()
Thread.sleep(millisec) 方法會休眠當前正在執行的執行緒,millisec 單位為毫秒。
sleep() 可能會丟擲 InterruptedException,因為異常不能跨執行緒傳播回 main() 中,因此必須在本地進行處理。執行緒中丟擲的其它異常也同樣需要在本地進行處理。
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
yield()
對靜態方法 Thread.yield() 的呼叫聲明瞭當前執行緒已經完成了生命週期中最重要的部分,可以切換給其它執行緒來執行。該方法只是對執行緒排程器的一個建議,而且也只是建議具有相同優先順序的其它執行緒可以執行。
public void run() {
Thread.yield();
}
執行緒阻塞
執行緒可以阻塞於四種狀態:
- 當執行緒執行 Thread.sleep() 時,它一直阻塞到指定的毫秒時間之後,或者阻塞被另一個執行緒打斷;
- 當執行緒碰到一條 wait() 語句時,它會一直阻塞到接到通知 notify()、被中斷或經過了指定毫秒時間為止(若制定了超時值的話)
- 執行緒阻塞與不同 I/O 的方式有多種。常見的一種方式是 InputStream 的 read() 方法,該方法一直阻塞到從流中讀取一個位元組的資料為止,它可以無限阻塞,因此不能指定超時時間;
- 執行緒也可以阻塞等待獲取某個物件鎖的排他性訪問許可權(即等待獲得 synchronized 語句必須的鎖時阻塞)。
注意,並非所有的阻塞狀態都是可中斷的,以上阻塞狀態的前兩種可以被中斷,後兩種不會對中斷做出反應
4. 中斷
一個執行緒執行完畢之後會自動結束,如果在執行過程中發生異常也會提前結束。
InterruptedException
通過呼叫一個執行緒的 interrupt() 來中斷該執行緒,如果該執行緒處於阻塞、限期等待或者無限期等待狀態,那麼就會丟擲 InterruptedException,從而提前結束該執行緒。但是不能中斷 I/O 阻塞和 synchronized 鎖阻塞。
對於以下程式碼,在 main() 中啟動一個執行緒之後再中斷它,由於執行緒中呼叫了 Thread.sleep() 方法,因此會丟擲一個 InterruptedException,從而提前結束執行緒,不執行之後的語句。
public class InterruptExample {
private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at InterruptExample.lambda$main$0(InterruptExample.java:5)
at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
interrupted()
如果一個執行緒的 run() 方法執行一個無限迴圈,並且沒有執行 sleep() 等會丟擲 InterruptedException 的操作,那麼呼叫執行緒的 interrupt() 方法就無法使執行緒提前結束。
但是呼叫 interrupt() 方法會設定執行緒的中斷標記,此時呼叫 interrupted() 方法會返回 true。因此可以在迴圈體中使用 interrupted() 方法來判斷執行緒是否處於中斷狀態,從而提前結束執行緒。
public class InterruptExample {
private static class MyThread2 extends Thread {
@Override
public void run() {
while (!interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
Thread end
Executor 的中斷操作
呼叫 Executor 的 shutdown() 方法會等待執行緒都執行完畢之後再關閉,但是如果呼叫的是 shutdownNow() 方法,則相當於呼叫每個執行緒的 interrupt() 方法。
以下使用 Lambda 建立執行緒,相當於建立了一個匿名內部執行緒。
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
executorService.shutdownNow();
System.out.println("Main run");
}
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at ExecutorInterruptExample.lambda$main$0(ExecutorInterruptExample.java:9)
at ExecutorInterruptExample$$Lambda$1/1160460865.run(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)
如果只想中斷 Executor 中的一個執行緒,可以通過使用 submit() 方法來提交一個執行緒,它會返回一個 Future<?> 物件,通過呼叫該物件的 cancel(true) 方法就可以中斷執行緒。
Future<?> future = executorService.submit(() -> {
// ..
});
future.cancel(true);
5. 互斥同步
Java 提供了兩種鎖機制來控制多個執行緒對共享資源的互斥訪問,第一個是 JVM 實現的 synchronized,而另一個是 JDK 實現的 ReentrantLock。
synchronized
1. 同步一個程式碼塊
public void func() {
synchronized (this) {
// ...
}
}
它只作用於同一個物件,如果呼叫兩個物件上的同步程式碼塊,就不會進行同步。
對於以下程式碼,使用 ExecutorService 執行了兩個執行緒,由於呼叫的是同一個物件的同步程式碼塊,因此這兩個執行緒會進行同步,當一個執行緒進入同步語句塊時,另一個執行緒就必須等待。
public class SynchronizedExample {
public void func1() {
synchronized (this) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func1());
executorService.execute(() -> e1.func1());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
對於以下程式碼,兩個執行緒呼叫了不同物件的同步程式碼塊,因此這兩個執行緒就不需要同步。從輸出結果可以看出,兩個執行緒交叉執行。
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
SynchronizedExample e2 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func1());
executorService.execute(() -> e2.func1());
}
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9
2. 同步一個方法
public synchronized void func () {
// ...
}
它和同步程式碼塊一樣,作用於同一個物件。
3. 同步一個類
public void func() {
synchronized (SynchronizedExample.class) {
// ...
}
}
作用於整個類,也就是說兩個執行緒呼叫同一個類的不同物件上的這種同步語句,也會進行同步。
public class SynchronizedExample {
public void func2() {
synchronized (SynchronizedExample.class) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
SynchronizedExample e2 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func2());
executorService.execute(() -> e2.func2());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
4. 同步一個靜態方法
- 非靜態同步函式的鎖是:this
- 靜態的同步函式的鎖是:位元組碼物件
public synchronized static void fun() {
// ...
}
作用於整個類。
ReentrantLock
重入鎖(ReentrantLock)是一種遞迴無阻塞的同步機制。
public class LockExample {
private Lock lock = new ReentrantLock();
public void func() {
lock.lock();
try {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
} finally {
lock.unlock(); // 確保釋放鎖,從而避免發生死鎖。
}
}
}
public static void main(String[] args) {
LockExample lockExample = new LockExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> lockExample.func());
executorService.execute(() -> lockExample.func());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
ReentrantLock 是 java.util.concurrent(J.U.C)包中的鎖,相比於 synchronized,它多了以下高階功能:
1. 等待可中斷
當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,改為處理其他事情。
2. 可實現公平鎖
公平鎖是指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖。
synchronized 中的鎖是非公平的,ReentrantLock 預設情況下也是非公平的,但可以通過帶布林值的建構函式要求使用公平鎖。
3. 鎖繫結多個條件
一個 ReentrantLock 物件可以同時繫結多個 Condition 物件。
synchronized 和 ReentrantLock 比較
1. 鎖的實現
synchronized 是 JVM 實現的,而 ReentrantLock 是 JDK 實現的。
2. 效能
新版本 Java 對 synchronized 進行了很多優化,例如自旋鎖等。目前來看它和 ReentrantLock 的效能基本持平了,因此效能因素不再是選擇 ReentrantLock 的理由。synchronized 有更大的效能優化空間,應該優先考慮 synchronized。
3. 功能
ReentrantLock 多了一些高階功能。
4. 使用選擇
除非需要使用 ReentrantLock 的高階功能,否則優先使用 synchronized。這是因為 synchronized 是 JVM 實現的一種鎖機制,JVM 原生地支援它,而 ReentrantLock 不是所有的 JDK 版本都支援。並且使用 synchronized 不用擔心沒有釋放鎖而導致死鎖問題,因為 JVM 會確保鎖的釋放。
synchronized與lock的區別,使用場景。看過synchronized的原始碼沒?
- (用法)synchronized(隱式鎖):在需要同步的物件中加入此控制,synchronized 可以加在方法上,也可以加在特定程式碼塊中,括號中表示需要鎖的物件。
- (用法)lock(顯示鎖):需要顯示指定起始位置和終止位置。一般使用 ReentrantLock 類做為鎖,多個執行緒中必須要使用一個 ReentrantLock 類做為物件才能保證鎖的生效。且在加鎖和解鎖處需要通過 lock() 和 unlock() 顯示指出。所以一般會在 finally 塊中寫 unlock() 以防死鎖。
- (效能)synchronized 是託管給 JVM 執行的,而 lock 是 Java 寫的控制鎖的程式碼。在 Java1.5 中,synchronize 是效能低效的。因為這是一個重量級操作,需要呼叫操作介面,導致有可能加鎖消耗的系統時間比加鎖以外的操作還多。相比之下使用 Java 提供的 Lock 物件,效能更高一些。但是到了 Java1.6 ,發生了變化。synchronize 在語義上很清晰,可以進行很多優化,有適應自旋,鎖消除,鎖粗化,輕量級鎖,偏向鎖等等。導致 在 Java1.6 上 synchronize 的效能並不比 Lock 差。
- (機制)synchronized 原始採用的是 CPU 悲觀鎖機制,即執行緒獲得的是獨佔鎖。獨佔鎖意味著其他執行緒只能依靠阻塞來等待執行緒釋放鎖。Lock 用的是樂觀鎖方式。所謂樂觀鎖就是,每次不加鎖而是假設沒有衝突而去完成某項操作,如果因為衝突失敗就重試,直到成功為止。樂觀鎖實現的機制就是 CAS 操作(Compare and Swap)。
什麼是CAS
蘑菇街面試,這裡簡單論述一下
入門例子
在 Java 併發包中有這樣一個包,java.util.concurrent.atomic,該包是對 Java 部分資料型別的原子封裝,在原有資料型別的基礎上,提供了原子性的操作方法,保證了執行緒安全。下面以 AtomicInteger 為例,來看一下是如何實現的。
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
public final int decrementAndGet() {
for (;;) {
int current = get