併發與多執行緒基礎

Anwen發表於2019-02-19

個人技術部落格:www.zhenganwen.top

建立並啟動執行緒

熟悉Java的人都能很容易地寫出如下程式碼:

public static class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("MyThread is running...");
    }
}

public static void main(String[] args) {
    Thread t = new MyThread();
    t.start();
}
複製程式碼

執行緒的生命週期

這是一個面試常問的基礎問題,你應該肯定的回答執行緒只有五種狀態,分別是:新建狀態、就緒狀態、執行狀態、阻塞狀態、終止狀態。

image

就緒狀態和執行狀態

由於Scheduler(排程器)的時間片分配演算法,每個Running的執行緒會執行多長時間是未知的,因此執行緒能夠在Runnable和Running之間來回轉換。阻塞狀態的執行緒必須先進入就緒狀態才能進入執行狀態

執行狀態和阻塞狀態

Running執行緒在主動呼叫Thread.sleep()obj.wait()thread.join()時會進入TIMED-WAITINGWAITING狀態並主動讓出CPU執行權。如果是TIMED-WAITING,那麼在經過一定的時間之後會主動返回並進入Runnable狀態等待時間片的分配。

thread.join()的底層就是當前執行緒不斷輪詢thread是否存活,如果存活就不斷地wait(0)

Running執行緒在執行過程中如果遇到了臨界區(synchronized修飾的方法或程式碼塊)並且需要獲取的鎖正在被其他執行緒佔用,那麼他會主動將自己掛起並進入BLOCKED狀態。

阻塞狀態和就緒狀態

如果持有鎖的執行緒退出臨界區,那麼在該鎖上等待的執行緒都會被喚醒並進入就緒狀態,但只有搶到鎖的執行緒會進入執行狀態,其他沒有搶到鎖的執行緒仍將進入阻塞狀態。

如果某個執行緒呼叫了objnotify/notifyAll方法,那麼在該執行緒退出臨界區時(呼叫wait/notify必須先通過synchronized獲取物件的鎖),被喚醒的等待在obj.wait上的執行緒才會從阻塞狀態進入就緒狀態獲取objmonitor,並且只有搶到monitor的執行緒才會從obj.wait返回,而沒有搶到的執行緒仍舊會阻塞在obj.wait

終止狀態

在執行狀態下的執行緒執行完run方法或阻塞狀態下的執行緒被interrupt時會進入終止狀態,隨後會被銷燬。

start原始碼剖析

public synchronized void start() {
    if (threadStatus != 0)
        throw new IllegalThreadStateException();
    group.add(this);
    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {}
    }
}

private native void start0();
複製程式碼

start方法主要做了三件事:

  1. 將當前執行緒物件加入其所屬的執行緒組(執行緒組在後續將會介紹)
  2. 呼叫start0,這是一個native方法,在往期文章《Java執行緒是如何實現的?》一文中談到執行緒的排程將交給LWP,這裡的啟動新建執行緒同樣屬於此範疇。因此我們能夠猜到此JNI(Java Native Interface)呼叫將會新建一個執行緒(LWP)並執行該執行緒物件的run方法
  3. 將該執行緒物件的started狀態置為true表示已被啟動過。正如初學執行緒時老師所講的,執行緒的start只能被呼叫一次,重複呼叫會報錯就是通過這個變數實現的。

為什麼要引入Runnable

單一職責原則

我們將通過Thread來模擬這樣一個場景:銀行多視窗叫號。從而思考已經有Thread了為什麼還要引入Runnable

首先我們需要一個視窗執行緒模擬叫號(視窗叫號,相應號碼的顧客到對應視窗辦理業務)的過程:

public class TicketWindow extends Thread {

    public static final Random RANDOM = new Random(System.currentTimeMillis());
    private static final int MAX = 20;
    private int counter;
    private String windowName;

    public TicketWindow(String windowName) {
        super(windowName);
        counter = 0;
        this.windowName = windowName;
    }

