一、多線程之間的通信(Java版本) 1、多線程概念介紹 多線程概念 在我們的程式層面來說,多線程通常是在每個進程中執行的,相應的附和我們常說的線程與進程之間的關係。線程與進程的關係:線程可以說是進程的兒子,一個進程可以有多個線程。但是對於線程來說,只屬於一個進程。再說說進程,每個進程的有一個主線程 ...
一、多線程之間的通信(Java版本)
1、多線程概念介紹
多線程概念
-
在我們的程式層面來說,
多線程
通常是在每個進程
中執行的,相應的附和我們常說的線程與進程
之間的關係。線程與進程的關係:線程可以說是進程的兒子,一個進程可以有多個線程
。但是對於線程來說,只屬於一個進程。再說說進程,每個進程的有一個主線程
作為入口,也有自己的唯一標識PID
,它的PID也就是這個主線程的線程ID
。 -
對於我們的電腦硬體來說,線程是
進程
中的一部分,也是進程的的實際運作單位,它也是操作系統中的最小運算調度單位。多線程可以提高CPU的處理速度。當然除了單核CPU,因為單核心CPU同一時間只能處理一個線程。在多線程環境下,對於單核CP來說,並不能提高響應速度,而且還會因為頻繁切換線程上下文導致性能降低。多核心CPU具有同時並行執行線程的能力,因此我們需要註意使用環境。線程數超出核心數時也會引起線程切換,並且操作系統對我們線程切換是隨機的。
2、線程之間如何通信
引入
- 對於我們Java語言來說,多線程編程也是它的特性之一。我們需要利用多線程操作同一
共用資源
,從而實現一些特殊任務。上面說了,多線程在進行切換時CPU隨機調度
的,假如我們直接運行多個線程操作共用資源的話,勢必會引起一些不可控錯誤因素。 - 接下來,我們就需要讓這些不可控變為可控 !這個時候就引出了本文的重點線程通信。線程通信就是
為瞭解決多線程對同一共用變數的爭奪
。
Java 線程通信的方式
- 共用記憶體機制
- 比如說Java的volatile關鍵字就是基於記憶體屏障解決變數的可見性,從而實現其他線程訪問共用變數都是必須從主存中獲取(對應其他線程對變數的更新也得及時的刷新到主存)。
- synchronized 關鍵字基於對象鎖這種方式實現線程互斥,可以通知對方有其他的線程正在執行這部分代碼。
- 消息傳遞模式
- wait() 和 notify()/notifyAll() 等待通知方式實現線程的阻塞就緒狀態之間的轉換。
- park、unpark
- join() 阻塞【底層也是依賴wait實現】。
- interrupt()打斷阻塞狀態。
- 管道輸入/輸出。
3、線程通信方法詳細介紹
主要介紹wait/notify,也有ReentrantLock的Condition條件變數的await/signal,LockSupport的park/unpark方法,也能實現線程之間的通信。主要是阻塞/喚醒通信模式。
首先說明這種方法一般都是作用於調用方法的所線上程。比如在主線程執行wait方法,就是將主線程阻塞了。
wait/notify機制
- wait()、notify方法在Java中是Object提供給我們的。又因為所有的類都預設隱式繼承了Object類,進而我們的每一個對象都具有wait和notify。
- wait方法含義:一個線程一旦調用了任意對象obj.wait()方法,它就釋放了所持有的監視器對象(obj)上的鎖,並轉為非運行狀態(阻塞)。
- notify方法含義:一個線程若執行obj.notify方法,則隨機喚醒obj對象上監視器(操作系統也稱為管程)monitor的阻塞隊列waitset中一個線程。
- wait和notify方法的使用同時必須配合synchronized關鍵字使用。同時也需要成對出現。就是說wait和notify必須得在同步代碼塊內部使用,大致原因就是需要保證同時只有一個線程可以去執行wait,使該線程阻塞。
await/signal
- 要想使用await/signal首先是需要借用Condition條件變數,要想獲取Condition條件變數,就必須通過ReentrantLock鎖獲取。
- ReentrantLock和Synchronized類似,都是可重入鎖,並且大多都是當做重量級鎖使用。
- 區別:ReentrantLock是API層面實現的,我們可以根據自己隨意調用定製,但是Synchronized是JVM底層實現,我們無需關心他上鎖解鎖的流程。
- await/signal使用時需要配合ReentrantLock鎖對象的lock和unlock方法加鎖解鎖。就像wait/notify在synchronized在同步代碼塊中使用一樣。他們都需要保證當前線程是唯一執行這段邏輯的線程。防止出現多線程造成的線程安全問題。
park/unpark
二、線程通信過程中需要註意的問題
1、喚醒丟失
如果一個線程先於被通知線程調用wait()前調用了notify(),等待的線程將錯過這個信號。
- 喚醒丟失主要是在我們使用wait 和 notify的過程中的時序問題。比如說我們線程二在執行某個對象notify的時候,線程一還沒有執行該對象的wait方法。那麼這次的喚醒就會丟失,我們就不能讓線程二得notify方法起作用,自然而然線程一就不會被喚醒。
- 舉個例子吧,這就好比我們平常在宿舍每天都會有叫醒服務,但是這次 因為一些原因(通宵···)我一整晚都沒有睡覺,而且當第二天早上的叫醒服務來的 時候也是醒著的。那麼叫醒服務就會以為你已經醒來了,就會視而不見。沒想到吧,叫醒服務剛走我就躺下來睡著了,所以我錯過了這次叫醒服務。就能好好的睡億覺了。這看起來沒有什麼大問題,但是你仔細想想若是每個睡著的人都需要被叫醒服務才能醒過來,外加上只有一次叫醒服務的機會。那麼你就可以沉睡萬年了,開心不。
- 哈哈哈···
- 這在程式中也是一樣 的,如果錯過notify那麼就會一直wait。
- 所以我們必須預防這種問題,比如說每隔一段時間去喚醒,也就是隔兩分鐘就去叫醒睡著的人。但是這種缺點就是太累了,對於程式來說是消耗性能和記憶體。實現也簡單就是寫入while迴圈體中,不停地嘗試即可。
- 我們也可以使用一個標誌位完美的實現。初始化設置
flag=FALSE
表示還沒wait,在wait之前將設置flag=TRUE
,在notify之後設置flag=FALSE
。每次notify喚醒之前都判斷flag=true
是否已經wait,在wait中判斷flag=false
是否已經notify。
核心代碼演示
- 首先使用線程池創建線程一使自己進入阻塞態,然後再調用LOCK1的notify方法喚醒線程一
// 線程一使用LOCK1對象調用wait方法阻塞自己
executor.execute(new ThreadTest("線程一",LOCK1,LOCK2));
synchronized (LOCK1) {
System.out.println("main執行notify方法讓線程一醒過來");
LOCK1.notify();
}
-
但是他很有可能醒不來,因為主線程調用LOCK1對象的notify方法,可能主線程已經執行完了,上麵線程還沒創建完成,也就是沒有進入wait狀態。就醒不來了。
-
解決方式:使用信號量標誌進行判斷是否已經進入wait
synchronized (LOCK1) { while (true) { if (FLAG.getFlag()) { System.out.println("main馬上執行notify方法讓線程一醒過來" + "flag = " + FLAG.getFlag()); LOCK1.notify(); // 將標誌位變為FALSE FLAG.setFlag(Constants.WaitOrNoWait.NO_WAIT.getFlag()); System.out.println("main執行notify方法完畢" + "flag = " + FLAG.getFlag()); break; } } }
2、假喚醒
由於莫名其妙的原因,線程有可能在沒有調用過notify()和notifyAll()的情況下醒來。
- 其實在上面的代碼中已經解決了假喚醒的問題,因為我們只需要不斷去嘗試獲取標誌位信息即可。
3、多線程喚醒
- 多個線程執行時,防止notifyAll全部喚醒之後就結束運行,我們的需求是只能喚醒一個線程,當其他線程被喚醒之後需要重新判斷標誌位是否為FALSE,也就是需要判斷是否有其他線程執行了喚醒操作,因為一次只能叫醒一個人,需要排隊,他們就可以繼續自旋判斷。
synchronized (waitName) {
while (!flag.getFlag()) {
try {
// 將標誌位設置為TRUE
flag.setFlag(Constants.WaitOrNoWait.WAIT.getFlag());
System.out.println("name;"+name+" 我睡著了進入阻塞狀態" + "flag = " + flag.getFlag());
waitName.wait();
System.out.println("name;"+name+" 我醒來了" + "flag = " + flag.getFlag());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- 大家如果使用的是new Thread()方式創建線程的話,要想保證安全的話還可以給該標誌位加上volatile關鍵字,可以時刻保證該標誌位的可見性。
- 我這裡使用的標誌位是使用傳遞引用的方式,使用同一個對象,將標誌位定義為該對象中的屬性,然後再結合枚舉類進行設置標誌位的值。因為我使用線程池創建對象,並且自定義線程類,這裡是無法設置全局變數,傳遞給線程類。包裝類也不行哦。(感興趣可以親自試一下)
- 大體代碼結構如下所示:
private final static Object LOCK1 = new Object();
private final static Object LOCK2 = new Object();
private final static Constants.WaitStatus FLAG = new Constants.WaitStatus(false);
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 5, 1, TimeUnit.DAYS, new ArrayBlockingQueue<>(4), new ThreadPoolExecutor.AbortPolicy());
executor.execute(new ThreadTest("線程一",LOCK1,LOCK2, FLAG));
// ···喚醒
}
class ThreadTest implements Runnable { //阻塞··· }
完整代碼可以看這[Gitee倉庫完整代碼][https://gitee.com/malongfeistudy/javabase/tree/master/Java多線程_Study/src/main/java/com/mlf/thread/demo_wait_notify]
三、線程通信實戰
前置知識:線程池的使用方法
-
首先複習一下創建線程的幾種方式和其的優缺點:
- 通過new Thread()
- 繼承Thread():和new Thread沒啥區別,就是耦合度低了
- 定義線程類繼承Thread類並且重寫run方法即可。
- 優點是簡潔方便
- 缺點是占用了該類的單繼承位置,無法繼承其他父類
- 實現Runnable介面
- 實現Callable介面
- 和實現Runnable介面類似
- 優點:
- 實現介面,不占用繼承的位置;
- 耦合度降低,並且可定化程度提高。各個模塊之間的調用關係更加清晰
- 缺點:
- 實現起來稍微麻煩
-
使用線程池的步驟
- 線程池初始化方式:
- 使用Executor初始化線程池
- 優點:方便快捷,適用於自己測試時使用
- 缺點:在實際開發中無法判斷細節
- new ThreadPoolExecutor()構造器創建(本文使用方式)
- 優點:可以清晰地定製出適合自己的線程池,不會造成資源浪費
- 缺點:麻煩
- 使用Executor初始化線程池
- 線程池初始化方式:
-
在主線程自定義線程池使用實例,這裡需要根據實際情況定義鎖對象,因為我們需要使用這些鎖對象控制多線程之間的運行順序以及線程之間的通信。在Java中每個對象都會在初始化的時候擁有一個監視器,我們需要利用好他進行併發編程。這種創建線程池的方法也是阿裡巴巴推薦的方式,想想以阿裡的體量多年總結出來的總沒有錯,大家還是提前約束自己的編碼習慣等。安裝一個阿裡代碼規範的插件對自己的程式員道路是比較nice的。
/**
* 每個使用對應唯一的對象作為監視器對象鎖。
*/
public static final Object A_O = new Object();
public static final Object B_O = new Object();
/** 參數:
* int corePoolSize, 核心線程數
* int maximumPoolSize, 最大線程數
* long keepAliveTime, 救急存活時間
* TimeUnit unit, 單時間位
* BlockingQueue<Runnable> workQueue, 阻塞隊列
* RejectedExecutionHandler handler 拒絕策略
**/
// 使用阿裡巴巴推薦的創建線程池的方式
// 通過ThreadPoolExecutor構造函數自定義參數創建
ThreadPoolExecutor executor = new ThreadPoolExecutor(
3,
5,
1,
TimeUnit.DAYS,
new ArrayBlockingQueue<>(2),
new ThreadPoolExecutor.AbortPolicy());
- 接下來需要自定義線程類,我們可以自定義線程類,並且在該線程類中定義自己需要的共用資源(鎖對象,屬性等),在run方法中寫盡自己的線程運行邏輯即可。
class ThreadDiy implements Runnable {
private final String name;
/**
* 阻塞鎖對象 等待標記
**/
private final Object waitFor;
/**
* 執行鎖對象 下一個標記
**/
private final Object next;
public AlternateThread(String name, Object waitFor, Object next) {
}
@Override
public void run() {
// 線程的代碼邏輯···
}
}
1、控制兩個線程之間的執行順序
題目:現在有兩個線程,不論線程的啟動順序,我需要指定線程一先執行,然後線程二再執行。
-
初始化兩個對象鎖作為線程監視器。
private final static Object ONE_LOCK = new Object(); private final static Object TWO_LOCK = new Object();
-
接下來初始化線程池,上面有具體的介紹,在這就不多說了
-
使用線程池去執行我們的兩個線程,在這裡我們需要分析的是
// 使用線程池創建線程
executor.execute(new DiyThread(1, ONE_LOCK, TWO_LOCK));
executor.execute(new DiyThread(2, TWO_LOCK, ONE_LOCK));
synchronized (ONE_LOCK) {
ONE_LOCK.notify();
}
創建線程類
-
我們使用繼承Runnable的方式去創建線程對象,需要在這個類中實現每個線程執行的邏輯,我們根據題目可以得出,我們要控制每個線程的執行順序,怎麼辦?那麼就要實現所有線程之間的通信,通信方式採用wait-notify的方式即可。我們使用wait-notify的時候必須結合synchronized,那麼就需要控制兩個對象鎖。因為我們不光是控制自己,還有另一個線程。
-
我們再分析一下題意,首先需要指定先後執行的順序,那麼就需要實現兩個線程之間的通信。其次呢,我們得控制兩個線程,那麼就需要兩個監視器去監視這兩個線程。
-
我們定義這兩個監視器對象為own和other。然後再新增一個屬性threadId來標識自己。
private final int threadId; private final Object own; private final Object other;
-
接下來就是編寫Run方法了
-
每個線程首先需要阻塞自己,等待喚醒。然後喚醒之後,再去喚醒另外一個線程。這樣就實現了自定義順序。至於先喚醒哪個線程,交給我們的主線程去完成。
-
這裡需要註意的是,如果我們只是單純地執行了多個線程對象,但是主線程沒有主動去喚醒其中一個,這樣就會形成類似於死鎖的迴圈等待。你需要我喚醒,我需要你喚醒。這個時候需要主線程去插手喚醒其中的任意一個線程。
-
第一步阻塞自己own
synchronized (own) { try { own.wait(); System.out.println(num); } catch (InterruptedException e) { e.printStackTrace(); } }
-
第二步喚醒other
synchronized (other) { other.notify(); }
-
2、多線程交替列印輸出
題目需求:現在需要使用三個線程輪流列印輸出。說白了也就是多線程輪流執行罷了,和問題一控制兩個線程列印順序沒什麼區別
- 還是老步驟,首先需要定義線程類,我們需要控制當前線程和下一個線程即可。我們這裡需要兩個對象,一個是阻塞鎖對象用來阻塞當前線程。另一個是喚醒鎖對象,用來喚醒下一個對象。
/**
* 阻塞鎖對象 等待標記
**/
private final Object waitFor;
/**
* 喚醒鎖對象 下一個標記
**/
private final Object next;
-
run方法的邏輯和上面的基本一樣。 一個線程一旦調用了任意對象的wait()方法,它就釋放了所持有的監視器對象上的鎖,並轉為非運行狀態。
-
每個線程首先會調用 waitFor對象的 wait()方法,隨後該線程進入阻塞狀態,等待其他線程執行自己引用的該 waitFor對象的 notify()方法即可。
while (true) { synchronized (waitFor) { try { waitFor.wait(); System.out.println(name + " 開始執行"); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (next) { next.notify(); } }
-
主線程需要初始化線程池、執行三個線程,並且最後需要打破僵局,因為此時每個線程都是阻塞狀態,他們沒法阻塞/喚醒迴圈下去。
synchronized (A_O) { A_O.notify(); }
-
模擬執行流程
/**
* 模擬執行流程
* 列印名(name) 等待標記(waitFor) 下一個標記(next)
* 1 A B
* 2 B C
* 3 C A
*
* 像不像Spring的迴圈依賴:確實很像,Spring中的迴圈依賴就是 BeanA 依賴 BeanB,BeanB 依賴 BeanA;
* 他們實例化過程中都需要先屬性註入對方的實例,倘若剛開始的時候都沒有實例化,初始化就會死等。類似於死鎖。
**/
3、多線程順序列印同一個自增變數
使用多線程輪流列印 01234····
- 思路:使用自增原子變數AtomicInteger和多線程配合列印。
具體代碼請移步到Gitee倉庫:[順序列印自增變數][https://gitee.com/malongfeistudy/javabase/blob/master/Java多線程_Study/src/main/java/com/mlf/thread/print/AddNumberPrint2.java]
條件變數Condition的使用
- Condition是一個 LOCK 實例出來的,他們獲取的都是一個 LOCK 的鎖,而如果要調用 object的 wait和notify 方法,首先要獲取對應的object的鎖,如果要調用Condition 的await、signal方法,必須先獲取Lock鎖(Lock.lock)。
- 多線程的初衷就是操作共用資源,然後我們需要保證共用資源同一時刻只能被一個線程所修改。那麼就需要一把鎖來控制這些線程之間互斥條件。這裡使用一個ReentrantLock鎖作為我們的Lock對象。通過同一個 Lock鎖 獲取的每個Condition 就可以作為每個線程自己的阻塞條件和喚醒條件。
如有問題,請留言評論。
作者:小白且菜鳥 出處:https://www.cnblogs.com/malongfeistudy/