【Java 併發筆記】併發基礎整理
文前說明
作為碼農中的一員,需要不斷的學習,我工作之餘將一些分析總結和學習筆記寫成部落格與大家一起交流,也希望採用這種方式記錄自己的學習之旅。
本文僅供學習交流使用,侵權必刪。
不用於商業目的,轉載請註明出處。
1. 基本概念
1.1 程序
- 有獨立的程式碼和資料空間(程序上下文)。
- 程序間的切換會有較大的開銷。
- 一個程序包含 1~n 個執行緒。
- 程序是資源分配的最小單位。
- 多程序是指作業系統能同時執行多個任務(程式)。
1.2 執行緒
- 同一類執行緒共享程式碼和資料空間。
- 每個執行緒有獨立的執行棧和程式計數器(PC)。
- 執行緒切換開銷小。
- 執行緒是 CPU 排程的最小單位。
- 多執行緒是指在同一程式中有多個順序流在執行,只能使用分配給程式的資源和環境。
- 執行緒和程序一樣分為五個階段:建立、就緒、執行、阻塞、終止。
- 執行緒是程序中負責程式執行的執行單元,執行緒本身依靠程式進行執行。
1.2.1 程序與執行緒的不同
- 一個程序是一個獨立(self contained)的執行環境,它可以被看作一個程式或者一個應用。
- 執行緒是在程序中執行的一個任務。
- Java 執行環境是一個包含了不同的類和程式的單一程序。
- 執行緒可以被稱為輕量級程序。
- 執行緒需要較少的資源來建立和駐留在程序中,並且可以共享程序中的資源。
1.2.2 多執行緒的好處
- 在多執行緒程式中,多個執行緒被併發的執行以提高程式的效率,CPU 不會因為某個執行緒需要等待資源而進入空閒狀態。
- 多個執行緒共享堆記憶體(heap memory),因此建立多個執行緒去執行一些任務會比建立多個程序更好。
1.2.3 使用者執行緒和守護執行緒區別
- 在 Java 程式中建立一個執行緒,它就被稱為使用者執行緒。
- 一個守護執行緒是在後臺執行並且不會阻止 JVM 終止的執行緒。
- 當沒有使用者執行緒在執行的時候,JVM 關閉程式並且退出。
- 一個守護執行緒建立的子執行緒依然是守護執行緒。
- 使用 Thread 類的 setDaemon(true) 方法可以將執行緒設定為守護執行緒,需要注意的是,需要在呼叫 start() 方法前呼叫這個方法,否則會丟擲 IllegalThreadStateException 異常。
1.3 並行
- 多個 CPU 例項或者多臺機器同時執行一段處理邏輯,是真正的同時。
1.4 併發
- 通過 CPU 排程演算法,讓使用者看上去同時執行,實際上從 CPU 操作層面不是真正的同時。
- 併發往往在場景中有公用的資源,針對這個公用的資源往往產生瓶頸,會用 TPS 或者 QPS 來反應這個系統的處理能力。
1.5 執行緒安全
- 在併發的情況之下,程式碼經過多執行緒使用,執行緒的排程順序不影響任何結果。
- 使用多執行緒,只需要關注系統的記憶體,CPU 是不是夠用即可。
- 執行緒不安全就意味著執行緒的排程順序會影響最終結果。
- 程式的正確性不能依賴執行緒的優先順序高階來保證。
1.6 同步
- 通過人為的控制和排程,保證共享資源的多執行緒訪問成為執行緒安全,來保證結果的準確。
1.7 執行緒生命週期
狀態名稱 | 說明 |
---|---|
NEW | 初始狀態,執行緒被構建,但是還沒有呼叫 start() 方法。 |
RUNNABLE | 執行狀態,Java 執行緒將作業系統中的就緒(RUNNABLE)和執行(RUNNING)兩種狀態籠統稱作執行中。 |
BLOCKED | 阻塞狀態,表示執行緒阻塞於鎖。 |
WAITING | 等待狀態,表示執行緒進入等待狀態,進入該狀態表示當前執行緒需要等待其他執行緒做出一些特定動作(通知或中斷)。 |
DEAD | 終止狀態,表示當前執行緒已經執行完畢。 |
- 當需要新起一個執行緒來執行某個子任務時,就建立一個執行緒。但是執行緒建立之後,不會立即進入就緒狀態,因為執行緒的執行需要一些條件(比如分配一定的記憶體空間),只有執行緒執行需要的所有條件滿足了,才進入就緒狀態。
- 當執行緒進入就緒狀態後,不代表立刻就能獲取 CPU 執行時間,也許此時 CPU 正在執行其他的事情,因此它要等待。當得到 CPU 執行時間之後,執行緒便真正進入執行狀態。
- 執行緒在執行狀態過程中,可能有多個原因導致當前執行緒不繼續執行下去,比如使用者主動讓執行緒睡眠(睡眠一定的時間之後再重新執行)、使用者主動讓執行緒等待,或者被同步塊給阻塞,此時就對應著多個狀態 time waiting(睡眠或等待一定的時間)、waiting(等待被喚醒)、blocked(阻塞)。
- 當由於突然中斷或者子任務執行完畢,執行緒就會被消亡。

