一文解決你不理解的併發與多執行緒問題!(上)
前排溫馨提示:由於文章寫完後篇幅較長,所以我選擇了上下文的形式釋出
建立並啟動執行緒
熟悉Java的人都能很容易地寫出如下程式碼:
public static class MyThread extends Thread { <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override public void run() { System.out.println("MyThread is running..."); } } public static void main(String[] args) { Thread t = new MyThread(); t.start(); }</a>
執行緒的生命週期
這是一個面試常問的基礎問題,你應該肯定的回答執行緒只有五種狀態,分別是:新建狀態、就緒狀態、執行狀態、阻塞狀態、終止狀態。

就緒狀態和執行狀態
由於Scheduler(排程器)的時間片分配演算法,每個Running的執行緒會執行多長時間是未知的,因此執行緒能夠在Runnable和Running之間來回轉換。 阻塞狀態的執行緒必須先進入就緒狀態才能進入執行狀態 。
執行狀態和阻塞狀態
Running執行緒在主動呼叫Thread.sleep()、obj.wait()、thread.join()時會進入TIMED-WAITING或WAITING狀態並主動讓出CPU執行權。如果是TIMED-WAITING,那麼在經過一定的時間之後會主動返回並進入Runnable狀態等待時間片的分配。
thread.join()的底層就是當前執行緒不斷輪詢thread是否存活,如果存活就不斷地wait(0)。
Running執行緒在執行過程中如果遇到了臨界區(synchronized修飾的方法或程式碼塊)並且需要獲取的鎖正在被其他執行緒佔用,那麼他會主動將自己掛起並進入BLOCKED狀態。
阻塞狀態和就緒狀態
如果持有鎖的執行緒退出臨界區,那麼在該鎖上等待的執行緒都會被喚醒並進入就緒狀態,但只有搶到鎖的執行緒會進入執行狀態,其他沒有搶到鎖的執行緒仍將進入阻塞狀態。
如果某個執行緒呼叫了obj的notify/notifyAll方法,那麼在該執行緒退出臨界區時(呼叫wait/notify必須先通過synchronized獲取物件的鎖),被喚醒的等待在obj.wait上的執行緒才會從阻塞狀態進入就緒狀態獲取obj的monitor,並且只有搶到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方法主要做了三件事:
- 將當前執行緒物件加入其所屬的執行緒組(執行緒組在後續將會介紹)
- 呼叫start0,這是一個native方法,在往期文章《Java執行緒是如何實現的?》一文中談到執行緒的排程將交給LWP,這裡的啟動新建執行緒同樣屬於此範疇。因此我們能夠猜到此JNI(Java Native Interface)呼叫將會新建一個執行緒(LWP)並執行該執行緒物件的run方法
- 將該執行緒物件的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; } <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@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(); } } } }</a>
然後編寫一個叫號客戶端模擬四個視窗同時叫號:
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中的單一職責原則。
我們將執行緒和要執行的任務耦合在了一起,因此出現瞭如上所述的尷尬情況。執行緒的職責就是執行任務,它有它自己的執行時狀態,我們不應該將要執行的任務的相關狀態(如本例中的counter、windowName)將執行緒耦合在一起,而應該將業務邏輯單獨抽取出來作為一個邏輯執行單元,當需要執行時提交給執行緒即可。於是就有了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; <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@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(); } } } }</a>
測試類:
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 { <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override public double calculate(double salary, double bonus) { return salary * 0.03 + bonus * 0.1; } }</a>
而業務程式碼僅呼叫介面:
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 { <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override public double calculate(double salary, double bonus) { return (salary + bonus) * 1.5; } }</a>
在JDK增加內部類語法糖之後,可以使用匿名內部類省去建立新類的開銷:
public class TaxCalculateTest { public static void main(String[] args) { TaxCalculator taxCalaculator = new TaxCalculator(5000,1500, new TaxCalculateStrategy(){ <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override public double calculate(double salary, double bonus) { return (salary + bonus) * 1.5; } }); } }</a>
但是在JDK新增函數語言程式設計後,可以更加簡潔明瞭:
public class TaxCalculateTest { public static void main(String[] args) { TaxCalculator taxCalaculator = new TaxCalculator(5000, 1500, (salary, bonus) -> (salary + bonus) * 1.5); } }
這對只有一個抽象方法run的Runnable介面來說是同樣適用的。
構造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();
-
g是當前物件的ThreadGroup,2~8就是在設定當前物件所屬的執行緒組,如果在new Thread時沒有顯式指定,那麼預設將父執行緒(當前執行new Thread的執行緒)執行緒組設定為自己的執行緒組。
-
9~10行,從父執行緒中繼承兩個狀態:是否是守護執行緒、優先順序是多少。當然了,在new Thread之後可以通過thread.setDeamon或thread.setPriority進行自定義
-
12行,如果是通過new Thread(Runnable target)方式建立的執行緒,那麼取得傳入的Runnable target,執行緒啟動時呼叫的run中會執行不空的target的run方法。理論上來講建立執行緒有三種方式:
-
實現Runnable介面MyRunnable,通過new Thread(myRunnable)執行MyRunnable中的run
-
繼承Thread並重寫run,通過new MyThread()執行重寫的run
-
繼承Thread並重寫run,仍可向構造方法傳入Runnable實現類例項:new MyThread(myRunnable),但是隻會執行MyThread中重寫的run,不會受myRunnable的任何影響。這種建立執行緒的方式有很大的歧義,除了面試官可能會拿來為難你一下,不建議這樣使用* 設定執行緒優先順序,一共有10個優先級別對應取值[0,9],取值越大優先順序越大。但這一引數具有平臺依賴性,這意味著可能在有的作業系統上可能有效,而在有的作業系統上可能無效,因為Java執行緒是直接對映到核心執行緒的,因此具體的排程仍要看作業系統。
-
設定棧大小。這個大小指的是棧的記憶體大小而非棧所能容納的最大棧幀數目,每一個方法的呼叫和返回對應一個棧幀從執行緒的虛擬機器棧中入棧到出棧的過程,在下一節中會介紹這個引數。虛擬機器棧知識詳見《深入理解Java虛擬機器(第二版)》第二章。
-
設定執行緒的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記憶體結構

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

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