「有點收穫」三種基本方法建立執行緒

飛天小牛肉發表於2021-05-12

挺基礎的知識,一開始不是很願意寫,畢竟這種簡單的知識大家不一定願意看,而且容易寫的大眾化,不過還好梳理一遍下來還算是有點收穫,比如我看了 Thread 類重寫的 run 方法,才明白為什麼可以把任務(Runnable)和執行緒本身(Thread)分開來。

建立執行緒的三種方法

執行緒英譯是 Thread,這也是 Java 中執行緒對應的類名,在 java.lang 包下。

注意下它實現了 Runnable 介面,下文會詳細解釋。

執行緒與任務合併 — 直接繼承 Thread 類

執行緒建立出來自然是需要執行一些特定的任務的,一個執行緒需要執行的任務、或者說需要做的事情就在 Thread 類的 run 方法裡面定義。

這個 run 方法是哪裡來的呢?

事實上,它並不是 Thread 類自己的。Thread 實現了 Runnable 介面,run 方法正是在這個介面中被定義為了抽象方法,而 Thread 實現了這個方法。

所以,我們把這個 Runnable 介面稱為任務類可能更好理解。

如下,就是通過整合 Thread 類建立一個自定義執行緒 Thread1 的示例:

// 自定義執行緒物件
class Thread1 extends Thread {
    @Override
	public void run() {
		// 執行緒需要執行的任務
		......
  	}
}

// 建立執行緒物件
Thread1 t1 = new Thread1();

看這裡,Thread 類提供了一個建構函式,可以為某個執行緒指定名字:

所以,我們可以這樣:

// 建立執行緒物件
Thread1 t1 = new Thread1("t1");

這樣,控制檯列印的時候就比較明瞭,一眼就能知道是哪個執行緒輸出的。

當然了,一般來說,我們寫的程式碼都是下面這種匿名內部類簡化版本的:

// 建立執行緒物件
Thread t1 = new Thread("t1") {
	@Override
	// run 方法內實現了要執行的任務
	public void run() {
		// 執行緒需要執行的任務
    	......
 	}
};

執行緒與任務分離 — Thread + 實現 Runnable 介面

假如有多個執行緒,這些執行緒執行的任務都是一樣的,那按照上述方法一的話我們豈不是就得寫很多重複程式碼?

所以,我們考慮把執行緒執行的任務與執行緒本身分離開來。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 執行緒需要執行的任務
    	......
    }
}

// 建立任務類物件
MyRunnable runnable = new MyRunnable();
// 建立執行緒物件
Thread t2 = new Thread(runnable);

除了避免了重複程式碼,使用實現 Runnable 介面的方式也比方法一的單繼承 Thread 類更具靈活性,畢竟一個類只能繼承一個父類,如果這個類本身已經繼承了其它類,就不能使用第一種方法了。另外,用這種方式,也更容易與執行緒池等高階 API 相結合。

因此,一般來說,更推薦使用這種方式去建立執行緒。也就是說,不推薦直接操作執行緒物件,推薦操作任務物件。

上述程式碼使用匿名內部類的簡化版本如下:

// 建立任務類物件
Runnable runnable = new Runnable() {
    public void run(){
        // 要執行的任務
        ......
    }
};

// 建立執行緒物件
Thread t2 = new Thread(runnable);

同樣的,我們也可以為其指定執行緒名字:

Thread t2 = new Thread(runnable, "t2");

以上兩個 Thread 的建構函式如圖所示:

可以發現,Thread 類的建構函式無一例外全部呼叫了 init 方法,這個方法到底做了啥?我們點進去看看:

它將建構函式傳進來的 Runnable 物件傳給了一個成員變數 target。

target 就是 Thread 類中定義的 Runnable 物件,代表著需要執行的任務(What will be run)。

這個變數的存在,就是我們能夠把任務(Runnable)和執行緒本身(Thread)分開的原因所在。看下面這段程式碼:

沒錯,這就是 Thread 類預設實現的 run 方法。

在使用第一種方法建立執行緒的時候,我們定義了一個 Thread 子類並重寫了其父類的 run 方法,所以這個父類實現的 run 方法不會被執行,執行的是我們自定義的子類中的 run 方法。

而在使用第二種方法建立執行緒的時候,我們並沒有在 Thread 子類中重寫 run 方法,所以父類預設實現的 run 方法就會被執行。

而這段 run 方法程式碼的意思就是說,如果 taget != null,也就是說如果 Thread 建構函式中傳入了 Runnable 物件,那就執行這個 Runnable 物件的 run 方法。

執行緒與任務分離 — Thread + 實現 Callable 介面

雖然 Runnable 挺不錯的,但是仍然有個缺點,那就是沒辦法獲取任務的執行結果,因為它的 run 方法返回值是 void。

這樣,對於需要獲取任務執行結果的執行緒來說,Callable 就成為了一個完美的選擇。

