《Java多線程編程實戰指南(核心篇)》閱讀筆記
1 線程概念
1.1 進程、線程
- 進程
- 程序向操作系統申請資源(如內存空間和文件句柄)的基本單位
- 線程
- 進程中可獨立執行的最小單位
一個進程可以包含多個線程,同一個進程中的所有線程共享該進程中的資源,如內存空間、文件句柄等。
1.2 Java中線程的創建
在Java平台中創建一個線程就是創建一個Thread類(或其子類)的實例;運行一個線程實際上就是讓Java虛擬機執行其run方法, 使相應的任務處理邏輯代碼得以執行,Thread類的start方法的作用是啟動相應的線程。
start方法調用結束並不意味着相應線程已經開始運行,這個線程可能稍后才被運行,甚至也可能永遠不會被運行。 因為啟動一個線程的實質是請求Java虛擬機運行相應的線程,而這個線程具體何時能夠運行是由線程調度器(Scheduler)決定的。
- 創建Java線程
- Thread類的兩個常用構造器是:Thread和Thread(Runnable target),Thread是線程的抽象,Runnable可以看作是對任務的抽象。
new Thread(){ @Override public void run() { // 執行任務 } }; new Thread(new Runnable() { @Override public void run() { // 執行任務 } });
- 線程不可重復使用
- 線程屬於“一次性用品”,我們不能通過重新調用一個已經運行結束的線程的start方法來使其重新運行。 事實上,多次調用同一個Thread實例的start方法會導致其拋出IllegalThreadStateException異常。
- 內存空間分配
- 首先在Java中,一個線程就是一個對象,對象的創建需要內存空間的分配。 與創建其他類型的Java對象所不同的是,Java虛擬機會為每個線程分配調用棧(Call Stack)所需的內存空間。 調用棧用於跟蹤方法間的調用關系以及Java代碼對本地代碼(Native Code,通常是C代碼)的調用。 另外,Java平台中的每個線程可能還有一個內核線程(具體與Java虛擬機的實現有關)與之對應。 因此相對來說,創建線程對象比創建其他類型的對象的成本要高一些。
- 執行線程與當前線程
- Java中的任意一段代碼(比如一個方法)總是由確定的線程負責執行的,這個線程就相應地被稱為這段代碼的執行線程; 任意一段代碼都可以通過調用Thread.currentThread()來獲取這段代碼的執行線程,這個線程就被稱為當前線程。
1.3 線程(Thread)的屬性
線程的屬性包括線程的編號(Id)、名稱(Name)、線程類別(Daemon)和優先級(Priority)。
屬性 | 類型 | 用途 | 只讀 | 說明 |
編號(ID) |
long |
用於標識不同的線程,不同的線程擁有 不同的編號 |
是 |
某個編號的線程運行結束后,該編號可能被后續創建的線程使用。不同線程 擁有的編號雖然不同,但是這種編號的唯一性只在Java虛擬機的一次運行有 效。也就是說重啟個Java虛擬機(如重啟Web服務器)后,某些線程的編號可能 與上次Java虛擬機運行的某個線程的編號一樣,因此該屬性的值不適合用作 某種唯一標識,特別是作為數據庫中的唯一標識(如主鍵) |
名稱 (Name) |
String |
用於區分不同的線程,默認值與線程的 編號有關,默認值的格式為:“Thread- 線程編號”,如“Thread-0” |
否 |
Java並不禁止我們將不同的線程的名稱屬性設置為相同的值,盡管如此,設 置線程的名稱屬性有助於代碼調試和問題定位 |
線程類別 (Daemon) |
boolean |
值為tnue表示相應的線程為守護線程, 否則表示相應的線程為用戶線程。該屬 性的默認值與相應線程的父線程的該屬 性的值相同 |
否 |
該屬性必須在相應線程啟動之前設置,即對setDaemon方法的調用必須在對 start方法的調用之前,否則setDaemon方法會拋出 IllegalThreadStateException異常。負責一些關鍵任務處理的線程不適宜設 置為守護線程 |
優化級 (Priority) |
int |
該屬性本質上是給線程調度器的提示, 用於表示應用程序希望哪個線程能夠優 先得以運行。Java定義了1~10的10個優 先級,默認值一般為5(表示普通優先級 )。對於具體的一個線程而言,其優先 級的默認值與其父線程(創建該線程的 線程)的優先級值相等。 |
否 |
一般使用默認優先級即可,不恰當地設置該屬性值可能導致嚴重的問題(線程 飢餓) |
- 線程屬性的使用
- 線程的屬性除了編號外,其他屬性都是可讀寫的屬性,即Thread類提供了相應的get方法和set方法用於讀取或者設置相應的屬性。
Thread.currentThread().getName();
- 優先級的設定
- Java線程的優先級屬性本質上只是一個給線程調度器的提示信息,以便於線程調度器決定優先調度哪些線程運行。 它並不能保證線程按照其優先級高低的順序運行。另外,Java線程的優先級使用不當或者濫用則可能導致某些線程永遠無法得到運行, 即產生了線程飢錢(Thread Starvation)。因此,線程的優先級並不是設置得越高越好; 一般情況下使用普通優先級即可,即不必設置線程的優先級屬性。
- 用戶線程與守護線程
- 按照線程是否會阻止Java虛擬機正常停止,我們可以將Java中的線程分為守護線程(Daemon Thread)和用戶線程 (User Thread,也稱非守護線程)。其中用戶線程會阻止Java虛擬機的正常停止, 即一個Java虛擬機只有在其所有用戶線程都運行結束的情況下才能正常停止; 而守護線程則不會影響Java虛擬機的正常停止,即應用程序中有守護線程在運行也不影響虛擬機的正常停止。 因此,守護線程通常用於執行一些重要性不是很高的任務,例如用於監視其他線程的運行情況。
1.4 Thread類的常用方法
方法 | 功能 | 備注 |
static Thread currentThread() |
返回當前線程,即當前代碼的執行線程(對象) |
同一段代碼在不同時刻對Thread.currentThread的調用,其返回值可能不同 |
void run() | 線程的任務處理邏輯 | 該方法是由Java虛擬機直接調用的,一般情況下應用程序不應該調用該方法 |
void start() |
啟動線程 |
該方法的返回並不代表相應的線程已經被啟動;一個Thread實例的start方法 只能夠被調用一次,多次調用會拋出異常 |
void join() | 等待線程運行結束 | 線程A調用線程B的join方法,那么線程A的運行會被暫停,直到線程B運行結束 |
static void yield() |
使當前線程主動放棄其對處理器的占用,這可 能導致當前線程被暫停 |
這個方法是不可靠的,該方法被調用時當前線程可能仍然繼續運行(視系統當 前的運行狀況而定) |
static void sleep(long millis) |
使當前線程休眠(暫停運行)指定的時間 |
|
1.5 線程的層次關系
- 父線程與子線程
- Java平台中的線程不是孤立的,線程與線程之間總是存在一些聯系。假設線程A所執行的代碼創建了線程B, 那么,習慣上我們稱線程B為線程A的子線程,相應地線程A就被稱為線程B的父線程。不過Java平台中並沒有API用於獲取一個線程的父線程, 或者獲取一個線程的所有子線程。
- 父子線程的Daemon值
- 默認情況下,父線程是守護線程,則子線程也是守護線程,父線程是用戶線程,則子線程也是用戶線程。 另外,父線程在創建子線程后啟動子線程之前可以調用該線程的setDaemon方法,將相應的線程設置為守護線程(或者用戶線程)。
- 父子線程的優先級
- 一個線程的優先級默認值為該線程的父線程的優先級,即如果我們沒有設置或者更改一個線程的優先級, 那么這個線程的優先級的值與父線程的優先級的值相等。
- 父子線程的生命周期
- 父線程和子線程之間的生命周期也沒有必然的聯系。比如父線程運行結束后,子線程可以繼續運行, 子線程運行結束也不妨礙其父線程繼續運行。
- 工作者線程
- 習慣上,我們也稱某些子線程為エ作者線程(Worker Thread)或者后台線程(Background Thread)。 工作者線程通常是其父線程創建來用於專門負責某項特定任務的執行的。 例如,Java虛擬機中對內存進行回收的線程通常被稱為GC工作者線程。
1.6 線程的生命周期
Figure 1: Java線程的狀態
Java線程的狀態可以使用監控工具査看,也可以通過Thread.getState()調用來獲取。 Thread.getState()的返回值類型Thread.State是一個枚舉類型,其定義的線程狀態包括以下幾種:
- NEW
- 一個已創建而未啟動的線程處於該狀態。由於一個線程實例只能夠被啟動次,因此一個線程只可能有一次處於該狀態。
- RUNNABLE
- 該狀態可以被看成一個復合狀態,它包括兩個子狀態:READY和RUNNING。 前者表示處於該狀態的線程可以被線程調度器(Scheduler)進行調度而使之處於RUNNING狀態; 后者表示處於該狀態的線程正在運行,即相應線程對象的run方法所對應的指令正在由處理器執行。 執行Thread.yield()的線程,其狀態可能會由RUNNING轉換為READY。處於READY子狀態的線程也被稱為活躍線程。
- BLOCKED
- 一個線程發起一個阻塞式I/O(Blocking I/O)操作后,或者申請一個由其他線程持有的獨占資源(比如鎖)時,相應的線程會處於該狀態, 處於Blocked狀態的線程並不會占用處理器資源。當阻塞式1O操作完成后,或者線程獲得了其申請的資源, 該線程的狀態又可以轉換為RUNNABLE。
- Waiting
- 一個線程執行了某些特定方法之后,就會處於這種等待其他線程執行另外一些特定操作的狀態。 能夠使其執行線程變更為WAITING狀態的方法包括:Object.wait()、Thread.join()和LockSupport.park(Object)。 能夠使相應線程從WAITING變更為RUNNABLE的對應方法包括:Object.notify()、notifyAll()和LockSupport.unpark(Objec)
- TIMED WAITING
- 該狀態和WAITING類似,差別在於處於該狀態的線程並非無限制地等待其他線程執行特定操作,而是處於帶有時間限制的等待狀態。 當其他線程沒有在指定時間內執行該線程所期望的特定操作時,該線程的狀態自動轉換為RUNNABLE。
- TERMINATED
- 已經執行結束的線程處於該狀態。由於一個線程實例只能夠被啟動一次,因此一個線程也只可能有一次處於該狀態。 Thread.run()正常返回或者由於拋出異常而提前終止都會導致相應線程處於該狀態。
2 多線程編程
2.1 串行、並發與並行
Figure 2: 串行、並發與並行示意圖
- 串行(Sequential)
- 先開始做事情A,待其完成之后再開始做事情B,依次類推,直到事情C完成。這實際上順序逐一完成幾件事情,只需要投入一個人。 在這種方式下3件事情總共耗時35(15+10+10)分鍾。
- 並發(Concurrent)
- 這種方式也可以只投入一個人,這個人先開始做事情A,事情A的准備活動做好后(此時消耗了5分鍾), 在等待事情A完成的這段時間內他開始做事情B,為事情B的准備活動花了2分鍾之后,在等待事情B完成的這段時間內他開始做事情C, 直到10分鍾之后事情C完成。這整個過程實際上是以交替的方式利用等待某件事情完成的時間來做其他事情, 在這種方式下3件事情總共耗時17(5+2+10)分鍾,這比串行方式節約了一半多的時間。
- 並行(Parallel)
- 這種方式需要投入3個人,每個人負責完成其中一件事情,這3個人在同一時刻開始齊頭並進地完成這些事情。 在這種方式下3件事情總共耗時15分鍾(取決於耗時最長的那件事情所需的時間),比並發的方式節約了2分鍾的時間。
並發往往帶有部分串行,而並發的極致就是並行。從軟件的角度來說,並發就是在一段時間內以交替的方式去完成多個任務, 而並行就是以齊頭並進的方式去完成多個任務。
從軟件角度講,要以並發的方式去完成幾個任務往往需要借助多個線程(而不是一個線程)。 從硬件的角度來說,在一個處理器一次只能夠運行一個線程的情況下,由於處理器可以使用時間片(Time-slice)分配的技術在同一段時間內運行多個線程, 因此一個處理器就可以實現並發。而並行則需要靠多個處理器在同一時刻各自運行一個線程來實現。
多線程編程的實質就是將任務的處理方式由串行改為並發,即實現並發化,以發揮並發的優勢。如果一個任務的處理方式可以由串行改為並發(或者並行), 那么我們就稱這個任務是可並發化(或者可並行化)的。
2.2 多線程編程中存在的問題
2.2.1 競態(Race Condition)
- 概念
- 競態是指計算的正確性依賴於相對時間順序或者線程的交錯。競態往往伴隨着讀取臟數據問題(即讀取到一個過時的數據), 以及丟失更新問題(即一個線程對數據所做的更新沒有體現在后續其他線程對該數據的讀取上)。
- 競態的出現
public class Main { static class IndexGen { private int index; int nextIndex() { return index++; } } public static void main(String[] args) throws InterruptedException { IndexGen indexGen = new IndexGen(); final int num = 100; final int[] visited = new int[num]; final List<Thread> threads = new ArrayList<>(); for (int i = 0; i < num; i++) { threads.add(new Thread(() -> visited[indexGen.nextIndex()] ++)); } threads.forEach(Thread::start); for (Thread thread : threads) { thread.join(); } for (int i = 0; i < 10; i++) { for (int j = 0; j < 10; j++) { System.out.printf("%d\t", visited[i * 10 + j]); } System.out.println(); } } }
參考上述程序,我們創建一百個線程,每次通過IndexGen獲取下一個index,並將其訪問次數加一。我們期望的結果是每個index都只被訪問一次, 但多次運行后,卻發現可能出現下面的結果,其中,0,62,85被訪問了兩次,導致97,98,99未被訪問:
2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0
上述例子中,依照nextIndex()方法實現的邏輯,下標總是遞增的,因此不同的線程它們所“拿到”的index也不應該相同才對。 但從結果來看,不同線程卻“拿到”了重復的index,即nextIndex()所返回的下標值重復了。且如果我們多次重復運行代碼,所得到的結果也不完全相同。 這個現象說明,當多個線程在沒有采取任何控制措施的情況下並發地更新、讀取同一個共享變量時,是不安全的,可能出現意料之外的結果。
- 出現競態的原因
上述例子中的
index++
看起來像是一個操作,實際上相當於如下3個指令:1oad(index,r1); //指令①:將變量index的值從內存讀到寄存器r1 increment(r1); //指令②:將寄存器r1的值增加1 store(index,r1); /指令③:將奇存器r1的內容寫入變量index所對應的內存空間
多個線程在執行上述指定時,可能交錯運行上述三個指令,導致三個線程讀取到的index值重復,如下表如示:
thread-0 thread-1 thread-2 t1 未運行 執行其他操作 執行其他操作 t2 執行其他操作 [index=0]執行指令① [index=0]執行指令① t3 執行其他操作 [r1=0]執行指令② [r1=0]執行指令② t4 [index=0]執行指令① [r1=1][index=0]執行指令③ [r1=1][index=0]執行指令③ t5 [r1=0]執行指令② [index=1]執行其他操作 [index=1]執行其他操作 t6 [r1=1][index=0]執行指令③ 運行結束 運行結束 t7 [index=1]執行其他操作 運行結束 運行結束 從上述競態典型實例中我們可以提煉出競態的兩種模式:read-modify-write(讀-改-寫)和check-then-act(檢測-執行)。
- read-modify-write
- 讀取個共享變量的值(read),然后根據該值做一些計算(modify),接着更新該共享變量的值(write)
- check-then-act
- 讀取(read)某個共享變量的值,根據該變量的值(如if語句)決定下一步的動作(act)是什么。
2.2.2 線程安全性(Thread Safety)
- 定義
- 一般而言,如果一個類在單線程環境下能夠運作正常,並且在多線程環境下,如果使用方不必做任何改變的情況下也能運作正常, 那么我們就稱其是線程安全的,相應地我們稱這個類具有線程安全性
線程安全問題概括來說表現為三個方面:原子性、可見性和有序性。
2.2.3 原子性(Automicity)
- 定義
- 原子的字面意思是不可分割的。對於涉及共享變量訪問的操作,若該操作從其執行線程以外的任意線程來看是不可分割的, 那么該操作就是原子操作,相應地我們稱該操作具有原子性。
- 不可分割(Indivisible)
- 其中一個含義是指訪問(讀、寫)某個共享變量的操作從其執行線程以外的任何線程來看, 該操作要么已經執行結束要么尚未發生,即其他線程不會“看到”該操作執行了部分的中間效果。 另一個含義是,如果T1和T2是訪問共享變量V的兩個原子操作,如果這兩個操作並非都是讀操作, 那么個線程執行T1期間(開始執行而未執行完畢),其他線程無法執行T2。也就是說,訪問同一組共享變量的原子操作是不能夠被交錯的, 這就排除了一個線程執行一個操作期間另外一個線程讀取或者更新該操作所訪問的共享變量而導致的干擾(讀臟數據)和沖突(丟失更新)的可能。
總的來說,Java中有兩種方式來實現原子性:鎖(Lock)和CAS(Compare-And-Swap)指令。
- 鎖
- 鎖具有排他性,即它能夠保障一個共享變量在任意一個時刻只能夠被一個線程訪問, 這就排除了多個線程在同一時刻訪問同一個共享變量而導致干擾與沖突的可能,即消除了競態。
- CAS指令
- CAS指令實現原子性的方式與鎖實現原子性的方式實質上是相同的,差別在於鎖通常是在軟件這一層次實現的, 而CAS是直接在硬件(處理器和內存)這一層次實現的,它可以被看作“硬件鎖”。
在Java語言中,long型和double型以外的任何類型的變量的寫操作都是原子操作,包括:byte、boolean、short、char、foat、int和引用型變量。
對long/double型變量的寫操作由於Java語言規范並不保障其具有原子性,因此在多個線程並發訪問同一long/double型變量的情況下, 一個線程可能會讀取到其他線程更新該變量的“中間結果”。這是因為Java中的long/double型變量會占用64位的存儲空間, 而32位的Java虛擬機對這種變量的寫操作可能會被分解為兩個步驟來實施,比如先寫低32位,再寫高32位。 那么,在多個線程試圖共享同一個這樣的變量時就可能出現一個線程在寫高32位的時候,另外一個線程正在寫低32位的情形。
2.2.4 可見性(Visibility)
- 定義
- 在多線程環境下,一個線程對某個共享變量進行更新之后,后續訪問該變量的線程可能無法立刻讀取到這個更新的結果, 甚至永遠也無法讀取到這個更新的結果。如果一個線程對某個共享變量進行更新之后,后續訪問該變量的線程可以讀取到該更新的結果, 那么我們就稱這個線程對該共享變量的更新對其他線程可見,否則我們就稱這個線程對該共享變量的更新對其他線程不可見。
多線程程序在可見性方面存在問題意味着某些線程讀取到了舊數據(Stale Data),而這可能導致程序出現我們所不期望的結果。
- 寄存器和高速緩存帶導致的不可見
程序中的變量可能會被分配到寄存器(Register)而不是主內存中進行存儲,每個處理器都有其自己的寄存器, 而一個處理器無法讀取另外一個處理器上的寄存器中的內容。因此,如果兩個線程分別運行在不同的處理器上, 且這兩個線程所共享的變量卻被分配到寄存器上進行存儲,那么可見性問題就會產生。
另外,即便某個共享變量是被分配到主內存中進行存儲的,也不能保證該變量的可見性。這是因為處理器對主內存的訪問並不是直接訪問, 而是通過其高速緩存子系統進行的。一個處理器上運行的線程對變量的更新可能只是更新到該處理器的寫緩沖器中,還沒有到達該處理器的高速緩存中, 更不用說到主內存中了。而一個處理器的寫緩沖器中的內容無法被另外一個處理器讀取, 因此運行在另外一個處理器上的線程無法看到這個線程對某個共享變量的更新。
- 緩存一致性協議(Cache Coherence Protocol)
- 通過緩存一致性協議可以讓一個處理器來讀取其他處理器的高速緩存中的數據,
並將讀到的數據更新到該處理器的高速緩存中。這種一個處理器從其自身處理器緩存以外的其他存儲部件中讀取數據, 並將其更新到該處理器的高速緩存的過程,我們稱之為緩存同步。相應地,我們稱這些存儲部件的內容是可同步的, 可同步的存儲部件包括處理器的高速緩存、主內存。
緩存同步使得一個處理器上運行的線程,可以讀取到另外一個處理器上運行的線程對共享變量所做的更新,這樣在硬件層次上即保障了可見性。
- 沖刷處理器緩存
- 為了保障可見性,我們必須使一個處理器對共享變量所做的更新最終被寫入該處理器的高速緩存或者主內存中
而不是始終停留在其寫緩沖器中,這個過程被稱為沖刷處理器緩存。
- 刷新處理器緩存
- 同樣,為了保障可見性,一個處理器在讀取共享變量的時候,如果其他處理器在此之前已經更新了該變量,
那么該處理器必須從其他處理器的高速緩存或者主內存中,對相應的變量進行緩存同步,這個過程被稱為刷新處理器緩存。
- JIT優化導致的不可見
public class Main { static /**volatile**/ boolean isCanceled = false; public static void main(String[] args) throws InterruptedException { new Thread(() -> { while (!isCanceled) { // System.out.println("hello"); } }).start(); System.out.println("come here"); Thread.sleep(1000); isCanceled = true; } }
如果我們運行上述代碼,我們會發現主程序永遠不會結束,但是如果我們去掉對volatile的注釋,那么程序將只運行一秒。
這是JIT優化導致變量不可見的一個十分典型的例子,JIT在優化代碼時,可能將isCanceled當做非共享變量處理, 認為isCanceled在當前線程不會發生改變,進而直接使用false代替。另外比較有趣的一點,就是如果我們保持將volatile注釋掉的狀態, 但是在每次循環中打印一條"hello"語句,那么程序將同樣只運行一秒,這從另一個角度JIT優化將為不安全的多線程程序的帶來難以預測的結果。
- volatile
- 該關鍵字所起到的一個作用就是提示JIT編譯器被修飾的變量可能被多個線程共享,
以阻止JT編譯器做出可能導致程序運行不正常的優化;另外一個作用就是讀取一個volatile關鍵字修飾的變量會使相應的處理器執行刷新處理器緩存的動作, 寫個 volatile關鍵字修飾的變量會使相應的處理器執行沖刷處理器緩存的動作,從而保障了可見性。
- Java語言規范(Java Language Specification)中的可見性保證
- Java語言規范保證,父線程在啟動子線程之前對共享變量的更新對於子線程來說是可見的
- Java語言規范保證,一個線程終止后,該線程對共享變量的更新對於調用該線程的join方法的線程而言是可見的
- 需要注意的幾個問題
- 可見性得以保障,並不意味着一個線程能夠看到另外一個線程更新的所有變量的值。如果一個線程在某個時刻更新了多個共享變量的值, 那么此后其他線程再來讀取這些變量時,這些線程所讀取到的變量值有些是其他線程更新過的值,而有些則可能仍然是其他線程更新之前的值(舊值)。
另一方面,可見性的保障僅僅意味着一個線程能夠讀取到共享變量的相對新值,而不能保障該線程能夠讀取到相應變量的最新值。
- 相對新值
- 對於一個共享變量而言,一個線程更新了該變量的值之后,其他線程能夠讀取到這個更新后的值,那么這個值就被稱為該變量的相對新值。
- 最新值
- 如果讀取共享變量的線程,在讀取並使用該變量的時候其他線程無法更新該變量的值,那么該線程讀取到的相對新值就被稱為該變量的最新值。
相對新值描述的是一個時效性問題,即如果線程A在成功讀取到共享變量x當前最新值的同時,線程B更新了共享變量x,由於讀取動作已發生, 線程A獲取的值只能是相對新值,而x的最新值只有在線程A下一次讀取x時才會被獲取到。
- 可見性問題是多線程衍生出來的問題,它與程序的目標運行環境是單核處理器還是多核處理器無關。 也就是說,單處理器系統中實現的多線程編程也可能出現可見性問題:在目標運行環境是單處理器的情況下, 多線程的並發執行實際上是通過時間片分配實現的。此時,雖然多個線程是運行在同一個處理器上的, 但是由於在發生上下文切換的時候,一個線程對寄存器變量的修改會被作為該線程的線程上下文保存起來, 這導致另外一個線程無法“看到”該線程對這個變量的修政,因此,單處理器系統中實現的多線程編程也可能出現可見性問題。
2.2.5 有序性(Ordering)
- 重排序
- 重排序(Reordering)
- 在多核處理器的環境下,代碼的執行順序可能是沒有保障的:編譯器可能改變兩個操作的先后順序; 工處理器可能不是完全依照程序的目標代碼所指定的順序執行指令;另外,在一個處理器上執行的多個操作, 從其他處理器的角度來看其順序可能與目標代碼所指定的順序不一致。這種現象就叫作重排序。
重排序是對內存訪問有關的操作(讀和寫)所做的一種優化,它可以在不影響單線程程序正確性的情況下提升程序的性能。 但是,它可能對多線程程序的正確性產生影響,即它可能導致線程安全問題。
與可見性問題類似,重排序也不是必然出現的。重排序的潛在來源有許多,包括編譯器(在Java中指的是JIT編譯器)、 處理器和存儲子系統(包括寫緩沖器、高速緩存)。
為了理解重排序,我們先定義幾個順序:
- 源代碼順序
- 源代碼中所指定的內存訪問操作順序。
- 程序順序
- 在給定處理器上運行的目標代碼所指定的內存訪問操作順序。盡管Java虛擬機執行代碼有兩種方式: 解釋執行(被執行的是字節碼)和編譯執行(被執行的是機器碼),里的目標代碼是指字節碼。
- 執行順序
- 內存訪問操作在給定處理器上的實際執行順序。
- 感知順序
- 給定處理器所感知到(看到)的該處理器及其他處理器的內存訪問操作發生的順序。
在此基礎上,我們可以將重排序可以分為兩類:指令重排序和存儲子系統重排序。
重排序類型 重排序表現 重排序來源 指令重排序
程序順序與源代碼順序不一致 JIT編譯器 執行順序與程序順序不一致 JIT編譯器、處理器 存儲子系統重
排序源代碼順序、程序順序和執行順序這三者保
持一致,但是感知順序與執行順序不一致高速緩存、寫緩沖器
- 指令重排序(Instruction Reorder)
- 定義
- 在源代碼順序與程序順序不一致,或者程序順序與執行順序不一致的情況下,我們就說發生了指令重排序。 指令重排序是一種動作,它確確實實地對指令的順序做了調整,其重排序的對象是指令。
public class Main { private int shardValue = 1; private Helper helper; private void createHelper() { this.helper = new Helper(shardValue); } public static void main(String[] args) throws InterruptedException { final ExecutorService executorService = Executors.newCachedThreadPool(); final Semaphore semaphore = new Semaphore(Runtime.getRuntime().availableProcessors()); Map<Integer, AtomicInteger> sumToTimes = new ConcurrentHashMap<>(16); Main main = new Main(); final int size = 200000; for (int i = 0; i < size; i++) { executorService.submit(() -> { try { semaphore.acquire(); main.createHelper(); } catch (InterruptedException e) { e.printStackTrace(); } finally { semaphore.release(); } }); executorService.submit(() -> { try { try { semaphore.acquire(); } catch (InterruptedException e) { e.printStackTrace(); } main.sum(sumToTimes); } finally { semaphore.release(); } }); } executorService.shutdown(); executorService.awaitTermination(2000, TimeUnit.MINUTES); sumToTimes.forEach((sum, times) -> System.out.printf("%d : %d times %n", sum, times.intValue())); } private void sum(Map<Integer, AtomicInteger> sumToTimes) { int sum; final Helper observed = this.helper; if (observed == null) { sum = -1; } else { sum = observed.a + observed.b + observed.c + observed.d; } AtomicInteger atomicInteger = sumToTimes.putIfAbsent(sum, new AtomicInteger(1)); if (atomicInteger != null) { sumToTimes.get(sum).incrementAndGet(); } } static class Helper { int a; int b; int c; int d; Helper(int value) { a = b = c = d = value; } } }
在上述例子中,我們創建了一個Main對象,並不停的給它的成員變量helper賦值,並計算helper的sum值。 代碼很簡單,我們可以預測到輸出結果大多都為4,只有在helper被賦值之前才可能出現-1, 除這兩種外不存在其他輸出。但如果我們多次運行程序(需要添加JVM參數: -server -XX:-UseCompressedOops), 就會發現實際的輸出結果包含了 -1~4 之間的所有值。
==== 運行結果一 0 : 1 times 2 : 3 times 3 : 3 times 4 : 199993 times ==== 運行結果二 1 : 1 times 3 : 2 times 4 : 199997 times ==== 運行結果三 -1 : 2 times 3 : 1 times 4 : 199997 times
如果我用使用常量 1 代替
this.helper = new Helper(shardValue);
中的shardValue
變量, 會發現運行結果只有0和-4兩種。這是因為該語句可分為下列幾個子操作(偽代碼)://子操作① : 分配 Helper實例所需的內存空間,並獲得一個指向該空間的引用 objRef=allocate(Helper.class) //子操作② : 調用Helper類的構造器初始化,objRef引用指向的Helper實例 inovkeConstructor(objref); //子操作③ : 將Helper實例引用objRef賦值給實例變量helper helper=objRef
如果我們打印出JIT編譯出的匯編代碼(須添加hsdis-amd64庫,並在JVM參數中加入 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation -XX:PrintAssemblyOptions=intel),會發現執行順序與偽代碼中的不一致, 匯編指令將操作三重排序到操作一和操作二的中間:
x00007fe6a51e3788: mov QWORD PTR [rax+0x18],0x0 ;*new ; - com.thunisoft.zjzy.toolbox.Main::createHelper@1 (line 18) ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31) ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8 ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e3790: mov QWORD PTR [r10+0x18],rax ;*putfield helper ; - com.thunisoft.zjzy.toolbox.Main::createHelper@12 (line 18) ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31) ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8 ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e3794: mov r8d,DWORD PTR [r10+0x10] ;*getfield shardValue ; - com.thunisoft.zjzy.toolbox.Main::createHelper@6 (line 18) ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31) ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8 ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e3798: mov DWORD PTR [rax+0x10],r8d ;*putfield a ; - com.thunisoft.zjzy.toolbox.Main$Helper::<init>@21 (line 77) ; - com.thunisoft.zjzy.toolbox.Main::createHelper@9 (line 18) ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31) ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8 ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e379c: mov DWORD PTR [rax+0x1c],r8d ;*putfield d ; - com.thunisoft.zjzy.toolbox.Main$Helper::<init>@10 (line 77) ; - com.thunisoft.zjzy.toolbox.Main::createHelper@9 (line 18) ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31) ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8 ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e37a0: mov DWORD PTR [rax+0x18],r8d ;*putfield c ; - com.thunisoft.zjzy.toolbox.Main$Helper::<init>@14 (line 77) ; - com.thunisoft.zjzy.toolbox.Main::createHelper@9 (line 18) ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31) ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8 ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e37a4: mov DWORD PTR [rax+0x14],r8d ;*putfield b ; - com.thunisoft.zjzy.toolbox.Main$Helper::<init>@18 (line 77) ; - com.thunisoft.zjzy.toolbox.Main::createHelper@9 (line 18) ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31) ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8 ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e37a8: mov rdx,QWORD PTR [rcx+0x10] ;*getfield sync ; - java.util.concurrent.Semaphore::release@1 (line 426) ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@9 (line 35) ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8 ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511)
該Demo展示了重排序具有的兩個特征:
- 重排序可能導致線程安全問題:在本Demo中,重排序使得sum方法的返回值可能既不是-1(此時helper實例為null),也不是4。 當然,這並不表示重排序本身是錯誤的,而是說我們的程序本身有問題──我們的程序沒有使用或者沒有正確地使用線程同步機制。
- 重排序不是必然出現的:本Demo運行時重復調用createHelper和sum共200000次才出現7次重排序,比率為0.035%。
- 處理器亂序執行(Out-of-order Execution)
處理器也可能執行指令重排序,這使得執行順序與程序順序不一致。這是因為現代處理器為了提高指令執行效率, 往往不是按照程序順序逐一執行指令的,而是動態調整指令的順序,做到哪條指令就緒就先執行哪條指令, 這就是處理器處理器對指令進行的重排序,也被稱為處理器的亂序執行。
- 亂序執行的機制
- 在亂序執行的處理器中,指令是一條一條按照程序順序被處理器讀取的(即“順序讀取”), 然后這些指令中哪條就緒了哪條就會先被執行,而不是完全按照程序順序執行(即“亂序執行”)。 這些指令執行的結果會被先存入重排序緩沖器,而不是直接被寫入寄存器或者主內存。 重排序緩沖器會將各個指令的執行結果按照相應指令被處理器讀取的順序提交到寄存器或者內存中去(即“順序提交”)。 在亂序執行的情況下,盡管指令的執行順序可能沒有完全依照程序順序, 但是由於指令的執行結果的提交(即反映到寄存器和內存中)仍然是按照程序順序來的, 因此處理器的指令重排序並不會對單線程程序的正確性產生影響。
- 猜測執行(Speculation)
- 猜測執行技術就好比沒有衛星導航時代在陌生地方開車遇到岔路口的情形: 雖然我們不確定其中哪條路能夠通往目的地,但是我們可以憑猜測走其中一條路, 萬一猜錯了(前路不通)可以掉頭重新走另外一條路。猜測執行能夠造if語句的語句體先於其條件語句被執行的效果, 從而可能導致指令重排序現象。
下面是一個簡單的示例程序片段,在應用猜測執行技術時,可能會執行C2和C3,然后再執行C1。 此時如果flag值為真,則將C2的計算結果代入C4進行計算,拋棄C3的計算結果;否則將C3的運算結果代入C4計算, 拋棄C2的運算結果。
if(flag){ // C1 a = b + c; // C2 }else{ a = b - c; // C3 } rst = a + 1; // C4
- 存儲子系統重排序
- 存儲子系統
- 主內存(RAM)相對於處理器是一個慢速設備,為了避免其拖后腿,處理器並不是直接訪問主內存, 而是通過高速緩存訪問主內存的。在此基礎上,現代處理器還引人了寫緩沖器(Store Buffer,也稱Write Buffer), 以提高寫高速緩存操作的效率。有的處理器(如Intel的x86處理器)對所有的寫主內存的操作都是通過寫緩沖器進行的。 這里,我們將寫緩沖器和高速緩存統稱為存儲子系統。需要注意的是,存儲子系統並不是內存的子系統,而是處理器的子系統。
- 內存重排序(Memory Ordering)
- 即使在處理器嚴格依照程序順序執行兩個內存訪問操作的情況下, 在存儲子系統的作用下,其他處理器對這兩個操作的感知順序仍然可能與程序順序不一致, 即這兩個操作的執行順序看起來像是發生了變化。這種現象就是存儲子系統重排序,也被稱為內存重排序。
- 與指令重排序的區別
- 指令重排序的重排序對象是指令,它實實在在地對指令的順序進行調整, 而存儲子系統重排序是一種現象而不是一種動作,它並沒有真正對指令執行順序進行調整, 而只是造成了一種指令的執行順序像是被調整過一樣的現象,其重排序的對象是內存操作的結果。
- 指令重排序的類型
- 從處理器的角度來說,讀內存操作的實質,是從指定的RAM地址(通過高速緩存)加載數據到寄存器, 因此讀內存操作通常被稱為Load操作;寫內存操作的實質, 是將數據(指令中的操作數或寄存器中的值)存儲到指定地址表示的RAM存儲單元中,因此寫內存操作通常被稱為Store。 這樣,我們可以把內存重排序分為以下4種。
重排序類型 含義 LoadLoad重排序
(Loads reordered after loads)該重排序指一個處理器上先后執行兩個讀內存操作L1和L2,其他處理器對這兩個內存操作的感
知順序可能是L2→L1,即L1被重排序到L2之后
Store Store重排序
(Stores reordered after stores)
該重排序指一個處理器上先后執行兩個寫內存操作W1和W2,其他處理器對這兩個內存操作的感
知順序可能是W2→W1,即W1被重排序到W2之后
Load Store重排序
(Loads reordered after stores)該重排序指一個處理器上先后執行讀內存操作L1和寫內存操作W2,其他處理器對這兩個內存操
作的感知順序可能是W2→L1,即L1被重排序到W2之后
Store Load重排序
(Stores reordered after loads)該重排序指一個處理器上先后執行寫內存操作W1和讀內存操作L2,其他處理器對這兩個內存操
作的感知順序可能是L2→W1,即W1被重排序到L2之后
內在重排序與具體的自理器微架構有關,基於不同射到架構的處理器所允許/支持的內在重排序是不同的。
- 貌似串行語義(As-if-serial Semantics)
- 定義
- 重排序並非隨意地對指令、內存操作的結果進行雜亂無章的排序或者順序調整,而是遵循一定的規則。 編譯器(主要是JIT編譯器)、處理器(包括其存儲子系統)都會遵守這些規則, 從而給單線程程序創造一種假象──指令是按照源代碼順序執行的。這種假象就被稱為貌似串行語義。 貌似串行語義只是從單線程程序的角度保證重排序后的運行結果不影響程序的正確性,它並不保證多線程環境下程序的正確性。
- 數據依賴關系(Data Dependency)
- 為了保證貌似串行語義,存在數據依賴關系的語句不會被重排序,只有不存在數據依賴關系的語句才會被重排序。 如果兩個操作(指令)訪問同一個變量(地址),且其中一個操作(指令)為寫操作, 那么這兩個操作之間就存在數據依賴關系,這些操作包括:寫后讀(WAR)、讀后寫(RAW)、寫后寫(WAW)三種操作。
- 控制依賴關系(Control Dependency)
- 如果一條語句(指令)的執行結果會決定另外一條語句(指令)能否被執行, 那么這兩條語句(指令)之間就存在控制依賴關系。存在控制依賴關系的語句是可以允許被重排序的, 存在控制依賴關系的語句最典型的就是if語句中的條件表達式和相應的語句體。 允許這種重排序意味着處理器可能先執行f語句體所涉及的內存訪問操作,然后再執行相應的條件判斷。 允許對存在控制依賴關系的語句進行重排序同樣也是出於性能考慮, 這是因為存在控制依賴關系的語句(如if語句)會影響處理器對指令序列執行的並行程度。
- 保障內存訪問的順序
貌似串行語義只是保障重排序不影響單線程程序的正確性,從這個角度出發, 多線程程序的有序性的保障可以理解為通過某些措施使得貌似串行語義擴展到多線程程序。即重排序要么不發生, 要么即使發生了也不會影響多線程程序的正確性,這樣有序性的保障也可以理解為從邏輯上部分禁止重排序。
從底層的角度來說,禁止重排序是通過調用處理器提供相應的指令(內存屏障)來實現的。 當然,Java作為一個跨平台的語言,它會替我們與這類指令打交道,而我們只需要使用語言本身提供的機制即可。
- 可見性與有序性
- 可見性是有序性的基礎
- 可見性描述的是一個線程對共享變量的更新對於另外一個線程是否可見, 或者說什么情況下可見的問題。有序性描述的是,一個處理器上運行的線程對共享變量所做的更新, 在其他處理器上運行的其他線程看來,這些線程是以什么樣的順序觀察到這些更新的問題。 因此,可見性是有序性的基礎。另一方面,二者又是相互區分的。
2.2.6 線程上下文切換
- 描述
- 當一個進程中的一個線程由於其時間片用完,或者因其自身原因(比如稍后再繼續運行)被迫或者主動暫停其運行時, 另外一個線程(可能是同一個進程或者其他進程中的一個線程)可以被操作系統(線程調度器)選中, 占用處理器開始或者繼續其運行。這種一個線程被暫停,另外一個線程被選中開始或者繼續運行的過程就叫作線程上下文切換。 也可簡單地稱為上下文切換。
- 線程的切入(Switch In)與切出(Switch Out)
- 一個線程被剝奪處理器的使用權而被暫停運行就被稱為切出, 一個線程被操作系統選中占用處理器開始或者繼續其運行就被稱為切入。
- 上下文(Context)
- 切出和切入的時候,操作系統需要保存和恢復相應線程的進度信息, 即切入和切出那一刻相應線程所執行的任務狀態信息(如計算的中間結果以及執行到了哪條指令)。 這個進度信息就被稱為上下文。 它一般包括通用寄存器(General Purpose Register)和程序計數器(Program Counter)中的內容。
- Java中線程的暫停與喚醒
- 一個線程的生命周期狀態在RUNNABLE狀態與非RUNNABLE狀態之間切換的過程就是一個上下文切換的過程。 當一個線程的生命周期狀態由RUNNABLE轉換為非RUNNABLE (包括BLOCKED、WAITING和TIMED_ WAITING中的任意一狀態)時,我們稱這個線程被暫停。 而一個線程的生命周期狀態由非RUNNABLE狀態進入RUNNABLE狀態時,我們就稱這個線程被喚醒。 一個線程被喚醒僅代表該線程獲得了一個繼續運行的機會,而並不代表其立刻可以占用處理器運行。 當被喚醒的線程被操作系統選中占用處理器繼續其運行的時候,操作系統會恢復之前為該線程保存的上下文, 以便其在此基礎上進展。
- 上下文切換的分類
按照導致上下文切換的因素划分,我們可以將上下文切換分為自發性上下文切換和非自發性上下文切換。
- 自發性上下文切換(Voluntary Context Switch)
- 自發性上下文切換指線程由於其自身因素導致的切出。 比如當前運行的線程發起了I/O操作(如讀取文件)或者等待其他線程持有的鎖,或在其運行過程中執行下列任意一個方法。
Thread. sleep(long millis); Object.wait(); Object.wait(long timeout); Object.wait(long timeout, int nanos); Thread.yield(); Thread.join(); Thread.join(long timeout); LockSupport.park()
- 自發性上下文切換(Involuntary Context Switch)
- 線程由於線程調度器的原因被迫切出。 導致非自發性上下文切換的常見因素包括:被切出線程的時間片用完、有一個比被切出線程優先級更高的線程需要被運行。
從Java平台的角度來看,Java虛擬機的垃圾回收(Garbage Collect)動作也可能導致非自發性上下文切換。 這是因為垃圾回收器在執行垃圾回收的過程中,可能需要暫停所有應用線程才能完成其工作, 比如在主要回收(Major Collection)過程中,垃圾回收器在對Java虛擬機堆內存區域進行整理的時候需要先停止所有應用線程。
- 上下文切換的開銷
上下文切換的開銷包括直接開銷和間接開銷。
️直接開銷:
- 操作系統保存和恢復上下文所需的開銷,這主要是處理器時間開銷。
- ️線程調度器進行線程調度的開銷:比如,按照一定的規則決定哪個線程會占用處理器運行。
間接開銷:
- ️處理器高速緩存重新加載的開銷:一個被切出的線程可能稍后在另外一個處理器上被切入繼續運行。 由於這個處理器之前可能未運行過該線程,那么這個線程在其繼續運行過程中需訪問的變量, 仍然需要被該處理器重新從主內存或者通過緩存致性協議從其他處理器加載到高速緩存之中,這是有一定時間消耗的。
- 高速緩存內容沖刷(Flush)的開銷:️上下文切換也可能導致整個一級高速緩存中的內容被沖刷, 即一級高速緩存中的內容會被寫入下一級高速緩存(如二級高速緩存),或者主內存(RAM)中
線程的數量越多,可能導致的上下文切換的開銷也就可能越大。也就是說,多線程編程中使用的線程數量越多, 程序的計算效率可能反而越低。因此,在設計多線程程序的時候,減少上下文切換也是一個重要的考量因素。
2.2.7 線程的活性故障(Liveness Failure)
- 描述
- 事實上,線程並不是一直處於RUNNABLE狀態,導致一個線程可能處於非RUNNABLE狀態的因素, 除了資源(主要是處理器資源有限而導致的上下文切換)限制之外,還有程序自身的錯誤和缺陷。 由資源稀缺性或者程序自身的問題和缺陷導致線程一直處於非RUNNABLE狀態, 或線程雖然處於RUNNABLE狀態,但是其要執行的任務卻一直無法進展,這種現象被稱為線程活性故障。
常見的線程活性故障包括以下幾種:
- 死鎖(Deadlock)
- 死鎖只會出現在一組線程集合中,如果集合中的每一個線程都持有其他線程需要的資源, 導致所有線程因等待資源而被永暫停,這種現象就稱之為死鎖。 死鎖產生的典型場景是線程X持有資源A的時候等待線程Y釋放資源B, 同時線程Y在持有資源B的時候卻等待線程X釋放資源A,這就好比鷸蚌相爭故事中的情形。
- 鎖死(Lockout)
-
鎖死與死鎖類似,鎖死是指線程在等待一個永遠不會發生的事件;與死鎖不同的是, 鎖死的線程可能不持有任何資源。一個較典型的例子就是信號丟失導致的鎖死, 比如對
CountDownLatch.countDown()
方法的調用沒有放在finally
塊中時, 可能因為異常拋出導致執行CountDownLatch.await()
的線程永遠處於等待狀態。 - 活鎖(Livelock)
- 指線程一直處於運行狀態,但是其任務卻一直無法進展的一種活性故障。 活鎖的一個重要特征就是線程一直處於運行狀態,區別於死鎖、鎖死的線程處於等待狀態。 同樣以鷸蚌相爭故事為例,不同的是兩者商量好如果同時咬住對方,則兩者都松開口, 但松口后兩者又同時咬住了對方,於是兩者在不停的咬住與松口,直至累死。
- 飢餓(Starvation)
- 線程一直無法獲得其所需的資源而導致其任務直無法進展的一種活性故障。 比如由於當前線程的優先級極低,導致資源一直被其他線程搶占。
2.2.8 資源爭用與調度
- 線程間的資源共享
- 由於資源的稀缺性(例如有限的處理器資源)及資源本身的特性 (例如打印機一次 只能打印一個文件),往往需要在多個線程間共享同一個資源。
- 排他性資源
- 一次只能夠被一個線程占用的資源被稱為排他性資源, 常見的排他性資源包括處理器、數據庫連接、文件等。
- 資源爭用(Resource Contention)
- 在一個線程占用一個排他性資源進行訪問(讀、寫操作), 而未釋放其對資源所有權的時候,其他線程試圖訪問該資源的現象就被稱為資源爭用, 簡稱爭用。顯然,爭用是在並發環境下產生的一種現象。
- 爭用程度
- 同時試圖訪問同個已經被其他線程占用的資源的線程數量越多,爭用的程度就越高, 反之爭用的程度就越低。相應的爭用就被分別稱為高爭用和低爭用。
- 資源調度
- 在多個線程申請同一個排他性資源的情況下,決定哪個線程會被授予該資源的獨占權, 即選擇哪個申請者占用該資源的過程就是資源的調度。 獲得資源的獨占權而又未釋放其獨占權的線程就被稱為該資源的持有線程。
- 資源調度策略
資源調度的一種常見策略就是排隊。資源調度器內部維護一個等待隊列,在存在資源爭用的情況下, 申請失敗的線程會被存入該隊列。通常,被存入等待隊列的線程會被暫停。當相應的資源被其持有線程釋放時, 等待隊列中的一個線程會被選中並被喚醒而獲得再次申請資源的機會。 被喚醒的線程如果申請到資源的獨占權,那么該線程會從等待隊列中移除; 否則,該線程仍然會停留在等待隊列中等待再次申請的機會,即該線程會再次被暫停。 因此,等待隊列中的等待線程可能經歷若干次暫停與喚醒才獲得相應資源的獨占權。可見,資源的調度可能導致上下文切換。
- 資源調度的公平性
資源調度策略的一個常見特性就是它能否保證公平性。
所謂公平性,是指資源的申請者(線程),是否按照其申請(請求)資源的順序而被授予資源的獨占權。 如果資源的任何一個先申請者,總是能夠比任何一個后申請者先獲得該資源的獨占權, 那么相應的資源調度策略就被稱為是公平的; 如果資源的后申請者可能比先申請者先獲得該資源的獨占權,那么相應的資源調度策略就被稱為是非公平的。
需要注意的是,非公平的資源調度策略往往只是說明它並不保證資源調度的公平性, 即它允許不公平的資源調度的出現,而不是表示它刻意造就不公平的資源調度。
- 公平的調度策略
公平的調度策略不允許插隊現象的出現,即只有在資源未被其他任何線程占用, 並且沒有其他活躍線程申請該資源情況下,隊列中的線程才被允許被喚醒,搶占相應資源的獨占權。 其中,搶占成功的申請者獲得相應資源的獨占權,而搶占失敗的申請者會進入等待隊列。 因此,公平調度策略中的資源申請者總是按照先來后到的順序來獲得資源的獨占權。
- 非公平的調度策略
而非公平的調度策略則允許插隊現象,即一個線程釋放其資源獨占權的時候, 等待隊列中的一個線程會被喚醒申請相應的資源。而在這個過程中, 可能存在另一個活躍線程與這個被喚醒的線程共同參與相應資源的搶占。 因此,非公平調度策略中被喚醒的線程不一定就能夠成功申請到資源。因此,在極端的情況下, 非公平調度策略可能導致等待隊列中的線程永遠無法獲得其所需的資源,即出現飢餓現象。
- 對比
從申請者個體的角度來看:使用公平調度策略時,申請者獲得相應資源的獨占權所需時間的偏差可能比較小, 即每個申請者成功申請到資源所需的時間基本相同;而使用非公平的調度策略時, 申請者獲得相應資源的獨占權所需時間的偏差可能比較大,有的線程很快就申請到資源, 而有的線程則要經歷若干次暫停與喚醒才成功申請到資源。
從效率上看:在非公平調度策略中,資源的持有線程釋放該資源的時候,等待隊列中的一個線程會被喚醒, 而該線程從被喚醒到其繼續運行可能需要一段時間。在該時間內,如果使用非公平的調度策略, 新來的線程(活躍線程)可以先被授予該資源的獨占權,如果這個新來的線程占用該資源的時間不長, 那么它完全有可能在被喚醒的線程繼續其運行前釋放相應的資源,從而不影響該被喚醒的線程申請資源。 這種情形下,非公平調度策略可以減少上下文切換的次數。 但是,如果多數(甚至每個)線程占用資源的時間相當長,那么允許新來的線程搶占資源不會帶來任何好處, 反而會導致被喚醒的線程需要再次經歷暫停和喚醒,從而增加了上下文切換。 因此,多數線程占用資源的時間相當長的情況下不適合使用非公平調度策略。
綜上,在沒有特別需要的情況下,我們默認選擇非公平調度策略即可。 在資源的持有線程占用資源的時間相對長,或線程申請資源的平均間隔時間相對長, 或對資源申請所需的時間偏差有所要求(即時間偏差較小)的情況下可以考慮使用公平調度策略。
- 資源調度的公平性
2.3 多線程編程的優勢和風險
2.3.1 優勢
- 提高系統的吞吐率(Throughput)
- 多線程編程使得一個進程中可以有多個並發(Concurrent,即同時進行的)的操作。 例如,當一個線程因為I/O操作而處於等待時,其他線程仍然可以執行其操作。
- 提高響應性(Responsiveness)
- 在使用多線程編程的情況下,對於GUI軟件(如桌面應用程序)而言, 一個慢的操作(比如從服務器上下載大文件)並不會導致軟件的界面出現被“凍住”而無法響應用戶的其他操作的現象; 對於Web應用程序而言,一個請求的處理慢了並不會影響其他請求的處理。
- 充分利用多核(Multicore)處理器資源
- 如今多核處理器的設備越來越普及,就算是手機這樣的消費類設備也普遍使用多核處理器。 實施恰當的多線程編程有助於我們充分利用設備的多核處理器資源,從而避免了資源浪費。
- 最小化對系統資源的使用
- 一個進程中的多個線程可以共享其所在進程所申請的資源(如內存空間), 因此使用多個線程相比於使用多個進程進行編程來說,節約了對系統資源的使用。
- 簡化程序的結構
- 線程可以簡化復雜應用程序的結構。
2.3.2 風險
- 線程安全(Thread Safe)問題
- 多個線程共享數據的時候,如果沒有采取相應的並發訪問控制措施,那么就可能產生數據一致性問題, 如讀取臟數據(過期數據)、丟失更新(某些線程所做的更新被其他線程所做的更新覆蓋)等。
- 線程活性(Thread Liveness)問題
- 一個線程從其創建到運行結束的整個生命周期會經歷若干狀態。 從單個線程的角度來看,RUNNABLE狀態是我們所期望的狀態,但實際上, 代碼編寫不當可能導致某些線程一直處於等待其他線程釋放鎖的狀態(BLOCKED狀態),即產生了死鎖。 另外,線程是一種稀缺的計算資源,某些情況下可能出現線程飢餓問題。
- 上下文切換(Context Switch)
- 由於處理器資源的稀缺性,因此上下文切換可以被看作多線程編程的必然副產物, 它増加了系統的消耗,不利於系統的吞吐率。
- 可靠性
- 多線程編程一方面可以有利於可靠性,例如某個線程意外提前終止了,但這並不影響其他線程繼續其處理。 另一方面,線程是進程的一個組件,它總是存在於特定的進程中的,如果這個進程由於某種原因意外提前終止, 比如某個Java進程由於內存泄漏導致Java虛擬機崩潰而意外終止,那么該進程中所有的線程也就隨之而無法繼續運行。 因此,從提高軟件可靠性的角度來看,某些情況下可能要考慮多進程多線程的編程方式,而非簡單的單進程多線程方式。