借用 Java 並發編程實踐中的話:編寫正確的程序並不容易,而編寫正常的並發程序就更難了;相比於順序執行的情況,多線程的線程安全問題是微妙而且出乎意料的,因為在沒有進行適當同步的情況下多線程中各個操作的順序是不可預期的。
並發編程相比 Java 中其他知識點學習起來門檻相對較高,學習起來比較費勁,從而導致很多人望而卻步;而無論是職場面試和高並發高流量的系統的實現卻都還離不開並發編程,從而導致能夠真正掌握並發編程的人才成為市場比較迫切需求的。
本 Chat 作為 Java 並發編程之美系列的開篇,首先通過通俗易懂的方式先來和大家聊聊多線程並發編程線程有關基礎知識(本文結合示例進行講解,定會讓你耳目一新),具體內容如下:
什麼是線程?線程和進程的關係。
線程創建與運行。創建一個線程有那幾種方式?有何區別?
線程通知與等待,多線程同步的基礎設施。
線程的虛假喚醒,以及如何避免。
等待線程執行終止的 join 方法。想讓主線程在子線程執行完畢後在做一點事情?
讓線程睡眠的 sleep 方法,sleep 的線程會釋放持有的鎖?
線程中斷。中斷一個線程,被中斷的線程會自己終止?
理解線程上下文切換。線程多了一定好?
線程死鎖,以及如何避免。
守護線程與用戶線程。當 main 函數執行完畢,但是還有用戶線程存在的時候,JVM 進程會退出?
在討論什麼是線程前有必要先說下什麼是進程,因為線程是進程中的一個實體,線程本身是不會獨立存在的。進程是代碼在數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位,線程則是進程的一個執行路徑,一個進程至少有一個線程,進程中的多個線程是共享進程的資源的。
作業系統在分配資源時候是把資源分配給進程的,但是 CPU 資源就比較特殊,它是分派到線程的,因為真正要佔用 CPU 運行的是線程,所以也說線程是 CPU 分配的基本單位。
Java 中當我們啟動 main 函數時候其實就啟動了一個 JVM 的進程,而 main 函數所在線程就是這個進程中的一個線程,也叫做主線程。
如圖一個進程中有多個線程,多個線程共享進程的堆和方法區資源,但是每個線程有自己的程序計數器,棧區域。
其中程序計數器是一塊內存區域,用來記錄線程當前要執行的指令地址,那麼程序計數器為何要設計為線程私有的呢?前面說了線程是佔用 CPU 執行的基本單位,而 CPU 一般是使用時間片輪轉方式讓線程輪詢佔用的,所以當前線程 CPU 時間片用完後,要讓出 CPU,等下次輪到自己時候在執行,那麼如何知道之前程序執行到哪裡了?其實程序計數器就是為了記錄該線程讓出 CPU 時候的執行地址,待再次分配到時間片時候就可以從自己私有的計數器指定地址繼續執行了。
另外每個線程有自己的棧資源,用於存儲該線程的局部變量,這些局部變量是該線程私有的,其它線程是訪問不了的,另外棧還用來存放線程的調用棧幀。
堆是一個進程中最大的一塊內存,堆是被進程中的所有線程共享的,是進程創建時候分配的,堆裡面主要存放使用 new 操作創建的對象實例。
方法區則是用來存放進程中的代碼片段的,是線程共享的。
Java 中有三種線程創建方法,分別為實現 Runnable 接口的run方法、繼承 Thread 類並重寫 run 方法、使用 FutureTask 方式。
首先看下繼承 Thread 方法的實現:
public class ThreadTest { public static class MyThread extends Thread { @Override public void run() { System.out.println("I am a child thread"); } } public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); }}
如上代碼 MyThread 類繼承了 Thread 類,並重寫了 run 方法,然後調用了線程的 start 方法啟動了線程,當創建完 thread 對象後該線程並沒有被啟動執行。
當調用了 start 方法後才是真正啟動了線程。其實當調用了 start 方法後線程並沒有馬上執行而是處於就緒狀態,這個就緒狀態是指該線程已經獲取了除 CPU 資源外的其它資源,等獲取 CPU 資源後才會真正處於運行狀態。
當 run 方法執行完畢,該線程就處於終止狀態了。使用繼承方式好處是 run 方法內獲取當前線程直接使用 this 就可以,無須使用 Thread.currentThread() 方法,不好的地方是 Java 不支持多繼承,如果繼承了 Thread 類那麼就不能再繼承其它類,另外任務與代碼沒有分離,當多個線程執行一樣的任務時候需要多份任務代碼,而 Runable 則沒有這個限制,下面看下實現 Runnable 接口的 run 方法方式:
public static class RunableTask implements Runnable{ @Override public void run() { System.out.println("I am a child thread"); } } public static void main(String[] args) throws InterruptedException{ RunableTask task = new RunableTask(); new Thread(task).start(); new Thread(task).start();}
如上面代碼,兩個線程公用一個 task 代碼邏輯,需要的話 RunableTask 可以添加參數進行任務區分,另外 RunableTask 可以繼承其他類,但是上面兩種方法都有一個缺點就是任務沒有返回值,下面看最後一種是使用 FutureTask:
public static class CallerTask implements Callable<String>{ @Override public String call() throws Exception { return "hello"; } } public static void main(String[] args) throws InterruptedException { FutureTask<String> futureTask = new FutureTask<>(new CallerTask()); new Thread(futureTask).start(); try { String result = futureTask.get(); System.out.println(result); } catch (ExecutionException e) { e.printStackTrace(); }}
註:每種方式都有自己的優缺點,應該根據實際場景進行選擇。
Java 中 Object 類是所有類的父類,鑑於繼承機制,Java 把所有類都需要的方法放到了 Object 類裡面,其中就包含本節要講的通知等待系列函數,這些通知等待函數是組成並發包中線程同步組件的基礎。
下面講解下 Object 中關於線程同步的通知等待函數。
首先談下什麼是共享資源,所謂共享資源是說該資源被多個線程共享,多個線程都可以去訪問或者修改的資源。另外本文當講到的共享對象就是共享資源。
當一個線程調用一個共享對象的 wait() 方法時候,調用線程會被阻塞掛起,直到下面幾個事情之一發生才返回:
其它線程調用了該共享對象的 notify() 或者 notifyAll() 方法;
其它線程調用了該線程的 interrupt() 方法設置了該線程的中斷標誌,該線程會拋出 InterruptedException 異常返回。
另外需要注意的是如果調用 wait() 方法的線程沒有事先獲取到該對象的監視器鎖,則調用 wait() 方法時候調用線程會拋出 IllegalMonitorStateException 異常。
那麼一個線程如何獲取到一個共享變量的監視器那?
(1)執行使用 synchronized 同步代碼塊時候,使用該共享變量作為參數:
synchronized(共享變量){ }
(2)調用該共享變量的方法,並且該方法使用了 synchronized 修飾:
synchronized void add(int a,int b){ }
另外需要注意的是一個線程可以從掛起狀態變為可以運行狀態(也就是被喚醒)即使該線程沒有被其它線程調用 notify(),notifyAll() 進行通知,或者被中斷,或者等待超時,這就是所謂的虛假喚醒。
雖然虛假喚醒在應用實踐中很少發生,但是還是需要防範於未然的,做法就是不停的去測試該線程被喚醒的條件是否滿足,不滿足則繼續等待,也就是說在一個循環中去調用 wait() 方法進行防範,退出循環的條件是條件滿足了喚醒該線程。
synchronized (obj) { while (條件不滿足){ obj.wait(); } }
如上代碼是經典的調用共享變量 wait() 方法的實例,首先通過同步塊獲取 obj 上面的監視器鎖,然後通過 while 循環內調用 obj 的 wait() 方法。
下面從生產者消費者例子來加深理解,如下面代碼是一個生產者的例子,其中 queue 為共享變量,生產者線程在調用 queue 的 wait 方法前,通過使用 synchronized 關鍵字拿到了該共享變量 queue 的監視器,所以調用 wait() 方法才不會拋出 IllegalMonitorStateException 異常,如果當前隊列沒有空閒容量則會調用 queued 的 wait() 掛起當前線程,這裡使用循環就是為了避免上面說的虛假喚醒問題,這裡假如當前線程虛假喚醒了,但是隊列還是沒有空餘容量的話,當前線程還是會調用 wait() 把自己掛起。
synchronized (queue) { while (queue.size() == MAX_SIZE) { try { queue.wait(); } catch (Exception ex) { ex.printStackTrace(); } } queue.add(ele); queue.notifyAll(); } }
synchronized (queue) { while (queue.size() == 0) { try queue.wait(); } catch (Exception ex) { ex.printStackTrace(); } } queue.take(); queue.notifyAll(); } }
另外當一個線程調用了共享變量的 wait() 方法後該線程會被掛起,同時該線程會暫時釋放對該共享變量監視器的持有,直到另外一個線程調用了共享變量的 notify() 或者 notifyAll() 方法才有可能會重新獲取到該共享變量的監視器的持有權(這裡說有可能,是因為考慮到多個線程第一次都調用了 wait() 方法,所以多個線程會競爭持有該共享變量的監視器)。
借用上面這個例子來講解下調用共享變量 wait() 方法後當前線程會釋放持有的共享變量的鎖的理解。
如上代碼假如生產線程 A 首先通過 synchronized 獲取到了 queue 上的鎖,那麼其它生產線程和所有消費線程都會被阻塞,線程 A 獲取鎖後發現當前隊列已滿會調用 queue.wait() 方法阻塞自己,然後會釋放獲取的 queue 上面的鎖,這裡考慮下為何要釋放該鎖?
如果不釋放,由於其它生產線程和所有消費線程已經被阻塞掛起,而線程 A 也被掛起,這就處於了死鎖狀態。這裡線程 A 掛起自己後釋放共享變量上面的鎖就是為了打破死鎖必要條件之一的持有並等待原則。關於死鎖下面章節會有講到,線程 A 釋放鎖後其它生產線程和所有消費線程中會有一個線程獲取 queue 上的鎖進而進入同步塊,這就打破了死鎖。
最後再舉一個例子說明當一個線程調用共享對象的 wait() 方法被阻塞掛起後,如果其它線程中斷了該線程,則該線程會拋出 InterruptedException 異常後返回:
public class WaitNotifyInterupt { static Object obj = new Object(); public static void main(String[] args) throws InterruptedException { Thread threadA = new Thread(new Runnable() { public void run() { try { System.out.println("---begin---"); obj.wait(); System.out.println("---end---"); } catch (InterruptedException e) { e.printStackTrace(); } } }); threadA.start(); Thread.sleep(1000); System.out.println("---begin interrupt threadA---"); threadA.interrupt(); System.out.println("---end interrupt threadA---"); }}
運行上面代碼輸出為:
如上代碼 threadA 調用了共享對 obj 的 wait() 方法後阻塞掛起了自己,然後主線程在休眠1s後中斷了 threadA 線程,可知中斷後 threadA 在 obj.wait() 處拋出了 java.lang.IllegalMonitorStateException 異常後返回後終止。
void wait(long timeout) 方法
該方法相比 wait() 方法多一個超時參數,不同在於如果一個線程調用了共享對象的該方法掛起後,如果沒有在指定的 timeout ms 時間內被其它線程調用該共享變量的 notify() 或者 notifyAll() 方法喚醒,那麼該函數還是會因為超時而返回。
需要注意的是如果在調用該函數時候 timeout 傳遞了負數會拋出 IllegalArgumentException 異常。
void wait(long timeout, int nanos) 方法
內部是調用 wait(long timeout),如下代碼:只是當 nanos>0 時候讓參數一遞增1。
public final void wait(long timeout, int nanos) throws InterruptedException { if (timeout < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (nanos < 0 || nanos > 999999) { throw new IllegalArgumentException( "nanosecond timeout value out of range"); } if (nanos > 0) { timeout++; } wait(timeout); }
一個線程調用共享對象的 notify() 方法後,會喚醒一個在該共享變量上調用 wait 系列方法後被掛起的線程,一個共享變量上可能會有多個線程在等待,具體喚醒哪一個等待的線程是隨機的。
另外被喚醒的線程不能馬上從 wait 返回繼續執行,它必須獲取了共享對象的監視器後才可以返回,也就是喚醒它的線程釋放了共享變量上面的監視器鎖後,被喚醒它的線程也不一定會獲取到共享對象的監視器,這是因為該線程還需要和其它線程一塊競爭該鎖,只有該線程競爭到了該共享變量的監視器後才可以繼續執行。
類似 wait 系列方法,只有當前線程已經獲取到了該共享變量的監視器鎖後,才可以調用該共享變量的 notify() 方法,否者會拋出 IllegalMonitorStateException 異常。
不同於 nofity() 方法在共享變量上調用一次就會喚醒在該共享變量上調用 wait 系列方法被掛起的一個線程,notifyAll() 則會喚醒所有在該共享變量上由於調用 wait 系列方法而被掛起的線程。
最後本小節最後講一個例子來說明 notify() 和 notifyAll() 的具體含義和一些需要注意的地方,代碼實例如下:
private static volatile Object resourceA = new Object();public static void main(String[] args) throws InterruptedException { Thread threadA = new Thread(new Runnable() { public void run() { synchronized (resourceA) { System.out.println("threadA get resourceA lock"); try { System.out.println("threadA begin wait"); resourceA.wait(); System.out.println("threadA end wait"); } catch (InterruptedException e) { e.printStackTrace(); } } } }); Thread threadB = new Thread(new Runnable() { public void run() { synchronized (resourceA) { System.out.println("threadB get resourceA lock"); try { System.out.println("threadB begin wait"); resourceA.wait(); System.out.println("threadB end wait"); } catch (InterruptedException e) { e.printStackTrace(); } } } }); Thread threadC = new Thread(new Runnable() { public void run() { synchronized (resourceA) { System.out.println("threadC begin notify"); resourceA.notifyAll(); } } }); threadA.start(); threadB.start(); Thread.sleep(1000); threadC.start(); threadA.join(); threadB.join(); threadC.join(); System.out.println("main over");}
輸出結果:
如上代碼開啟了三個線程,其中線程 A 和 B 分別調用了共享資源 resourceA 的 wait() 方法,線程 C 則調用了 nofity() 方法。
閱讀全文請掃描
下方二維碼,
還可以加入讀者圈與作者聊天~: