1. 程式人生 > >Java修煉之道--併發程式設計

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