Callable 和 Runnable 基本差不多:

和 Runnbale 比起來,Callable 不過就是把 run 改成了 call。當然,最重要的是!和 void run 不同,這個 call 方法是擁有返回值的,而且能夠丟擲異常。

這樣,一個很自然的想法,就是把 Callable 作為任務物件傳給 Thread,然後 Thread 重寫 call 方法就完事兒。

But,遺憾的是,Thread 類的建構函式裡並不接收 Callable 型別的引數。

所以,我們需要把 Callable 包裝一下,包裝成 Runnable 型別,這樣就能傳給 Thread 建構函式了。

為此,FutureTask 成為了最好的選擇。

可以看到 FutureTask 間接繼承了 Runnable 介面,因此它也可以看作是一個 Runnable 物件,可以作為引數傳入 Thread 類的建構函式。

另外,FutureTask 還間接繼承了 Future 介面,並且,這個 Future 介面定義了可以獲取 call() 返回值的方法 get:

看下面這段程式碼,使用 Callable 定義一個任務物件,然後把 Callable 包裝成 FutureTask,然後把 FutureTask 傳給 Thread 建構函式,從而建立出一個執行緒物件。

另外,Callable 和 FutureTask 的泛型填的就是 Callable 任務返回的結果型別(就是 call 方法的返回型別)。

class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        // 要執行的任務
        ......
        return 100;
    }
}
// 將 Callable 包裝成 FutureTask,FutureTask也是一種Runnable
MyCallable callable = new MyCallable();
FutureTask<Integer> task = new FutureTask<>(callable);
// 建立執行緒物件
Thread t3 = new Thread(task);

當執行緒執行起來後,可以通過 FutureTask 的 get 方法獲取任務執行結果:

Integer result = task.get();

不過,需要注意的是,get 方法會阻塞住當前呼叫這個方法的執行緒。比如說我們在主執行緒中呼叫了 get 方法去獲取 t3 執行緒的任務執行結果,那麼只有這個 call 方法成功返回了,主執行緒才能夠繼續往下執行。

換句話說,如果 call 方法一直得不到結果,那麼主執行緒也就一直無法向下執行。

啟動執行緒

OK,綜上,我們已經把執行緒成功建立出來了,那麼怎麼把它啟動起來呢?

以第一種建立執行緒的方法為例:

// 建立執行緒
Thread t1 = new Thread("t1") {
	@Override
	// run 方法內實現了要執行的任務
	public void run() {
		// 執行緒需要執行的任務
    	......
 	}
};

// 啟動執行緒
t1.start();

這裡涉及一道經典的面試題,即為什麼使用 start 啟動執行緒,而不使用 run 方法啟動執行緒

使用 run 方法啟動執行緒看起來好像並沒啥問題,對吧,run 方法內定義了要執行的任務,呼叫 run 方法不就執行了這個任務了?

這確實沒錯,任務確實能夠被正確執行,但是並不是以多執行緒的方式,當我們使用 t1.run() 的時候,程式仍然是在建立 t1 執行緒的 main 執行緒下執行的,並沒有建立出一個新的 t1 執行緒。

舉個例子:

// 建立執行緒
Thread t1 = new Thread("t1") {
	@Override
	// run 方法內實現了要執行的任務
	public void run() {
		// 執行緒需要執行的任務
        System.out.println("開始執行");
    	FileReader.read(檔案地址); // 讀檔案
 	}
};

t1.run();
System.out.println("執行完畢");

如果使用 run 方法啟動執行緒,"執行完畢" 這句話需要在檔案讀取完畢後才能夠輸出,也就是說讀檔案這個操作仍然是同步的。假設讀取操作花費了 5 秒鐘,如果沒有執行緒排程機制,這 5 秒 CPU 什麼都做不了,其它程式碼都得暫停。

而如果使用 start 方法啟動執行緒,"執行完畢" 這句話在檔案讀取完畢之前就會被很快地輸出,因為多執行緒讓方法執行變成了非同步的,讀取檔案這個操作是 t1 執行緒在做,而 main 執行緒並沒有被阻塞。

? 關注公眾號 | 飛天小牛肉,即時獲取更新

  • 博主東南大學碩士在讀,攜程 Java 後臺開發暑期實習生,利用課餘時間運營一個公眾號『 飛天小牛肉 』,2020/12/29 日開通,專注分享計算機基礎(資料結構 + 演算法 + 計算機網路 + 資料庫 + 作業系統 + Linux)、Java 技術棧等相關原創技術好文。本公眾號的目的就是讓大家可以快速掌握重點知識,有的放矢。關注公眾號第一時間獲取文章更新,成長的路上我們一起進步
  • 並推薦個人維護的開源教程類專案: CS-Wiki(Gitee 推薦專案,現已累計 1.7k+ star), 致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習 ~ ?
  • 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 700+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中。

相關文章