    @Override
    public void run() {
        System.out.println(windowName + " start working...");
        while (counter < MAX){
            System.out.println(windowName + ": It's the turn to number " + counter++);
            //simulate handle the business
            try {
                Thread.sleep(RANDOM.nextInt(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
複製程式碼

然後編寫一個叫號客戶端模擬四個視窗同時叫號:

public class WindowThreadClient {
    public static void main(String[] args) {
        Stream.of("Window-1","Window-2","Window-3","Window-4").forEach(
            windowName -> new TicketWindow(windowName).start()
        );
    }
}
複製程式碼

你會發現同一個號碼被叫了四次,顯然這不是我們想要的。正常情況下應該是四個視窗共享一個叫號系統,視窗只負責辦理業務而叫號則應該交給叫號系統,這是典型的OOP中的單一職責原則。

我們將執行緒和要執行的任務耦合在了一起,因此出現瞭如上所述的尷尬情況。執行緒的職責就是執行任務,它有它自己的執行時狀態,我們不應該將要執行的任務的相關狀態(如本例中的counterwindowName)將執行緒耦合在一起,而應該將業務邏輯單獨抽取出來作為一個邏輯執行單元,當需要執行時提交給執行緒即可。於是就有了Runnable介面:

public interface Runnable {
    public abstract void run();
}
複製程式碼

因此我們可以將之前的多視窗叫號改造一下:

public class TicketWindowRunnable implements Runnable {

    public static final Random RANDOM = new Random(System.currentTimeMillis());
    private static final int MAX = 20;
    private int counter = 0;

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " start working...");
        while (counter < MAX){
            System.out.println(Thread.currentThread().getName()+ ": It's the turn to number " + counter++);
            //simulate handle the business
            try {
                Thread.sleep(RANDOM.nextInt(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
複製程式碼

測試類:

public class WindowThreadClient {
    public static void main(String[] args) {
        TicketWindowRunnable ticketWindow = new TicketWindowRunnable();
        Stream.of("Window-1", "Window-2", "Window-3", "Window-4").forEach(
                windowName -> new Thread(ticketWindow, windowName).start()
        );
    }
}
複製程式碼

如此你會發現沒有重複的叫號了。但是這個程式並不是執行緒安全的,因為有多個執行緒同時更改windowRunnable中的counter變數,由於本節主要闡述Runnable的作用,因此暫時不對此展開討論。

策略模式和函數語言程式設計

Thread中的run通過介面的方式暴露出來還有一個好處就是對策略模式和函數語言程式設計友好。

首先簡單介紹一下策略模式,假設我們現在需要計算一個員工的個人所得稅,於是我們寫了如下工具類,傳入基本工資和獎金即可呼叫calculate得出應納稅額:

public class TaxCalculator {

    private double salary;
    private double bonus;

    public TaxCalculator(double base, double bonus) {
        this.salary = base;
        this.bonus = bonus;
    }

    public double calculate() {
        return salary * 0.03 + bonus * 0.1;
    }
}
複製程式碼

這樣寫有什麼問題?我們將應納稅額的計算寫死了:salary * 0.03 + bonus * 0.1,而稅率並非一層不變的,客戶提出需求變動也是常有的事!難道每次需求變更我們都要手動更改這部分程式碼嗎?

這時策略模式來幫忙:當我們的需求的輸入是不變的,但輸出需要根據不同的策略做出相應的調整時,我們可以將這部分的邏輯抽取成一個介面:

public interface TaxCalculateStrategy {
    public double calculate(double salary, double bonus);
}
複製程式碼

具體策略實現:

public class SimpleTaxCalculateStrategy implements TaxCalculateStrategy {
    @Override
    public double calculate(double salary, double bonus) {
        return salary * 0.03 + bonus * 0.1;
    }
}
複製程式碼

而業務程式碼僅呼叫介面:

public class TaxCalculator {

    private double salary;
    private double bonus;
    private TaxCalculateStrategy taxCalculateStrategy;

    public TaxCalculator(double base, double bonus, TaxCalculateStrategy taxCalculateStrategy) {
        this.salary = base;
        this.bonus = bonus;
        this.taxCalculateStrategy = taxCalculateStrategy;
    }

    public double calculate() {
        return taxCalculateStrategy.calculate(salary, bonus);
    }
}
複製程式碼

Thread中的邏輯執行單元run抽取成一個介面Runnable有著異曲同工之妙。因為實際業務中,需要提交給執行緒執行的任務我們是無法預料的,抽取成一個介面之後就給我們的應用程式帶來了很大的靈活性。

另外在JDK1.8中引入了函數語言程式設計和lambda表示式,使用策略模式對這個特性也是很友好的。還是藉助上面這個例子,如果計算規則變成了(salary + bonus) * 1.5,可能我們需要新增一個策略類:

public class AnotherTaxCalculatorStrategy implements TaxCalculateStrategy {
    @Override
    public double calculate(double salary, double bonus) {
        return (salary + bonus) * 1.5;
    }
}
複製程式碼

在JDK增加內部類語法糖之後,可以使用匿名內部類省去建立新類的開銷:

public class TaxCalculateTest {
    public static void main(String[] args) {
        TaxCalculator taxCalaculator = new TaxCalculator(5000,1500, new TaxCalculateStrategy(){
            @Override
            public double calculate(double salary, double bonus) {
                return (salary + bonus) * 1.5;
            }
        });
    }
}
複製程式碼

但是在JDK新增函數語言程式設計後,可以更加簡潔明瞭:

public class TaxCalculateTest {
    public static void main(String[] args) {
        TaxCalculator taxCalaculator = new TaxCalculator(5000, 1500, (salary, bonus) -> (salary + bonus) * 1.5);
    }
}
複製程式碼

這對只有一個抽象方法runRunnable介面來說是同樣適用的。

構造Thread物件,你也許不知道的幾件事

檢視Thread的構造方法,追溯到init方法(略有刪減):

Thread parent = currentThread();
if (g == null) {
    if (g == null) {
        g = parent.getThreadGroup();
    }
}

this.group = g;
this.daemon = parent.isDaemon();
this.priority = parent.getPriority();

this.target = target;
setPriority(priority);
this.stackSize = stackSize;

tid = nextThreadID();
複製程式碼
  1. g是當前物件的ThreadGroup2~8就是在設定當前物件所屬的執行緒組,如果在new Thread時沒有顯式指定,那麼預設將父執行緒(當前執行new Thread的執行緒)執行緒組設定為自己的執行緒組。

  2. 9~10行,從父執行緒中繼承兩個狀態:是否是守護執行緒、優先順序是多少。當然了,在new Thread之後可以通過thread.setDeamonthread.setPriority進行自定義

  3. 12行,如果是通過new Thread(Runnable target)方式建立的執行緒,那麼取得傳入的Runnable target,執行緒啟動時呼叫的run中會執行不空的targetrun方法。理論上來講建立執行緒有三種方式:

    • 實現Runnable介面MyRunnable,通過new Thread(myRunnable)執行MyRunnable中的run
    • 繼承Thread並重寫run,通過new MyThread()執行重寫的run
    • 繼承Thread並重寫run,仍可向構造方法傳入Runnable實現類例項:new MyThread(myRunnable),但是隻會執行MyThread中重寫的run,不會受myRunnable的任何影響。這種建立執行緒的方式有很大的歧義,除了面試官可能會拿來為難你一下,不建議這樣使用
  4. 設定執行緒優先順序,一共有10個優先順序別對應取值[0,9],取值越大優先順序越大。但這一引數具有平臺依賴性,這意味著可能在有的作業系統上可能有效,而在有的作業系統上可能無效,因為Java執行緒是直接對映到核心執行緒的,因此具體的排程仍要看作業系統。

  5. 設定棧大小。這個大小指的是棧的記憶體大小而非棧所能容納的最大棧幀數目,每一個方法的呼叫和返回對應一個棧幀從執行緒的虛擬機器棧中入棧到出棧的過程,在下一節中會介紹這個引數。虛擬機器棧知識詳見《深入理解Java虛擬機器(第二版)》第二章。

  6. 設定執行緒的ID,是執行緒的唯一標識,比如偏向鎖偏向執行緒時會在物件頭的Mark Word中存入該執行緒的ID(偏向鎖可見《併發程式設計的藝術》和《深入理解Java虛擬機器》第五章)。

    通過nextThreadID會發現是一個static synchronized方法,原子地取得執行緒序列號threadSeqNumber自增後的值:

    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getId()); //11
        }).start();
    }
    複製程式碼

    為什麼main中建立的第一個執行緒的ID是11(意味著他是JVM啟動後建立的第11個執行緒)呢?這因為在JVM在執行main時會啟動JVM程式的第一個執行緒(叫做main執行緒),並且會啟動一些守護執行緒,比如GC執行緒。

多執行緒與JVM記憶體結構

JVM記憶體結構

image

這裡要注意的是每個執行緒都有一個私有的虛擬機器棧。所有執行緒的棧都存放在JVM執行時資料區域的虛擬機器棧區域中。

棧幀記憶體結構

image

stackSize引數

Thread提供了一個可以設定stackSize的過載構造方法:

public Thread(ThreadGroup group,
              Runnable target,
              String name,
              long stackSize)
複製程式碼

官方文件對該引數的描述如下:

The stack size is the approximate number of bytes of address space that the virtual machine is to allocate for this thread's stack. The effect of the stackSize parameter, if any, is highly platform dependent.

你能通過指定stackSize引數近似地指定虛擬機器棧的記憶體大小(注意:是記憶體大小即位元組數而不是棧中所能容納的最大棧幀數目,而且這個大小指的是該執行緒的棧大小而並非是整個虛擬機器棧區的大小)。且該引數具有高度的平臺依賴性,也就是說在各個作業系統上,同樣的參數列現出來的效果有所不同。

On some platforms, specifying a higher value for the stackSize parameter may allow a thread to achieve greater recursion depth before throwing a StackOverflowError. Similarly, specifying a lower value may allow a greater number of threads to exist concurrently without throwing an OutOfMemoryError (or other internal error). The details of the relationship between the value of the stackSize parameter and the maximum recursion depth and concurrency level are platform-dependent. On some platforms, the value of the stackSize parameter may have no effect whatsoever.

在一些平臺上,為stackSize指定一個較大的值,能夠允許執行緒在丟擲棧溢位異常前達到較大的遞迴深度(因為方法棧幀的大小在編譯期可知,以區域性變數表為例,基本型別變數中只有longdouble佔8個位元組,其餘的作4個位元組處理,引用型別根據虛擬機器是32位還是64位而佔4個位元組或8個位元組。如此的話棧越大,棧所能容納的最大棧幀數目也即遞迴深度也就越大)。類似的,指定一個較小的stackSize能夠讓更多的執行緒共存而避免OOM異常(有的讀者可能會異或,棧較小怎麼還不容易丟擲OOM異常了呢?不是應該棧較小,記憶體更不夠用,更容易OOM嗎?其實單執行緒環境下,只可能發生棧溢位而不會發生OOM,因為每個方法對應的棧幀大小在編譯器就可知了,執行緒啟動時會從虛擬機器棧區劃分一塊記憶體作為棧的大小,因此無論是壓入的棧幀太多還是將要壓入的棧幀太大都只會導致棧無法繼續容納棧幀而丟擲棧溢位。那麼什麼時候回丟擲OOM呢。對於虛擬機器棧區來說,如果沒有足夠的記憶體劃分出來作為新建執行緒的棧記憶體時,就會丟擲OOM了。這就不難理解了,有限的程式記憶體除去堆記憶體、方法區、JVM自身所需記憶體之後剩下的虛擬機器棧是有限的,分配給每個棧的越少,能夠並存的執行緒自然就越多了)。最後,在一些平臺上,無論將stackSize設定為多大都可能不會起到任何作用。

The virtual machine is free to treat the stackSize parameter as a suggestion. If the specified value is unreasonably low for the platform, the virtual machine may instead use some platform-specific minimum value; if the specified value is unreasonably high, the virtual machine may instead use some platform-specific maximum. Likewise, the virtual machine is free to round the specified value up or down as it sees fit (or to ignore it completely).

虛擬機器會將stackSize視為一種建議,在棧大小的設定上仍有一定的話語權。如果給定的值太小,虛擬機器會將棧大小設定為平臺對應的最小棧大小;相應的如果給定的值太大,則會設定成平臺對應的最大棧大小。又或者,虛擬機器能夠按照給定的值向上或向下取捨以設定一個合適的棧大小(甚至虛擬機器會忽略它)。

Due to the platform-dependent nature of the behavior of this constructor, extreme care should be exercised in its use. The thread stack size necessary to perform a given computation will likely vary from one JRE implementation to another. In light of this variation, careful tuning of the stack size parameter may be required, and the tuning may need to be repeated for each JRE implementation on which an application is to run.

由於此建構函式的平臺依賴特性,在使用時需要格外小心。執行緒棧的實際大小的計算規則會因為JVM的不同實現而有不同的表現。鑑於這種變化,可能需要仔細調整堆疊大小引數,並且對於應用程式使用的不同的JVM實現需要有不同的調整。

Implementation note: Java platform implementers are encouraged to document their implementation's behavior with respect to the stackSizeparameter.

不指定stackSize時棧溢位時方法呼叫深度:

public class StackSizeTest {
    public static int counter = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                count();
            } catch (StackOverflowError e) {
                System.out.println(counter);	// result -> 35473
            }
        }).start();
    }

    public static void count() {
        counter++;
        count();
    }
}
複製程式碼

指定stackSize為10KB

顯式指定stackSize之後顯著地影響了執行緒棧的大小,呼叫深度由原來的35473變成了296

public class StackSizeTest {
    public static int counter = 0;
    public static void main(String[] args) {
        new Thread(null,() -> {
            try {
                count();
            } catch (StackOverflowError e) {
                System.out.println(counter);
            }
        },"test-stack-size",10 * 1024).start(); //stackSize -> 10KB  result -> 296
    }

    public static void count() {
        counter++;
        count();
    }
}
複製程式碼

通過調整區域性變數大小來調整棧幀大小

要想改變棧幀的大小,通過增加區域性變數即可實現。以下通過增加多個long變數(一個佔8個位元組),較上一次的測試,方法呼叫深度又有明顯的減小:

public class StackSizeTest {
    public static int counter = 0;
    public static void main(String[] args) {
        new Thread(null,() -> {
            try {
                count();
            } catch (StackOverflowError e) {
                System.out.println(counter);
            }
        },"test-stack-size",10 * 1024).start(); //stackSize -> 10KB  result -> 65
    }

    public static void count() {
        long a,b,c,d,e,f,g,h,j,k,l,m,n,o,p,q;
        counter++;
        count();
    }
}
複製程式碼

守護執行緒及其使用場景

通過thread.setDaemon(true)可將新建後的執行緒設定為守護執行緒,必須線上程啟動前(thread.start)設定才有效。

  • 守護執行緒的特性就是在其父執行緒終止時,守護執行緒也會跟著銷燬。
  • JVM只有在最後一個非守護執行緒終止時才會退出。

心跳檢測

叢集架構中,通常需要心跳檢測機制。如果應用程式開一條非守護執行緒來做心跳檢測,那麼可能會出現應用主程式都終止執行了但心跳檢測執行緒仍在工作的情況,這時JVM會因為仍有非守護執行緒在工作而繼續佔用系統的CPU、記憶體資源,這顯然是不應該的。

下列程式碼簡單模仿了這一場景:

public class HeartCheck {
    public static void main(String[] args) {

        // worker thread
        new Thread(()->{

            // start the heart-check thread first
            Thread heartCheck = new Thread(()->{
                // do interval-automatic heart check and notify the parent thread when heart check has error
                while (true) {
                    System.out.println("do heart check");
                    try {
                        Thread.sleep(100);	//interval
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            heartCheck.setDaemon(true);
            heartCheck.start();

            // simulate work
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}
複製程式碼

join方法詳解

原始碼剖析

直接上原始碼:

public final synchronized void join(long millis) throws InterruptedException {
    long base = System.currentTimeMillis();
    long now = 0;

    if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }

    if (millis == 0) {
        while (isAlive()) {
            wait(0);
        }
    } else {
        while (isAlive()) {
            long delay = millis - now;
            if (delay <= 0) {
                break;
            }
            wait(delay);
            now = System.currentTimeMillis() - base;
        }
    }
}
複製程式碼

如果呼叫某個執行緒threadjoin(),會分發到join(0),執行上述的第10~12行,只要當前執行緒獲取到了CPU執行權就會輪詢thread的執行狀態(isAlive是個native方法,但我們能夠猜到它的作用就是檢測thread是否存活,即不是Terminated狀態),一旦發現thread仍然存活就會釋放CPU執行權(通過wait(0)的方式),等下一輪的輪詢,直到thread進入終止狀態,那麼當前執行緒將從thread.join()返回。

一定要區分清楚,呼叫thread.join()阻塞的是當前執行緒,不會對thread執行緒造成任何影響。

join提供了一個過載的限時等待方法(這是一個經典的超時等待模型:只有當條件滿足或者已超過等待時限時才返回),這也是為了避免當前執行緒陷入永久等待的困境,能夠在等待一段時間發現目標執行緒仍未執行完後自動返回。

join有一個比較好玩的地方就是如果執行緒呼叫它自己的join方法,那麼該執行緒將無限wait下去,因為:Thread.currentThread().join()會等待當前執行緒執行完,而當前執行緒正在呼叫當前執行緒的join即等當前執行緒執行完……就讓他自個兒去慢慢玩兒吧~

join使用場景

分步驟執行任務

比如電商網站中的使用者行為日誌,可能需要經過聚合、篩選、分析、歸類等步驟加工,最後再存入資料庫。並且這些步驟的執行必須是按部就班的層層加工,那麼一個步驟就必須等到上一個步驟結束後拿到結果在開始,這時就可以利用join做到這點。

下列程式碼簡單模仿了此場景:

public class StepByStep {

    public static void main(String[] args) throws InterruptedException {
        Thread step1 = new Thread(() -> {
            System.out.println("start capture data...");
            //simulate capture data
            try {
                Thread.sleep(1000);
                System.out.println("capture done.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        step1.start();

        Thread step2 = new Thread(() -> {
            try {
                step1.join();
                System.out.println("start screen out the data...");
                Thread.sleep(1000);
                System.out.println("screen out done.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        step2.start();

        Thread step3 = new Thread(() -> {
            try {
                step2.join();
                System.out.println("start analyze the data...");
                Thread.sleep(1000);
                System.out.println("analyze done.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        step3.start();

        Thread step4 = new Thread(() -> {
            try {
                step3.join();
                System.out.println("start classify the data");
                Thread.sleep(1000);
                System.out.println("classify done.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        step4.start();

        step4.join();

        System.out.println("write into database");
    }
}
複製程式碼

值得注意的是,如果呼叫未啟動執行緒的join,將會立即返回:

public class StepByStep {

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {

        });
        t.join();
    }
}
複製程式碼

Fork/Join模型

有時任務量太大且任務是可分的(子任務之間沒有上例的依賴關係),那麼我們不妨將任務拆分成互不相干的子任務(這一步叫做Fork),分別為各個子任務分配一個單獨執行緒從而實現子任務並行執行,提高執行效率,最後將個子任務的結果整合起來做最後的加工(主執行緒就可以使用join來等待各個子任務執行緒的執行結果,從而最後做一個彙總)。JDK8提供的StreamForkJoin框架都有此模型的身影。

異常感知

我們可以通過join的過載方法提供的限時等待,在目標任務執行時間過長時自動返回,從而採取其他彌補策略,而不至於老是傻傻地等著。

interrupt詳解

public void interrupt() {
    if (this != Thread.currentThread())
        checkAccess();

    synchronized (blockerLock) {
        Interruptible b = blocker;
        if (b != null) {
            interrupt0();           // Just to set the interrupt flag
            b.interrupt(this);
            return;
        }
    }
    interrupt0();
}
複製程式碼

這裡有一個細節,interrupt首先會設定執行緒的中斷標誌位,然後再打斷它。

檢視官方文件:

If this thread is blocked in an invocation of the wait(), wait(long), or wait(long, int) methods of the Object class, or of the join(), join(long), join(long, int), sleep(long), or sleep(long, int), methods of this class, then its interrupt status will be cleared and it will receive an InterruptedException.

If none of the previous conditions hold then this thread's interrupt status will be set.

Interrupting a thread that is not alive need not have any effect.

由此我們可以提取三點資訊:

  1. Timed-Waiting/Waiting中的執行緒被打斷後首先會清除它的中斷標誌位,然後再丟擲InterruptedException。因此被中斷的執行緒進入
  2. 處於執行狀態(Runnable/Running)下的執行緒不會被打斷,但是其中斷標誌位會被設定,即呼叫它的isInterrupted將返回true
  3. 對終止狀態下的執行緒呼叫interrupt不會產生任何效果。

isInterrupted

Tests whether this thread has been interrupted. The interrupted status of the thread is unaffected by this method.

A thread interruption ignored because a thread was not alive at the time of the interrupt will be reflected by this method returning false.

測試執行緒是否被中斷過,該方法的呼叫不會改變執行緒的中斷標誌位。對一個終止狀態下的執行緒呼叫過interrupt並不會導致該方法返回true

於是我們可以使用isInterrupted來測試一下上面提取的3個結論:

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {

        }

        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    t1.start();
    t1.interrupt();
    System.out.println(t1.isInterrupted());	//true
    Thread.sleep(1000);
    System.out.println(t1.isInterrupted());	//false
}
複製程式碼

上述程式碼在t1.interrupt後馬上檢查t1的中斷標誌位,由於interrupt是先設定中斷標誌位,再中斷,因此17行的輸出檢測到了中斷標誌位返回true;接著18~19行先等t1在丟擲InterruptedException時清除標誌位,再檢測其中斷標誌位發現返回false證明了結論1:丟擲InterruptedException之前會先清除其中斷標誌位。

static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        while (flag) {

        }
    });
    t1.start();
    t1.interrupt();
    System.out.println(t1.isInterrupted());	//true
    flag = false;
    t1.join();
    System.out.println(t1.isInterrupted());	//false
}
複製程式碼

interrupted不會中斷正在執行的執行緒,但會設定其中斷標誌位,因此第10行返回true。由第13行的輸出我們還可以的處一個新的結論:對終止狀態的執行緒呼叫isInterrupted始終會返回false

interrupted

這是一個靜態方法,用來檢測當前執行緒是否被中斷過,但與isInterrupted不同,它的呼叫會導致當前執行緒的中斷標誌位被清除且isInterrupted是例項方法。也就是說如果連續兩次呼叫Thread.interrupted,第二次一定會返回false

static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        while (flag) {

        }
        System.out.println(Thread.currentThread().isInterrupted());	//true
        System.out.println(Thread.interrupted());	//true
        System.out.println(Thread.interrupted());	//false
    });
    t1.start();
    t1.interrupt();
    flag = false;
}
複製程式碼

如何優雅地終結執行緒

stop

Thread有一個棄用的方法stop,棄用的原因是這個方法是類似於linuxkill -9的方式強制立即終止執行緒,不給執行緒任何喘息的機會,這意味著執行了一半的程式突然沒後文了,如果執行緒開啟了I/O、資料庫連線等資源時將無法及時釋放他們。

利用守護執行緒和join

守護執行緒在其父執行緒終結時也會隨之終結,因此我們可以通過將執行緒設定為守護執行緒,通過控制其父執行緒的終結時間來間接終結他:

public class ThreadService {

    private Thread executeThread;
    private volatile boolean finished;

    public void execute(Runnable task) {
        executeThread =new Thread(() -> {
            Thread t = new Thread(() -> {
                task.run();
            });
            t.setDaemon(true);
            t.start();

            try {
                t.join();
                finished = true;
            } catch (InterruptedException e) {
                System.out.println("task execution was interrupted");
            }
        });
        executeThread.start();
    }

    public void shutdown(long millis) {
        long base = System.currentTimeMillis();
        long now = 0;
        while (!finished) {
            now = System.currentTimeMillis() - base;
            if (now >= millis) {
                System.out.println("task execution time out, kill it now");
                executeThread.interrupt();
                break;
            }

            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                System.out.println("was interrupted when shutdown");
            }
        }
        finished = true;
    }
}
複製程式碼

在上述程式碼中,可以通過給shutdown傳入一個task執行時限,要求它在millis時間內執行完,如果超出這個時間則視為任務執行異常,通過終止其父執行緒來終止它。如果它執行正常,在millis時間內返回了,那也會導致父執行緒的結束,shutdown也能通過輪詢finished狀態來感知任務執行結束。

使用共享狀態變數

public class ThreadCloseGraceful implements Runnable{

    private volatile boolean stop = false;
    
    @Override
    public void run() {
        while (true) {
            if (stop) {
                break;
            }
            // to do here
        }
    }

    public void shutdown() {
        stop = true;
    }
}
複製程式碼

這種方式的要點是,共享狀態變數必須宣告為volatile,這樣執行執行緒才能及時感知到shutdown命令。

輪詢中斷標誌位

通過輪詢執行緒的中斷標誌位來感知外界的中斷命令。

public class ThreadCloseGraceful extends Thread{

    @Override
    public void run() {
        while (true) {
            if (Thread.interrupted()) {
                break;
            }
            // to do here
        }
    }

    public void shutdown() {
        this.interrupt();
    }
}
複製程式碼

suspend/resume

resume/suspend被棄用的主要原因是因為suspend將執行緒掛起時並不會釋放其所持有的共享資源,如果一個執行緒持有一個甚至多個鎖後執行suspend,那麼將會導致所有等待該鎖或這些鎖釋放的執行緒陷入長久的阻塞狀態。如果碰巧將要resume這個被掛起執行緒的執行緒事先也有獲取這些鎖的需求,那麼resume執行緒也會被阻塞,這可能導致suspend執行緒將無人喚醒,這些執行緒都將陷入永久阻塞。

因此在併發場景下,對於臨界區來說,suspendresume是執行緒對立的,無論是誰先進入臨界區,都將導致這兩個執行緒甚至是多個執行緒陷入死鎖。

synchronized詳解

synchronized能夠保證被同步的程式碼在多執行緒環境下的執行是序列化的。

synchronized關鍵字的用法

  • 如果用在例項方法上,那麼執行緒在進入該方法(臨界區)時首先要獲取this物件的monitor(也就是我們通常所說的鎖,術語是管程),一個monitor同一個時刻只能被一個執行緒持有,獲取失敗將陷入阻塞狀態(BLOCKED),直到該鎖被釋放(持有鎖的執行緒退出該方法/臨界區)後該執行緒將加入到新一輪的鎖爭取之中
  • 如果用在靜態方法上,則需要獲取當前類的Class物件的monitor,鎖獲取-釋放邏輯和例項方法的相同。
  • 用在程式碼塊上(程式碼塊仍然可稱為臨界區),前兩者是JDK自身的語義,隱含著加鎖的物件。而用在程式碼塊上則需要在synchronized括號後顯式指定一個同步物件,鎖獲取-釋放邏輯依然相同

synchronized關鍵字的特性

  • 獲取鎖失敗時陷入阻塞、鎖釋放時相應阻塞在該鎖上的執行緒會被喚醒,這會引起執行緒由使用者態到核心態的切換,時間開銷較大,甚至大於臨界區程式碼的實際執行開銷。因此原則上要減少synchronized的使用,但是隨著JDK的升級,自旋鎖、適應性自旋、鎖消除、鎖粗化、偏向鎖、輕量級鎖等優化的引入(詳見《深入理解Java虛擬機器(第二版)》高併發章節),synchronized的開銷實際上也沒那麼大了。

  • 可重入,如果當前執行緒已持有某個物件的monitor,在再次進入需要該monitor的臨界區時,可直接進入而無需經過鎖獲取這一步。

  • 一個執行緒可同時持有多個monitor注意,這一操作容易導致死鎖的發生,以下程式碼就模仿了這一情景:

    public class DeadLock {
        public static Object lock1 = new Object();
        public static Object lock2 = new Object();
    
        public static void main(String[] args) {
            IntStream.rangeClosed(0,19).forEach(i->{
                if (i % 2 == 0) {
                    new Thread(() -> m1()).start();
                } else {
                    new Thread(() -> m2()).start();
                }
            });
        }
    
        public static void m1() {
            synchronized (lock1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        }
    
        public static void m2() {
            synchronized (lock2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        }
    }
    複製程式碼

    上述程式碼有很大的機率陷入死鎖,但是並不會有任何提示資訊。我們可以通過jps/jstack檢視一下執行緒狀態:

    C:\Users\zaw>jps
    2864
    5664 Jps
    4072 Launcher
    2172 DeadLock
    
    C:\Users\zaw>jstack 2172
    "Thread-1" #12 prio=5 os_prio=0 tid=0x0000000018c71800 nid=0x8f0 waiting for monitor entry [0x00000000196cf000]
       java.lang.Thread.State: BLOCKED (on object monitor)
            at deadlock.DeadLock.m2(DeadLock.java:47)
            - waiting to lock <0x00000000d6081098> (a java.lang.Object)
            - locked <0x00000000d60810a8> (a java.lang.Object)
            at deadlock.DeadLock.lambda$null$1(DeadLock.java:21)
            at deadlock.DeadLock$$Lambda$3/1989780873.run(Unknown Source)
            at java.lang.Thread.run(Thread.java:748)
    
    "Thread-0" #11 prio=5 os_prio=0 tid=0x0000000018c70800 nid=0x944 waiting for monitor entry [0x00000000195cf000]
       java.lang.Thread.State: BLOCKED (on object monitor)
            at deadlock.DeadLock.m1(DeadLock.java:34)
            - waiting to lock <0x00000000d60810a8> (a java.lang.Object)
            - locked <0x00000000d6081098> (a java.lang.Object)
            at deadlock.DeadLock.lambda$null$0(DeadLock.java:19)
            at deadlock.DeadLock$$Lambda$2/999966131.run(Unknown Source)
            at java.lang.Thread.run(Thread.java:748)
    複製程式碼

    筆者省去了其他執行緒的狀態,分析清楚這一對執行緒死鎖的原因之後,剩下的18個執行緒是類似的。首先第918兩行表明兩個執行緒表明執行緒因為獲取不到物件的鎖而陷入BLOCKED狀態。11~12行詳細的指出Thread-1正在等待獲取記憶體地址為0x00000000d6081098的一個物件的鎖,且已持有了記憶體地址為0x00000000d60810a8的物件的鎖。20~21行同樣的指出Thread-0等在0x00000000d60810a8物件上,而已獲取了0x00000000d6081098物件的鎖。可見他們都在無腦阻塞地等待對方釋放鎖,於是就陷入了死鎖。

    jstack羅列JVM各個執行緒狀態之後還為我們分析了死鎖:

    Found one Java-level deadlock:
    =============================
    "Thread-19":
      waiting to lock monitor 0x0000000018c5a398 (object 0x00000000d60810a8, a java.lang.Object),
      which is held by "Thread-1"
    "Thread-1":
      waiting to lock monitor 0x0000000018c58d98 (object 0x00000000d6081098, a java.lang.Object),
      which is held by "Thread-0"
    "Thread-0":
      waiting to lock monitor 0x0000000018c5a398 (object 0x00000000d60810a8, a java.lang.Object),
      which is held by "Thread-1"
    複製程式碼

    我們還可以使用JDK內建的JVM效能監控工具JConsole更直觀地分析執行緒狀態:

    C:\Users\zaw>jps
    2864
    6148 Jps
    4072 Launcher
    2172 DeadLock
    
    C:\Users\zaw>jconsole 2172
    複製程式碼

    開啟的工具視窗會詢問一下是否信任不安全的連線,點選是方可進入。進入後通過執行緒皮膚能夠檢視各執行緒狀態,點選死鎖分析,它會為我們分析出當前JVM程式中哪些執行緒陷入了死鎖以及原因是什麼:

    image

synchronized底層實現

要想了解為什麼執行緒在執行臨界區(包括同步方法和同步程式碼塊)時會有鎖獲取-釋放這一機制,那我們就要知道這個關鍵字在編譯後生成了怎樣的JVM指令。

首先我們分別編寫一個同步方法和同步塊,分別測試synchronized在位元組碼層面會產生什麼樣的效果:

public class SynchronizedTest{
	
	public synchronized void m1(){
		System.out.println("sync method");
	}
	
	Object lock = new Object();
	public void m2(){
		synchronized(lock){
			System.out.println("sync block");
		}
	}
}
複製程式碼

然後使用javac編譯,由於編譯後的位元組碼檔案是二進位制位元組流,我們檢視不方便(JVM檢視方便),因此還需要使用javap將其轉換成我們能看懂的友好內容(位元組碼格式詳見《深入理解Java虛擬機器(第二版)》中的Class檔案格式),為了照顧對這部分不熟悉的讀者,筆者做了刪減,僅關注synchronized產生的效果:

C:\Users\zaw>cd Desktop

C:\Users\zaw\Desktop>javac SynchronizedTest.java

C:\Users\zaw\Desktop>javap -verbose SynchronizedTest.class

public class SynchronizedTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
  	# 這裡省去了常量池部分
{
  java.lang.Object lock;
    descriptor: Ljava/lang/Object;
    flags:

  public synchronized void m1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String sync method
         5: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 4: 0
        line 5: 8

  public void m2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: getfield      #3                  // Field lock:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter
         7: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: ldc           #7                  // String sync block
        12: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        15: aload_1
        16: monitorexit
        17: goto          25
        20: astore_2
        21: aload_1
        22: monitorexit
        23: aload_2
        24: athrow
        25: return
   Exception table:
         from    to  target type
             7    17    20   any
            20    23    20   any
}
SourceFile: "SynchronizedTest.java"
複製程式碼

儘管上述程式碼看起來很長,但是我們只需要關注兩個點:

  • 對比20行和33行,會發現同步方法m1比非同步方法m2flags多了一個ACC_SYNCHRONIZED,因此執行緒在進入同步方法時,若檢測到該方法的flags包含ACC_SYNCHRONIZED,那麼該執行緒將嘗試獲取this或該方法所在類的Class例項(這取決於方法是例項方法還是靜態方法),即同步方法的synchronized語義是通過方法標誌位ACC_SYNCHRONIZED來實現的,同步過程是隱式的(同步物件由JVM來指定,鎖釋放由JVM來完成)
  • 再看40~49行,發現它給我們的同步塊內的內容System.out.println("sync block")的前後分別加上了一個monitorenter和一個monitorexit,這就對應鎖獲取-釋放,這種同步語義是顯式的,同步物件和臨界區由我們來控制,相對同步方法靈活一些。

還有一點值得注意的是上述的第49行程式碼為什麼又出現了一個monitorexit?這是為了保證在同步程式碼塊執行過程中如果丟擲了異常,執行緒持有的鎖也能夠在異常丟擲前被釋放掉(不至於影響到其他正在等待鎖獲取的執行緒)。

如何避免死鎖

經過上述的分析,對於鎖的理解應該有了更深刻的認識。那麼如何避免死鎖呢?陷入死鎖的執行緒既不會工作還要持續佔用系統資源,我們的應用程式應當避免發生這種情況。

  • 避免一個執行緒同時持有多個鎖。如果讓執行緒在已持有鎖的情況下,再嘗試獲取其他的鎖,那麼一旦獲取失敗必然導致該執行緒帶著已持有的鎖陷入阻塞,佔著同步資源陷入阻塞對高併發和不友好,應當避免。
  • 避免臨界區的執行時間過長。如果執行臨界區時發生異常導致執行緒遲遲不能退出臨界區,這是常有的事,比如運算元據庫連線時由於網路環境不佳而長時間不能返回,由比如非同步呼叫的webservice介面異常導致非同步執行緒不能及時返回,等等。這樣會導致持有鎖的執行緒遲遲不釋放鎖,如果剛好有很多其他的執行緒在等待獲取該鎖,那麼這些執行緒將陷入長久的阻塞中。我們因儘量縮小臨界區的範圍,只對存在資料爭用的程式碼做同步。同時為了避免大量執行緒因鎖獲取而陷入長久的等待,應該使用LocktryLock(millis)超時等待機制,一旦發現等待時間過長,那麼就沒必要一直等下去,可以先去完成其他任務之後再來嘗試獲取鎖。後面我們將針對這種情況手寫一個等待超時就能自動返回的鎖。

wait/notify和wait set

棄用suspend/resume之後,官方建議使用wait/notify代替。與suspend/resume的定位不同,wait/notify實現於Object,是所有物件都能夠呼叫的方法。且呼叫物件的wait/notify前必須先獲取該物件的monitor

以下是官方對wait(millis)給出的說明:

* This method causes the current thread (call it <var>T</var>) to
* place itself in the wait set for this object and then to relinquish
* any and all synchronization claims on this object. Thread <var>T</var>
* becomes disabled for thread scheduling purposes and lies dormant
* until one of four things happens: notify, notifyAll, interrupt, time out
複製程式碼

呼叫一個物件objwait方法將會導致當前執行執行緒被放入obj的等待佇列中(wait set,執行緒休息室),並且釋放該執行緒通過synchronized已持有的所有鎖,然後釋放CPU的執行權陷入等待,直到被notify/notifyAll通知到、被其他執行緒呼叫interrupt中斷或者等待時間已超過所設定的時限時才會進入就緒狀態重新爭取CPU執行權。

這裡需要注意的是並非執行緒被notify/notifyAll喚醒了就能立即從wait返回,被喚醒後只會使執行緒進入就緒狀態爭取CPU執行權,只有獲取到CPU執行權並且獲取到所有wait前釋放的鎖後才能從wait返回,否則執行緒仍將阻塞在wait上。

使用wait/notify,我們能夠實現執行緒間的通訊。

wait/notify經典正規化

官方給出了wait/notify使用的經典正規化:

synchronized (obj) {
         while (<condition does not hold>)
             obj.wait();
         ... // Perform action appropriate to condition
     }
複製程式碼

使用while而不使用if的原因就是被喚醒並從wait返回的執行緒應該不斷檢查它所關注的條件,因為被喚醒可能並不是由於另一個執行緒為了通知該執行緒而有針對性的喚醒該執行緒,這一點從notify的隨機喚醒、notifyAll喚醒全部、被喚醒的執行緒在同一時刻只有一個能夠搶到鎖,可以看出真正能夠從wait返回的執行緒具有很大的不確定性。由於每個執行緒的關注的條件不同,所以需要輪詢判斷條件是否成立,方可從while中退出來。

由此我們可以利用wait/notify實現生產者-消費者通訊模型:

public class ClassicForm {

    private static String message;
    private static Object lock = new Object();

    public static void main(String[] args) {
        Thread consumer = new Thread(() -> {
            while(true){
                synchronized (lock) {
                    while (message == null) {	// wait for producing
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            System.out.println("consumer was broken");
                            return;
                        }
                    }
                    System.out.println("CONSUMER receive message : " + message);
                    message = null;
                    lock.notify();
                }
            }
        });

        Thread producer = new Thread(() -> {
            synchronized (lock) {
                for(int i = 0 ; i < 100 ; i++){
                    while (message != null) {	// wait for consuming
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            System.out.println("producer was broken");
                            return;
                        }
                    }
                    message = "please the order, order-id is " + i;
                    lock.notify();
                    System.out.println("PRODUCER send the message : " + message);
                }
            }
        });

        consumer.start();
        producer.start();
    }

}
複製程式碼

你會發現這裡的message即使沒有加volatile,生產者每次所做的更改消費者都能準確獲取到。這是由synchronizedunlock指令和JMM(Java記憶體模型)共同決定的,JMM將在後文中詳細展開。

wait/notify限時等待模型

上述程式碼有一個明顯的缺陷,那就是如果生產者生產訊息很慢,那麼消費者就會一直wait直到有新的訊息到來。這樣就沒有充分利用消費者執行緒所佔用的資源。能否為消費者的等待設定一個限時?在等待時長超過限時之後就不wait了,先去處理其他任務,稍後再來監聽生產者生產的訊息。下段程式碼簡單模擬了這一場景:

public class WaitTimeoutModel {

    private static String message;
    private static Object lock = new Object();
    private static final long MAX_WAIT_LIMIT = 1000;

    public static void main(String[] args) {
        Thread consumer = new Thread(() -> {
            synchronized (lock) {
                while (true) {
                    long base = System.currentTimeMillis();
                    long now = 0;
                    while (message == null) {
                        now = System.currentTimeMillis() - base;
                        if (now >= MAX_WAIT_LIMIT) {
                            break;  // exit wait
                        }
                        try {
                            lock.wait(MAX_WAIT_LIMIT);
                        } catch (InterruptedException e) {
                            System.out.println("consumer was broken");
                        }
                    }
                    if (message == null) {
                        System.out.println("CONSUMER exit wait, and do other things");
                        try {   // simulate do other thing
                            Thread.sleep(50);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    } else {
                        System.out.println("CONSUMER receive the message : " + message);
                        message = null;
                    }
                }
            }
        });

        Thread producer = new Thread(() -> {
            // prepare message is very slowly
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // notify consumer
            synchronized (lock) {
                message = "please handle the order, order-id is 5454656465";
                lock.notify();
                System.out.println("PRODUCER send the message : " + message);
            }
        });

        consumer.start();
        producer.start();
    }
}
複製程式碼

要點就是在經典正規化的基礎之上,在輪詢狀態變數的過程中增加了一個等待時長判斷(第14~17行),如果發現超過了給定的時限(這裡是MAX_WAIT_LIMIT),那麼就不再等待,去做其他事情(第25~30行),相反如果在wait(MAX_WAIT_LIMIT)期間由於生產者的提醒被喚醒,那麼同樣會跳出輪詢(生產者通常生產出訊息後才喚醒消費者)進入到第32~33行去消費訊息。但無論是哪一種情況,都算是消費者一個邏輯執行單元的結束。由於消費者通常是24小時執行監聽的(while(true)),因此在每一個執行單元結束後將重置用來計算等待時長的basenow(第11~12行)。

執行效果如下:

CONSUMER exit wait, and do other things
PRODUCER send the message : please handle the order, order-id is 5454656465
CONSUMER receive the message : please handle the order, order-id is 5454656465
CONSUMER exit wait, and do other things
CONSUMER exit wait, and do other things
CONSUMER exit wait, and do other things
...
複製程式碼

超時等待模型被廣泛用於併發設計模式以及JUC包,需要好好理解。

wait和sleep的本質區別(面試常問)

  • waitObject中的例項方法且呼叫前需要獲取例項物件的鎖,sleepThread中的靜態方法可直接呼叫
  • sleep不會釋放當前執行緒所持有的鎖,而wait則會釋放當前執行緒持有的所有鎖
  • sleepwait都會使執行緒進入TIMED-WAITING狀態釋放CPU執行權,但呼叫sleep的執行緒在設定的時限後能夠自動返回,而wait(millis)在超時後需要先獲取物件的鎖才能返回、wait(0)更是需要等待被喚醒並獲取到鎖後才能返回。

多Consumer和多Producer引發的假死問題

下段程式碼模擬了生產者-消費者模型下兩個生產者和兩個消費者同時工作導致程式假死的一個案例

public class ProducerConsumer {

    private String message;

    public synchronized void produce() {
        while (message != null) {
            try {
                this.wait();
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + " was broken");
                return;
            }
        }
        message = "time is " + new Date(System.currentTimeMillis());
        this.notify();
        System.out.println(Thread.currentThread().getName() + " send the message : " + message);
    }

    public synchronized void consume() {
        while (message == null) {
            try {
                this.wait();
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + " was broken");
                return;
            }
        }
        System.out.println(Thread.currentThread().getName() + " recv the message : " + message);
        message = null;
        this.notify();
    }

    public static void main(String[] args) {
        ProducerConsumer pc = new ProducerConsumer();
        Stream.of("p1", "p2").forEach(name -> {
            new Thread(() -> {
                while (true) {
                    pc.produce();
                }
            }, name).start();
        });

        Stream.of("c1", "c2").forEach(name -> {
            new Thread(() -> {
                while (true) {
                    pc.consume();
                }
            }, name).start();
        });
    }
}
複製程式碼

輸出如下:

p1 send the message : time is Fri Feb 01 14:06:26 CST 2019
c2 recv the message : time is Fri Feb 01 14:06:26 CST 2019
p2 send the message : time is Fri Feb 01 14:06:26 CST 2019
c2 recv the message : time is Fri Feb 01 14:06:26 CST 2019
p1 send the message : time is Fri Feb 01 14:06:27 CST 2019
# 至此,四個執行緒陷入永久wait
複製程式碼

筆者也曾異或良久,一個Producer生產了訊息會通知一個Consumer消費,且後者消費完後又會通知一個等待生產的Producer,沒問題啊!怎麼會都陷入wait呢?

這是因為我們陷入了一個慣性思維,學生產者-消費者模式學著學著就總以為生產者生產了訊息會通知消費者、消費者消費完了會通知生產者。我們忘記了notify的本質:notify會從物件的wait set隨機選取一個執行緒喚醒。我們再來理性地分析一下上述程式碼:第17行的notify一定會喚醒物件的wait set上的一個消費者執行緒嗎?不一定吧!假設某一時刻p1搶到了鎖,而p2,c1,c2均阻塞在wait上,那麼p1生產訊息後呼叫的notify有沒有可能喚醒的是p2呢(如此的話,被喚醒的p2發現p1生產的訊息沒有被消費仍然會陷入wait,這樣的話四個執行緒就都陷入wait了,沒有其他執行緒來喚醒他們。類似的,消費者消費完訊息後喚醒的也可能是另一個在wait的消費者,這樣的喚醒做的是無用功)。就是因為notify不確定性,從而導致上述程式碼並沒有按照生產者-消費者的套路來,最後四個執行緒都陷入了wait且沒有執行緒去喚醒他們。

但是如果將第17,34行的notify改成notifyAll就不會死鎖了。這是因為notifyAll會喚醒所有阻塞在該物件的wait上的執行緒。因此p1生產訊息後如果呼叫的是notifyAll,那麼p2,c1,c2都會被喚醒並爭取該物件的monitor,這時即使p2先搶到了,它也會由於訊息未被消費而進入wait進而釋放鎖並喚醒等待該鎖的c1,c2,所以p1notifyAll最終一定會導致其中一個消費者從wait返回,這樣即使是多Producer多Consumer,程式也能跑通了。

p2 send the message : time is Fri Feb 01 14:30:39 CST 2019
c1 recv the message : time is Fri Feb 01 14:30:39 CST 2019
p1 send the message : time is Fri Feb 01 14:30:39 CST 2019
c2 recv the message : time is Fri Feb 01 14:30:39 CST 2019
p2 send the message : time is Fri Feb 01 14:30:40 CST 2019
c1 recv the message : time is Fri Feb 01 14:30:40 CST 2019
p1 send the message : time is Fri Feb 01 14:30:41 CST 2019
c2 recv the message : time is Fri Feb 01 14:30:41 CST 2019
p2 send the message : time is Fri Feb 01 14:30:42 CST 2019
c1 recv the message : time is Fri Feb 01 14:30:42 CST 2019
...
複製程式碼

多執行緒下的生產者-消費者模型,要使用notifyAll

手寫一個BooleanLock

上文說到synchronized有一個嚴重的缺陷就是,如果持有鎖的執行緒遲遲不釋放鎖(臨界區的執行時間過長),那麼等待該鎖的其他執行緒就會一直阻塞住,直到該鎖被釋放。那麼能否實現這樣一種機制呢:給等待鎖釋放的執行緒設定一個時限,如果超過了該時限,那麼就認為鎖一時半會兒不會被釋放,於是可以讓執行緒利用這段空閒執行其他的任務而非一直阻塞著什麼事都不做。

現在我們可以使用wait/notify的經典正規化實現synchronized語義,使用其超時等待模型實現限時等待語義。首先定義一個同步物件介面,即Lock

public interface Lock {

    void lock() throws InterruptedException;

    void unlock();

    void lock(long millis) throws InterruptedException, TimeoutException;

    Collection<Thread> getBlockedThread();

    int getBlockedCount();
}
複製程式碼

接著實現一個簡單的用一個布林變數表示同步狀態的BooleanLock

public class BooleanLock implements Lock {

    private volatile boolean isSync = false; //represent whether the lock is held or not. true is held, false is not held
    private Thread currentThread;   //current thread which hold the lock
    private Collection<Thread> waitQueue;

    public BooleanLock() {
        this.isSync = false;
        this.currentThread = null;
        this.waitQueue = new ArrayList<>();
    }

    @Override
    public synchronized void lock() throws InterruptedException {
        waitQueue.add(Thread.currentThread());
        while (isSync) {    // lock is held by other thread
            this.wait();
        }
        // get the lock successfully
        waitQueue.remove(Thread.currentThread());
        currentThread = Thread.currentThread();
        isSync = true;  //indicate the lock is held
        System.out.println(Thread.currentThread().getName() + " get the lock");
    }

    @Override
    public void unlock() {
        // check the operator is the thread which is holding the lock
        if (Thread.currentThread() != currentThread) {
            return;
        }
        synchronized (this) {
            currentThread = null;
            isSync = false;
            this.notifyAll();
            System.out.println(Thread.currentThread().getName() + " release the lock");
        }
    }

    @Override
    public synchronized void lock(long millis) throws InterruptedException, TimeoutException {
        long base = System.currentTimeMillis();
        long now = 0;
        waitQueue.add(Thread.currentThread());
        while (isSync) {
            now = System.currentTimeMillis() - base;
            if (now >= millis) {
                throw new TimeoutException();
            }
            this.wait(millis);
        }
        waitQueue.remove(Thread.currentThread());
        currentThread = Thread.currentThread();
        isSync = true;
        System.out.println(Thread.currentThread().getName() + " get the lock");
    }

    @Override
    public Collection<Thread> getBlockedThread() {
        return Collections.unmodifiableCollection(waitQueue);
    }

    @Override
    public int getBlockedCount() {
        return waitQueue.size();
    }
}
複製程式碼

測試synchronized語義:

public static void main(String[] args) {
    BooleanLock lock = new BooleanLock();
    Stream.of("t1", "t2", "t3", "t4", "t5").forEach(name -> {
        new Thread(() -> {
            try {
                lock.lock();
                Thread.sleep(50); // to do thing
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                printBlockedThread(lock);
                lock.unlock();
            }
        }, name).start();
    });
}

private static void printBlockedThread(BooleanLock lock) {
    System.out.print("There are " + lock.getBlockedCount() + " threads waiting on the lock: ");
    lock.getBlockedThread().forEach(thread -> System.out.print(thread.getName() + " "));
    System.out.println();
}
複製程式碼

執行結果:

t1 get the lock
There are 4 threads waiting on the lock: t4 t3 t2 t5 
t1 release the lock
t5 get the lock
There are 3 threads waiting on the lock: t4 t3 t2 
t5 release the lock
t4 get the lock
There are 2 threads waiting on the lock: t3 t2 
t4 release the lock
t2 get the lock
There are 1 threads waiting on the lock: t3 
t2 release the lock
t3 get the lock
There are 0 threads waiting on the lock: 
t3 release the lock
複製程式碼

需要注意的是unlock必須寫在finally中確保鎖一定會被釋放,而synchronized同步塊執行時丟擲異常JVM會通過異常表(詳見《深入理解Java虛擬機器(第二版)》Class檔案結構一章中的方法表的描述)在異常丟擲時釋放當前執行緒所持有的全部的鎖。

測試限時獲取鎖

上例只是實現了與synchronized同樣的功能,接著我們測試一下限時獲取鎖的功能,這是synchronized無法做到的。

public static void main(String[] args) {
    BooleanLock lock = new BooleanLock();
    Stream.of("t1", "t2", "t3", "t4", "t5").forEach(name -> {
        new Thread(() -> {
            try {
                lock.lock(1000);
                Thread.sleep(2000); // the task is very time-consuming
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + " was interrupted");
            } catch (TimeoutException e) {
                System.out.println(Thread.currentThread().getName() + " get lock time out, so do other thing first and then get the lock again");
            } finally {
                lock.unlock();
            }
        }, name).start();
    });
}
複製程式碼

輸出如下:

t1 get the lock
t2 get lock time out, so do other thing first and then get the lock again
t3 get lock time out, so do other thing first and then get the lock again
t4 get lock time out, so do other thing first and then get the lock again
t5 get lock time out, so do other thing first and then get the lock again
t1 release the lock
複製程式碼

給你的應用程式注入鉤子程式

在使用一些開源框架時,比如Tomcat,在關閉時仍會有些日誌列印出來,這些日誌通常是釋放應用程式資源的資訊。也就是說我們點選terminate的事件被應用程式捕獲到後,應用程式並非直接終止而是先釋放一些珍貴資源。這就是通過設定鉤子函式做到的,它會在應用程式主執行緒終止前被呼叫。對應APIRuntime.getRuntime().addShutdownHook(thread)

下面我將在linux上演示鉤子函式的用處。MyApp.java表示我的應用程式:

public class MyApp{
    public static void main(String[] args){

        Runtime.getRuntime().addShutdownHook(
            new Thread(() -> {
                //release resource here, like socket,connection etc
                System.out.println("releasing resources...");
            })
        );

        while(true){
            // start a service
        }
    }
}
複製程式碼

通過addShutdownHook設定的執行緒將在main執行緒被外界中斷時呼叫,比如我在執行java MyApp時按下了CTRL C

[root@izm5ecexclrsy1gmkl4bgdz ~]# javac MyApp.java
[root@izm5ecexclrsy1gmkl4bgdz ~]# java MyApp
^Creleasing resources...
複製程式碼

又比如後臺執行MyApp,通過kill pid終止它:

[root@izm5ecexclrsy1gmkl4bgdz ~]# java MyApp &
[1] 14230
[root@izm5ecexclrsy1gmkl4bgdz ~]# jps
14240 Jps
14230 MyApp
[root@izm5ecexclrsy1gmkl4bgdz ~]# kill 14230
[root@izm5ecexclrsy1gmkl4bgdz ~]# releasing resources...
複製程式碼

但是kill -9則不會觸發鉤子程式:

[root@izm5ecexclrsy1gmkl4bgdz ~]# java MyApp &
[1] 14264
[root@izm5ecexclrsy1gmkl4bgdz ~]# ps aux|grep java
root     14264 96.3  1.4 2460724 27344 pts/0   Sl   16:03   0:09 java MyApp
root     14275  0.0  0.0 112660   964 pts/0    R+   16:03   0:00 grep --color=auto java
[root@izm5ecexclrsy1gmkl4bgdz ~]# kill -9 14264
[root@izm5ecexclrsy1gmkl4bgdz ~]# ps aux|grep java
root     14277  0.0  0.0 112660   964 pts/0    R+   16:03   0:00 grep --color=auto java
[1]+  Killed                  java MyApp
複製程式碼

獲取堆疊資訊

Thread.currentThread().getStackTracke()獲取當前執行緒執行到當前方法時棧中的所有棧幀資訊,返回StackTraceElement[],一個元素就代表一個方法棧幀,可以通過它得知方法所屬的類、方法名、方法執行到了第幾行

public static void main(String[] args) {
    m1();
}

public static void m1() {
    m2();
}

private static void m2() {
    m3();
}

private static void m3() {
    Arrays.asList(Thread.currentThread().getStackTrace()).stream()
        .filter(
        //過濾掉native方法
        stackTraceElement -> !stackTraceElement.isNativeMethod()
    ).forEach(
        stackTraceElement -> {
            System.out.println(stackTraceElement.getClassName() + ":" +
                               stackTraceElement.getMethodName() + "():" +
                               stackTraceElement.getLineNumber());
        }
    );

}
複製程式碼

捕獲run方法執行時異常

由於Runnable介面的run方法並未宣告丟擲任何異常,因此在重寫run時,所有checked exception都需要我們手動解決。但是如果丟擲unchecked exception呢,1/0就是典型的例子,我們如何捕獲他?

通過thread.setUncheckedExceptionHandler()能夠做到這一點:

public static final int A = 1;
public static final int B = 0;
public static void main(String[] args) {
    Thread thread = new Thread(() -> {
        int i = A / B;
    });
    thread.setUncaughtExceptionHandler((t, e) -> {
        // t -> the ref of the thread, e -> exception
        System.out.println(e.getMessage()); /// by zero
    });
    thread.start();
}
複製程式碼

執行緒組ThreadGroup

執行緒組代表一個執行緒的集合,一個執行緒組也可以包含其他執行緒組,執行緒組可以以樹形結構展開。

  1. 在JVM啟動時,會建立一個名為main的執行緒執行main函式和一個名為main的執行緒組,main執行緒的執行緒組是main執行緒組:

    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());	//main
        System.out.println(Thread.currentThread().getThreadGroup().getName());	//main
    }
    複製程式碼
  2. 建立執行緒時,如果沒有為該執行緒顯式指定執行緒組,那麼該執行緒將會拿他的父執行緒的執行緒組作為自己的執行緒組。

    如果建立執行緒組時沒有顯式指定其父執行緒組,將會拿當前執行緒的執行緒組作為其父執行緒組

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            //
        });
        System.out.println(t1.getThreadGroup().getName());	//main
    
        ThreadGroup threadGroup = new ThreadGroup("MyThreadGroup");
        Thread t2 = new Thread(threadGroup, () -> {
            //
        });
        System.out.println(t2.getThreadGroup().getName());				//MyThreadGroup
        System.out.println(t2.getThreadGroup().getParent().getName());   //main
    }
    複製程式碼
  3. threadGroup.list()方法能夠列印執行緒組中存活執行緒的資訊,可用於debug

    ThreadGroup threadGroup = new ThreadGroup("MyThreadGroup");
    new Thread(threadGroup, () -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
    
    new Thread(threadGroup, () -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
    threadGroup.list();
    
    java.lang.ThreadGroup[name=MyThreadGroup,maxpri=10]
        Thread[Thread-0,5,MyThreadGroup]
        Thread[Thread-1,5,MyThreadGroup]
    複製程式碼

更多API大家可檢視官方文件。

自定義執行緒池

工作執行緒的執行邏輯

工作執行緒應該不斷輪詢任務佇列是否有任務可做,有則拿來執行,無則等待外界提交。然後還要為外界提供終止當前執行緒的stop,其採用的是利用共享狀態變數的方式並使用volatile修飾使得外界的終止操作立即對當前工作執行緒可見。

public class Worker implements Runnable {

    private volatile boolean stop;
    private LinkedList<Runnable> taskQueue;
    private Thread currentThread;

    public Worker(LinkedList<Runnable> taskQueue) {
        this.taskQueue = taskQueue;
    }

    @Override
    public void run() {
        currentThread = Thread.currentThread();
        Runnable task = null;
        OUTER:
        while (!stop) {
            synchronized (taskQueue) {
                while (taskQueue.isEmpty()) {
                    try {
                        taskQueue.wait();
                    } catch (InterruptedException e) {
                        System.out.println(Thread.currentThread().getName()+" has been interrupted");
                        break OUTER;
                    }
                }
                task = taskQueue.removeFirst();
                taskQueue.notifyAll();
            }
            if (task != null) {
                task.run();
            }
        }
    }

    public void interrupt() {
        if (currentThread != null) {
            currentThread.interrupt();
        }
    }

    public void stop() {
        stop = true;
    }
}
複製程式碼

執行緒池:用來建立和管理執行緒

public class ThreadPool {

    private static final int DEFAULT_THREAD_COUNT = 10;

    private int threadCount;
    private LinkedList<Worker> workQueue;
    private LinkedList<Runnable> taskQueue;

    public ThreadPool() {
        this(DEFAULT_THREAD_COUNT);
    }

    public ThreadPool(int size) {
        this.threadCount = size;
        this.workQueue = new LinkedList<>();
        this.taskQueue = new LinkedList<>();
        init(size);
    }

    //建立並啟動count個執行緒
    private void init(int count) {
        if (count <= 0) {
            throw new IllegalArgumentException("thread pool size must greater than zero");
        }
        for (int i = 0; i < count; i++) {
            Worker worker = new Worker(taskQueue);
            Thread thread = new Thread(worker, "ThreadPool-" + i);
            thread.start();
            workQueue.add(worker);
        }
    }

    public void execute(Runnable task) {
        synchronized (taskQueue) {
            taskQueue.add(task);
            taskQueue.notifyAll();
        }
    }

    public int getThreadCount() {
        return threadCount;
    }

    public int getTaskCount() {
        return taskQueue.size();
    }

    //對wait中的執行緒呼叫stop,他也無法輪詢該變數而退出迴圈
    //因此對於wait中的工作執行緒直接中斷它,而正在執行的執行緒則等他自己輪詢到stop而退出
    public void shutdown() {
        synchronized (taskQueue) {
            for (Worker worker : workQueue) {
                worker.stop();
                worker.interrupt();
            }
        }
        System.out.println("thread pool destroyed");
    }
}
複製程式碼

測試

public class ThreadPoolTest {
    public static void main(String[] args) throws InterruptedException {
        ThreadPool threadPool = new ThreadPool();
        for (int i = 0; i < 40; i++) {
            int number = i;
            threadPool.execute(()->{
                System.out.println(Thread.currentThread().getName() + "start execute task-" + number);
                try {
                    Thread.sleep(new Random(System.currentTimeMillis()).nextInt(1000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        Thread.sleep(5000);
        threadPool.shutdown();
    }
}
複製程式碼

為執行緒池增加拒絕策略

執行緒池的工作佇列不應該無限大,如果不注意的或可能會導致OOM,因此在任務佇列中的任務數到達一定數目時應對提交的任務採取拒絕策略。

這裡應該用策略模式,策略介面:

public interface RefusePolicy {
    void refuse() throws Exception;
}
複製程式碼

簡單任務數過大就拋異常的策略:

public class DiscardRefusePolicy implements RefusePolicy {

    public class TaskExceededException extends Exception {
        public TaskExceededException(String message) {
            super(message);
        }
    }

    @Override
    public void refuse() throws TaskExceededException {
        throw new TaskExceededException("task has exceeded the taskSize of thread poll");
    }
}
複製程式碼

改造execute方法:

private static final int DEFAULT_THREAD_COUNT = 10;
private static final RefusePolicy DEFAULT_REFUSE_POLICY = new DiscardRefusePolicy();
private static final int DEFAULT_TASK_SIZE = 200;

private int threadCount;
private LinkedList<Worker> workQueue;
private LinkedList<Runnable> taskQueue;
private int maxTaskSize;
private RefusePolicy refusePolicy;

public ThreadPool() {
    this(DEFAULT_THREAD_COUNT, DEFAULT_TASK_SIZE, DEFAULT_REFUSE_POLICY);
}

public ThreadPool(int size, int maxTaskSize, RefusePolicy refusePolicy) {
    this.threadCount = size;
    this.maxTaskSize = maxTaskSize;
    this.workQueue = new LinkedList<>();
    this.taskQueue = new LinkedList<>();
    this.refusePolicy = refusePolicy;
    init(size);
}

public void execute(Runnable task) throws Exception {
    synchronized (taskQueue) {
        if (taskQueue.size() >= maxTaskSize) {
            refusePolicy.refuse();
            return;
        }
        taskQueue.add(task);
        taskQueue.notifyAll();
    }
}
複製程式碼

測試

public static void main(String[] args) throws InterruptedException {
    ThreadPool threadPool = new ThreadPool();
    for (int i = 0; i < 300; i++) {
        int number = i;
        try {
            threadPool.execute(() -> {
                System.out.println(Thread.currentThread().getName() + "start execute task-" + number);
                try {
                    Thread.sleep(new Random(System.currentTimeMillis()).nextInt(100));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        } catch (Exception e) {
            System.out.println("task-" + i + " execution error : " + e.getMessage());
        }
    }

    Thread.sleep(5000);
    threadPool.shutdown();
}
複製程式碼

相關文章