挺基礎的知識,一開始不是很願意寫,畢竟這種簡單的知識大家不一定願意看,而且容易寫的大眾化,不過還好梳理一遍下來還算是有點收獲,比如我看了 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 可以獲取配套教程,目前尚在更新中。