執行緒狀態轉換
1.7.1 上下文切換
- 對於單核 CPU 來說,CPU 在一個時刻只能執行一個執行緒,當在執行一個執行緒的過程中轉去執行另外一個執行緒,這個叫做執行緒上下文切換(對於程序也是類似)。
- 由於可能當前執行緒的任務並沒有執行完畢,所以在切換時需要儲存執行緒的執行狀態,以便下次重新切換回來時能夠繼續切換之前的狀態執行。
- 對於執行緒的上下文切換實際上就是 儲存和恢復 CPU 狀態的過程 ,它使得執行緒執行能夠從中斷點恢復執行。
- 雖然多執行緒可以使得任務執行的效率得到提升,但是由於線上程切換時同樣會帶來一定的開銷代價,並且多個執行緒會導致系統資源佔用的增加。
1.7.2 執行緒優先順序
- 在作業系統中,執行緒可以劃分優先順序,優先順序較高的執行緒得到的 CPU 資源較多,也就是 CPU 優先執行優先順序較高的執行緒物件中的任務。
- 設定執行緒優先順序有助於幫 執行緒排程器 確定在下一次選擇哪一個執行緒來優先執行。
- 執行緒的優先順序分為 1~10 這 10 個等級,如果小於 1 或大於 10,則丟擲異常 IllegalArgumentException。
- 執行緒優先順序特性
- 比如 A 執行緒啟動 B 執行緒,則 B 執行緒的優先順序與 A 是一樣的。(繼承性)
- 高優先順序的執行緒總是大部分先執行完,但不代表高優先順序執行緒全部先執行完。(規則性)
- 優先順序較高的執行緒不一定每一次都先執行完。(隨機性)
1.7.3 執行緒排程器(Thread Scheduler)和時間分片(Time Slicing)
- 執行緒排程器是一個作業系統服務,它負責為 Runnable 狀態的執行緒分配 CPU 時間。
- 一旦建立一個執行緒並啟動它,它的執行便依賴於執行緒排程器的實現。
- 時間分片是指將可用的 CPU 時間分配給可用的 Runnable 執行緒的過程。
- 分配 CPU 時間可以基於執行緒優先順序或者執行緒等待的時間。
- 執行緒排程並不受到 Java 虛擬機器控制,所以由應用程式來控制它是更好的選擇(也就是說不要讓程式依賴於執行緒的優先順序)。
1.8 執行緒常用方法
方法 | 說明 |
---|---|
public void start() | 使該執行緒開始執行;Java 虛擬機器呼叫該執行緒的 run() 方法。 |
public void run() | 如果該執行緒是使用獨立的 Runnable 執行物件構造的,則呼叫該 Runnable 物件的 run() 方法;否則,該方法不執行任何操作並返回。 |
public final void setName(String name) | 改變執行緒名稱,使之與引數 name 相同。 |
public final void setPriority(int priority) | 更改執行緒的優先順序。 |
public final void setDaemon(boolean on) | 將該執行緒標記為守護執行緒或使用者執行緒。 |
public final void join(long millisec) | 等待該執行緒終止的時間最長為 millis 毫秒。 |
public void interrupt() | 中斷執行緒。 |
public final boolean isAlive() | 測試執行緒是否處於活動狀態。 |
public static void yield() | 執行緒讓步,暫停當前正在執行的執行緒物件,並執行其他執行緒。 |
public static void sleep(long millisec) | 在指定的毫秒數內讓當前正在執行的執行緒休眠(暫停執行),此操作受到系統計時器和排程程式精度和準確性的影響。 |
public static Thread currentThread() | 返回對當前正在執行的執行緒物件的引用。 |
public static void join() | 執行緒加入,等待其他執行緒終止。 |
1.8.1 sleep()、yield() 和 wait() 的區別
/ | sleep | yield | wait |
---|---|---|---|
方法所在類 | Thread | Thread | Object |
鎖行為 | 不會改變鎖行為 | 不會改變鎖行為 | 釋放鎖 |
進入狀態 | 進入阻塞狀態 | 進入就緒狀態 | 進入等待狀態 |
恢復 | 指定時間後恢復 | 與相同優先順序執行緒爭奪 | 等待別的執行緒 notify/notifyAll 喚醒 |
- sleep 是 Thread 類的方法。
- wait 是 Object 類中定義的方法。
- Thread.sleep 不會導致 鎖行為的改變 ,如果當前執行緒是擁有鎖的,那麼 Thread.sleep 不會讓執行緒釋放鎖。
- Thread.sleep 和 Object.wait 都會暫停當前的執行緒。OS 會將執行時間分配給其它執行緒。區別在於呼叫 wait 後,需要別的執行緒執行 notify/notifyAll 才能夠重新獲得 CPU 執行時間。
- 呼叫 Thread.yield 方法會讓當前執行緒交出 CPU 許可權,讓 CPU 去執行其他的執行緒。它跟 sleep 方法類似,同樣不會釋放鎖。
- 但是 yield 不能控制具體的交出 CPU 的時間。
- yield 方法只能讓擁有相同優先順序的執行緒有獲取 CPU 執行時間的機會。
- 呼叫 yield 方法並不會讓執行緒進入阻塞狀態,而是讓執行緒重回就緒狀態,它只需要等待重新獲取 CPU 執行時間,這一點是和 sleep 方法不一樣的。
- Thread 類的 sleep 和 yield 方法將在當前正在執行的執行緒上執行。
- 所以在其他處於等待狀態的執行緒上呼叫這些方法是沒有意義的。
- 所以這兩個方法都是靜態的。
1.8.2 join()
- 在很多情況下,主執行緒建立並啟動了執行緒,如果子執行緒中要進行大量耗時運算,主執行緒往往將早於子執行緒結束之前結束。這時,如果主執行緒想等待子執行緒執行完成之後再結束,比如子執行緒處理一個數據,主執行緒要取得這個資料中的值,就要用到 join() 方法了。
- 方法 join() 的作用是讓主執行緒等待呼叫它的執行緒結束。
1.8.3 阻塞的三種情況
- 等待阻塞:執行的執行緒執行 wait() 方法,JVM 會把該執行緒放入等待池中。(wait 會釋放持有的鎖)
- 同步阻塞:執行的執行緒在獲取物件的同步鎖時,若該同步鎖被別的執行緒佔用,則 JVM 會把該執行緒放入鎖池中。
- 其他阻塞:執行的執行緒執行 sleep() 或 join() 方法,或者發出了 I/O 請求時,JVM 會把該執行緒置為阻塞狀態。當 sleep() 狀態超時、join() 等待執行緒終止或者超時、或者 I/O 處理完畢時,執行緒重新轉入就緒狀態。
1.8.4 停止執行緒
- 使用退出標誌,使執行緒正常退出,也就是當 run() 方法完成後執行緒終止。
- 使用 stop() 方法強行終止執行緒,但是不推薦使用這個方法,因為 stop() 和 suspend() 及 resume()
一樣,都是作廢過期的方法,使用他們可能產生不可預料的結果。 - 推薦使用 interrupt() 方法中斷執行緒,但這個不會終止一個正在執行的執行緒,還需要加入一個判斷才可以完成執行緒的停止。
private volatile boolean on = true; @Override public void run() { wheile (on && !Thread.currentThread().isInterrupted()) { ...... } } public void cancel() { on = false; }
1.8.5 interrupted 與 isInterrupted 的區別
- interrupted() 測試當前執行緒是否已經是中斷狀態,執行後具有狀態標誌清除為 false 的功能。
- isInterrupted() 測試執行緒 Thread 物件是否已經是中斷狀態,但不清除狀態標誌。
public static boolean interrupted() { return currentThread().isInterrupted(true); } public boolean isInterrupted() { return isInterrupted(false); } private native boolean isInterrupted(boolean ClearInterrupted);
2. 執行緒的實現
2.1 繼承 Thread 類
- Thread 類在 java.lang 包中定義,繼承 Thread 類必須重寫 run() 方法。
public class Test { public static void main(String[] args){ System.out.println("主執行緒ID:"+Thread.currentThread().getId()); MyThread thread1 = new MyThread("thread1"); thread1.start(); MyThread thread2 = new MyThread("thread2"); thread2.run(); } } class MyThread extends Thread { private String name; public MyThread(String name) { this.name = name; } @Override public void run() { System.out.println("name:"+name+" 子執行緒ID:"+Thread.currentThread().getId()); } }
- start() 方法的呼叫後並不是立即執行多執行緒程式碼,而是使得該執行緒變為可執行態(Runnable),什麼時候執行是由作業系統決定的。
- Thread.sleep() 方法呼叫目的是不讓當前執行緒獨自霸佔該程序所獲取的 CPU 資源,以留出一定時間給其他執行緒執行的機會。
- 實際上所有的多執行緒程式碼執行順序都是不確定的,每次執行的結果都是隨機的。
- start() 方法重複呼叫的話,會出現 java.lang.IllegalThreadStateException 異常。
2.2 實現 Runnable 介面
- 實現 Runnable 介面,必須重寫其 run() 方法。
public class Test { public static void main(String[] args){ System.out.println("主執行緒ID:"+Thread.currentThread().getId()); MyRunnable runnable = new MyRunnable(); Thread thread = new Thread(runnable); thread.start(); } } class MyRunnable implements Runnable { public MyRunnable() { } @Override public void run() { System.out.println("子執行緒ID:"+Thread.currentThread().getId()); } }
2.3 Thread 和 Runnable 的區別
- Runnable 介面比繼承 Thread 類,適合多個相同的程式程式碼的執行緒去處理同一個資源。
- Runnable 介面比繼承 Thread 類,可以避免 Java 中的單繼承的限制。
- Runnable 介面比繼承 Thread 類,增加了程式的健壯性,程式碼可以被多個執行緒共享,程式碼和資料獨立。
- 執行緒池只能放入實現 Runable 或 Callable 類執行緒,不能直接放入繼承 Thread 的類。
2.3.1 直接呼叫 Thread 類的 run() 方法
- 直接呼叫 Thread 的 run() 方法,行為就會和普通的方法一樣,為了在新的執行緒中執行程式碼,必須使用 Thread.start() 方法。
3. 執行緒間通訊
3.1 wait()、notify() 和 notifyAll()
- wait()、notify() 和 notifyAll() 都是 Object 類中的方法。
- wait()、notify() 和 notifyAll() 方法是本地方法,並且為 final 方法,無法被重寫。
- 使用 wait()、notify() 和 notifyAll() 時需要先對呼叫物件加鎖。
- 呼叫 wait() 方法後,執行緒狀態由 RUNNING 變為 WAITING,並將當前執行緒放置到物件的等待佇列。
- notify() 或者 notifyAll() 方法呼叫後,等待執行緒依舊不會從 wait() 返回,需要呼叫 notify() 或者 notifyAll() 方法的執行緒釋放鎖之後,等待執行緒才有機會從 wait() 返回。
- notify() 方法將等待佇列中的一個等待執行緒從等待佇列移動到同步佇列中,而 notifyAll() 方法則是將等待佇列中的所有執行緒全部移動到同步佇列中,被移動的執行緒狀態由 WAITING 變為 BLOCKED。
- 從 wait() 方法返回的前提是獲取呼叫物件的鎖。
3.2 等待/通知機制
-
等待方(消費者)遵循的原則。
- 獲取物件的鎖。
- 如果條件不滿足,那麼呼叫物件的 wait() 方法,被通知後仍要檢查條件。
- 條件滿足則執行對應的事務邏輯。
-
通知方(生產者)遵循的原則。
- 獲取物件的鎖。
- 改變條件。
- 通知所有等待在物件上的執行緒。
public class Test { private int queueSize = 10; private PriorityQueue<Integer> queue = new PriorityQueue<Integer>(queueSize); public static void main(String[] args){ Test test = new Test(); Producer producer = test.new Producer(); Consumer consumer = test.new Consumer(); producer.start(); consumer.start(); } class Consumer extends Thread{ @Override public void run() { consume(); } private void consume() { while(true){ synchronized (queue) { while(queue.size() == 0){ try { System.out.println("佇列空,等待資料"); queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); queue.notify(); } } queue.poll(); //每次移走隊首元素 queue.notify(); System.out.println("從佇列取走一個元素,佇列剩餘"+queue.size()+"個元素"); } } } } class Producer extends Thread{ @Override public void run() { produce(); } private void produce() { while(true){ synchronized (queue) { while(queue.size() == queueSize){ try { System.out.println("佇列滿,等待有空餘空間"); queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); queue.notify(); } } queue.offer(1); //每次插入一個元素 queue.notify(); System.out.println("向佇列取中插入一個元素,佇列剩餘空間:"+(queueSize-queue.size())); } } } } }
3.2 ThreadLocal
- ThreadLocal 是執行緒變數,是一個以 ThreadLocal 物件為鍵,任意物件為值的儲存結構。
- 這個結構被附帶線上程上,一個執行緒可以根據一個 ThreadLocal 物件查詢到繫結在這個執行緒上的一個值。
- 其提供了一個執行緒副本的成員變數,從而在一些情況下可以巧妙避開併發問題。
- 在多執行緒情況下對共享變數的修改,如果不採用任何同步策略,那麼結果很大的概率上都會發生錯誤,這個主要是由於執行緒的 CPU 的 cache 與主記憶體的變數檢視不一致導致的。
- 除了採用加鎖同步之外,在一些特定的情況下,也可以使用 ThreadLocal 來修飾成員變數,從而給每一個執行緒維持繫結一個自己的副本變數,這樣不論何時都只有本執行緒可以修改它,所以就不存在併發問題。
方法 | 說明 |
---|---|
get | 獲取 ThreadLocal 中當前執行緒共享變數的值。 |
set | 設定 ThreadLocal 中當前執行緒共享變數的值。 |
remove | 移除 ThreadLocal 中當前執行緒共享變數的值。 |
initialValue | ThreadLocal 沒有被當前執行緒賦值時或當前執行緒剛呼叫 remove 方法後呼叫 get 方法,返回此方法值。 |
3.2.1 ThreadLocal 使用場景
- 每個執行緒都需要維護一個自己專用的執行緒的上下文變數,比如計數器,JDBC 連結,web session,事務 ID 等。
- 包裝一個執行緒不安全的成員變數,給其提供一個執行緒安全的環境,比如 Java 裡面的 SimpleDateFormat 是執行緒不安全的,所以在多執行緒下使用可以採用 ThreadLocal 包裝,從而提供安全的訪問。
- 對於一些執行緒級別,傳遞方法引數有許多層的時候,我們可以使用 ThreadLocal 包裝,只在特定地方 set 一次,然後不管在什麼地方都可以隨便 get 出來,從而巧妙避免了多層傳參。
3.2.2 ThreadLocal 原理
- 執行緒共享變數快取是 Thread.ThreadLocalMap<ThreadLocal, Object>
- Thread 為當前執行緒。
- Object 為當前執行緒的共享變數。
- ThreadLocal 並不是替換 Java 裡面同步操作的,它的使用場景非常有限,在一定特定的情況下可以發揮比較棒的作用,比如在 Spring 和 Hibernate 框架中就大量採用了 ThreadLocal 來儲存事務會話。
- 儘管 ThreadLocalMap 的 Key 物件繼承了 WeakReference (弱引用)物件,能夠確保在記憶體空間不足的時候來回收物件,但 ThreadLocalMap 的 Value 值確是強引用,當執行緒沒有結束,但是 ThreadLocal 已經被回收,則可能導致執行緒中存在 ThreadLocalMap<null, Object> 的鍵值對,造成記憶體洩露。(ThreadLocal 被回收,ThreadLocal 關聯的執行緒共享變數還存在),為了防止此類情況的出現。
- 使用完執行緒共享變數後,顯示呼叫 ThreadLocalMap.remove 方法清除執行緒共享變數。
- JDK 建議將 ThreadLocal 定義為 private static ,這樣 ThreadLocal 的弱引用問題則不存在了。