線程是Java語言中不可或缺的重要功能,它能使複雜的非同步代碼變得更簡單,從而極大地簡化了複雜系統的開發。此外,要想充分發揮多處理器系統的強大計算能力,最簡單的方式就是使用線程。隨著處理器數量的持續增長,如何高效地使用併發正變得越來越重要。 ...
一.程式、進程和線程
程式是一組指令的有序集合,也可以將其通俗地理解為若幹行代碼。它本身沒有任何運行的含義,它只是一個靜態的實體,它可能只是一個單純的文本文件,也有可能是經過編譯之後生成的可執行文件。
從狹義來說,進程是正在運行的程式的實例;從廣義上來說,進程是一個具有一定獨立功能的程式關於某個數據集合的一次運行活動。進程是操作系統進行資源分配的基本單位。
線程是進程中可獨立執行的最小單位,它也是處理器進行獨立調度和分派的基本單位。一個進程可以包含多個線程,每個線程執行自己的任務,同一個進程中的所有線程共用該進程中的資源,如記憶體空間、文件句柄等。
二.多線程編程簡介
什麼是多線程編程
多線程編程技術是Java語言的重要特點。多線程編程的含義是將程式任務分成幾個並行的子任務,並將這些子任務交給多個線程去執行。
多線程編程就是以線程為基本抽象單位的一種編程範式。但是,多線程編程又不僅僅是使用多個線程進行編程那麼簡單,其自身又有需要解決的問題。多線程編程和麵向對象編程是可以相容的,即我們可以在面向對象編程的基礎上實現多線程編程。事實上,Java平臺中的一個線程就是一個對象。
為什麼要使用多線程編程
現在的電腦動輒就是多處理器核心的,而每一個線程同一時間只能運行在一個處理器上。如果只採用單線程進行開發,那麼就不能充分利用多核處理器的資源來提高程式的執行效率。而使用多線程進行編程時,不同的線程可以運行在不同的處理器上。這樣一來,不僅大大提高了對電腦資源的利用率,同時也提高了程式的執行效率。
三.Java線程API簡介
java.lang.Thread類就是Java平臺對線程的實現。Thread類或其子類的一個實例就是一個線程。
1.線程的創建、啟動和運行
在Java平臺中,創建一個線程就是創建一個Thread類(或其子類)的示例。每個線程都有其要執行的任務。線程的任務處理邏輯可以在Thread類的run方法中直接實現或者通過該方法進行調用,因此run方法相當於線程的任務處理邏輯的入口方法,它應該由Java虛擬機在運行相應線程時直接調用,而不應該由應用代碼進行調用。
運行一個線程實際上就是讓Java虛擬機執行該線程的run方法,從而使任務處理邏輯代碼得以執行。如果一個線程沒有啟動,它的run方法是絕對不會被執行的。為此,首先需要啟動線程。Thread類的start方法的作用是啟動相應的線程。啟動一個線程的實質是請求虛擬機運行相應的線程,而這個線程具體何時能夠運行是由線程調度器(線程調度器是操作系統的一部分)決定的。因此,調用線程的start方法並不意味著線程已經開始運行,這個線程可能馬上開始運行,也有可能稍後才被運行,也有可能永遠不運行。
下麵介紹兩種創建線程的方式(實際上還有其他方式,後續文章中會詳細介紹)。在此之前我們先來看一下Thread類的run方法的源碼:
// Code 1-1
@Override
public void run() {
if (target != null) {
target.run();
}
}
這個run方法是在介面Runnable中定義的,它不接受參數也沒有返回值。事實上Runnable介面中也只有這一個方法,因此這個介面是一個函數式介面,這意味著我們可以在需要Runnable的地方使用lambda表達式。Thread類實現了這個介面,因此它必須實現這個方法。target是Thread類中的一個域,它的類型也是Runnable。target域表示這個線程需要執行的內容,而Thread類的run方法所做的也只是執行target的run方法。
我們剛剛提到,Java虛擬機會自動調用線程的run方法。但是,Thread類的run方法已經定義好了,我們沒有辦法將自己需要執行的代碼放在Thread類的run方法中。因此,我們可以考慮其他的方式來影響run方法的行為。第一種就是繼承Thread類並重寫run方法,這樣JVM在運行線程時就會調用我們重寫的run方法而不是Thread類的run方法;第二種方法是將我們要執行的代碼傳遞給Thread類的target方法,而剛好Thread類有幾個構造器可以直接對target進行賦值,這樣一來,JVM在調用run方法時執行的仍然是我們傳遞的代碼。
在Java平臺中,每個線程都可以擁有自己預設的名字,當然我們也可以在構造Thread類的實例時為我們的線程起一個名字,這個名字便於我們區分不同的線程。
下麵的代碼使用上述的兩種方式創建了兩個線程,它們要執行的任務很簡單——列印一行歡迎信息,並且要包含自己的名字。
// Code 1-2
public class WelcomeApp {
public static void main(String[] args) {
Thread thread1 = new WelcomeThread();
Thread thread2 = new Thread(() -> System.out.println("2. Welcome, I'm " + Thread.currentThread().getName()));
thread1.start();
thread2.start();
}
}
class WelcomeThread extends Thread {
@Override
public void run() {
System.out.println("1. Welcome, I'm " + Thread.currentThread().getName());
}
}
下麵是這個程式運行時輸出的內容:
1. Welcome, I'm Thread-0
2. Welcome, I'm Thread-1
多次運行這個程式,我們可以發現這個程式的輸出也有可能是:
2. Welcome, I'm Thread-1
1. Welcome, I'm Thread-0
這說明,雖然thread1的啟動在thread2之前,但這並不意味著thread1會在thread2之前被運行。
不管採用哪種方式創建線程,一旦線程的run方法執行(由JVM調用)結束,相應線程的運行也就結束了。當然,run方法執行結束包括正常結束(run方法正常返回)和代碼中拋出異常而導致的終止。運行結束的線程所占用的資源(如記憶體空間)會如同其他Java對象一樣被JVM回收。
線程屬於“一次性用品”,我們不能通過重新調用一個已經運行結束的線程的start方法來使其重新運行。事實上,start方法也只能夠被調用一次,多次調用同一個Thread實例的start方法會導致其拋出IllegalThreadStateException異常。
2.線程的屬性
線程的屬性包括線程的編號、名稱、類別和優先順序, 詳情如下表所示:
上面提到了守護線程和用戶線程的概念,這裡對它們做一個簡要的說明。按照線程是否會阻止Java虛擬機正常停止,我們可以將Java中的線程分為守護線程(Daemon Thread)和用戶線程(User Thread,也稱非守護線程)。線程的daemon屬性用於表示相應線程是否為守護線程。用戶線程會阻止Java虛擬機的正常停止,即一個Java虛擬機只有在其所有用戶線程都運行結束(即Thread.run()調用未結束)的情況下才能正常停止。而守護線程則不會影響Java虛擬機的正常停止,即應用程式中有守護線程在運行也不影響Java虛擬機的正常停止。因此,守護線程通常用於執行一些重要性不是很高的任務,例如用於監視其他線程的運行情況。
當然,如果Java虛擬機是被強制停止的,比如在Linux系統下使用kill命令強制終止一個Java虛擬機進程,那麼即使是用戶線程也無法阻止Java虛擬機的停止。
3.Thread類的常用方法
下麵列出了Thread類中常用的方法:
Java中的任何一段代碼總是執行在某個線程之中。執行當前代碼的線程就被稱為當前線程,Thread.currentThread()可以返回當前線程。由於同一段代碼可能被不同的線程執行,因此當前線程是相對的,即Thread.currentThread()的返回值在代碼實際運行的時候可能對應著不同的線程(對象)。
join方法的作用相當於執行該方法的線程和線程調度器說:“我得先暫停一下,等到另外一個線程運行結束後我才能繼續。”
yield靜態方法的作用相當於執行該方法的線程對線程調度器說:“我現在不急,如果別人需要處理器資源的話先給它用吧。當然,如果沒有其他人要用,我也不介意繼續占用。”
sleep靜態方法的作用相當於執行該方法的線程對線程調度器說:“我想小憩一會兒,過段時間再叫醒我繼續幹活吧。”
4.Thread類中的廢棄方法
由於Java虛擬機實現得有些問題,因此Thread類的有些方法已經被廢棄了,在新寫的代碼中應該避免使用這些方法。部分廢棄的方法如下表所示:
雖然這些方法並沒有相應的替代品,但是可以使用其他辦法來實現,我們會在後續文章中學習這部分內容。
四.無處不在的線程
Java平臺本身就是一個多線程的平臺。除了Java開發人員自己創建和使用的線程,Java平臺中其他由Java虛擬機創建、使用的線程也隨處可見。當然,這些線程也是各自有其處理任務。
Java虛擬機啟動的時候會創建一個主線程(main線程),該線程負責執行Java程式的入口方法(main方法)。下麵的程式列印出主線程的名稱:
// Code 1-3
public class MainThreadDemo {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
}
}
該程式會輸出“main”,這說明main方法是由一個名為“main”的線程調用的,這個線程就是主線程,它是由JVM創建並啟動的。
在多線程編程中,弄清楚一段代碼具體是由哪個(或者哪種)線程去負責執行的這點很重要,這關係到性能、線程安全等問題。本系列的後續文章會體現這點。
Java 虛擬機垃圾回收器(Garbage Collector)負責對Java程式中不再使用的記憶體空間進行回收,而這個回收的動作實際上也是通過專門的線程(垃圾回收線程)實現的,這些線程由Java虛擬機自行創建。
為了提高Java代碼的執行效率,Java虛擬機中的JIT(Just In Time)編譯器會動態地將Java位元組碼編譯為Java虛擬機宿主機處理器可直接執行的機器碼。這個動態編譯的過程實際上是由Java虛擬機創建的專門的線程負責執行的。
Java平臺中的線程隨處可見,這些線程各自都有其處理任務。
五.線程的層次關係
Java平臺中的線程不是孤立的,線程與線程之間總是存在一些聯繫。假設線程A所執行的代碼創建了線程B, 那麼,習慣上我們稱線程B為線程A的子線程,相應地線程A就被稱為線程B的父線程。例如, Code 1-2中的線程thread1和thread2是main線程的子線程,main線程是它們的父線程。子線程所執行的代碼還可以創建其他線程,因此一個子線程也可以是其他線程的父線程。所以,父線程、子線程是一個相對的稱呼。理解線程的層次關係有助於我們理解Java應用程式的結構,也有助於我們後續闡述其他概念。
在Java平臺中,一個線程是否是一個守護線程預設取決於其父線程:預設情況下父線程是守護線程,則子線程也是守護線程;父線程是用戶線程,則子線程也是用戶線程。另外,父線程在創建子線程後啟動子線程之前可以調用該線程的setDaemon方法,將相應的線程設置為守護線程(或者用戶線程)。
一個線程的優先順序預設值為該線程的父線程的優先順序,即如果我們沒有設置或者更改一個線程的優先順序,那麼這個線程的優先順序的值與父線程的優先順序的值相等。
不過,Java平臺中並沒有API用於獲取一個線程的父線程,或者獲取一個線程的所有子線程。並且,父線程和子線程之間的生命周期也沒有必然的聯繫。比如父線程運行結束後,子線程可以繼續運行,子線程運行結束也不妨礙其父線程繼續運行。
六.線程的生命周期狀態
在Java平臺中,一個線程從其創建、啟動到其運行結束的整個生命周期可能經歷若幹狀態。如下圖所示:
線程的狀態可以通過Thread.getState()調用來獲取。Thread.getState()的返回值類型Thread.State,它是Thread類內部的一個枚舉類型。Thread.State所定義的線程狀態包括以下幾種:
NEW:一個己創建而未啟動的線程處於該狀態。由於一個線程實例只能夠被啟動一次,因此一個線程只可能有一次處於該狀態。
RUNNABLE:該狀態可以被看成一個複合狀態,它包括兩個子狀態:READY和RUNNING,但實際上Thread.State中並沒有定義這兩種狀態。前者表示處於該狀態的線程可以被線程調度器進行調度而使之處於RUNNING狀態。後者表示處於該狀態的線程正在運行,即相應線程對象的run方法所對應的指令正在由處理器執行。執行Thread.yield()的線程,其狀態可能會由RUNNING轉換為READY。處於READY子狀態的線程也被稱為活躍線程。
BLOCKED:一個線程發起一個阻塞式I/0操作後,或者申請一個由其他線程持有的獨占資源(比如鎖)時,相應的線程會處於該狀態。處於BLOCKED狀態的線程並不會占用處理器資源。當阻塞式1/0操作完成後,或者線程獲得了其申請的資源,該線程的狀態又可以轉換為RUNNABLE。
WAITING:一個線程執行了某些特定方法之後就會處於這種等待其他線程執行另外一些特定操作的狀態。能夠使其執行線程變更為WAITING狀態的方法包括:Object.wait()、Thread.join()和LockSupport.park(Object)。能夠使相應線程從WAITING變更為RUNNABLE的相應方法包括:Object.notify()/notifyAll()和LockSupport.unpark(Object))。
TIMED_WAITING:該狀態和WAITING類似,差別在於處於該狀態的線程並非無限制地等待其他線程執行特定操作,而是處於帶有時間限制的等待狀態。當其他線程沒有在指定時間內執行該線程所期望的特定操作時,該線程的狀態自動轉換為RUNNABLE。
TERMINATED:已經執行結束的線程處於該狀態。由於一個線程實例只能夠被啟動一次,因此一個線程也只可能有一次處於該狀態。run方法正常返回或者由於拋出異常而提前終止都會導致相應線程處於該狀態。
一個線程在其整個生命周期中,只可能有一次處於NEW狀態和TERMINATED狀態。
七.多線程編程的優勢
多線程編程具有以下優勢:
- 提高系統的吞吐率:多線程編程使得一個進程中可以有多個併發(即同時進行的)的操作。例如,當一個線程因為I/0操作而處於等待時,其他線程仍然可以執行其操作。
- 提高響應性:在使用多線程編程的情況下,對於GUI軟體(如桌面應用程式)而言,一個慢的操作(比如從伺服器上下載一個大的文件)並不會導致軟體的界面出現被“凍住”的現象而無法響應用戶的其他操作;對於Web應用程式而言,一個請求的處理慢了並不會影響其他請求的處理。
- 充分利用多核處理器資源:如今多核處理器的設備越來越普及,就算是手機這樣的消費類設備也普遍使用多核處理器。實施恰當的多線程編程有助於我們充分利用設備的多核處理器資源,從而避免了資源浪費。
多線程編程也有自身的問題與風險,包括以下幾個方面:
- 線程安全問題。多個線程共用數據的時候,如果沒有採取相應的併發訪問控制措施,那麼就可能產生數據一致性問題,如讀取臟數據(過期的數據)、丟失更新(某些線程所做的更新被其他線程所做的更新覆蓋)等。
- 線程活性間題。一個線程從其創建到運行結束的整個生命周期會經歷若於狀態。從單個線程的角度來看,RUNNABLE狀態是我們所期望的狀態。但實際上,代碼編寫不當可能導致某些線程一直處於等待其他線程釋放鎖的狀態(BLOCKED狀態),這種情況稱為死鎖(Deadlock)。當然,一直忙碌的線程也可能會出現問題,它可能面臨活鎖(Livelock)問題,即一個線程一直在嘗試某個操作但就是無法進展。另外,線程是一種稀缺的計算資源,一個系統所擁有的處理器數最相比於該系統中存在的線程數量而言總是少之又少的。某些情況下可能出現線程饑餓(Starvation)的問題,即某些線程永遠無法獲取處理器執行的機會而永遠處於RUNNABLE狀態的READY子狀態。
- 上下文切換。處理器從執行一個線程轉向執行另外一個線程的時候操作系統所需要做的一個動作被稱為上下文切換。由於處理器資源的稀缺性,因此上下文切換可以被看作多線程編程的必然副產物,它增加了系統的消耗,不利於系統的吞吐率。