必須知道的線程通信
1.線程間通信
線程開始運行,擁有自己的棧空間,就如同一個腳本一樣,按照既定的代碼一步一步地執
行,直到終止。但是,每個運行中的線程,如果僅僅是孤立地運行,那麼沒有一點兒價值,或者說價值很少,如果多個線程能夠相互配合完成工作,這將會帶來巨大的價值。在JMM內存模型中我們說道JMM本身是爲了解決併發編程中三大問題:原子性、可見性、有序性。而這三大問題本質上就是多線程間通信問題。
1.1 線程間協同
要想實現多個線程的協同:如線程執行的先後順序、獲取某個線程執行結果等等。涉及到線程之間相互通信,大致分爲四類:
- 文件共享
- 網絡共享
- 共享變量
- jdk提供的線程協調API:
suspend/resume、wait/notify、park/unpark
文件、網絡共享這裏就不說了!共享變量會通過關鍵字(volatile)細說。這裏討論下jdk提供的API。
1.2 suspend/resume
大家對於CD機肯定不會陌生,如果把它播放音樂比作一個線程的運作,那麼對音樂播放
做出的暫停、恢復和停止操作對應在線程Thread的API就是suspend()、resume()和stop()。
/**
* 包子店
*/
public static Object shop = null;
public void suspendResumeTest() throws Exception {
// 啓動線程
Thread consumerThread = new Thread(() -> {
if (shop == null) { // 如果沒包子,則進入等待
System.out.println("1、進入等待");
Thread.currentThread().suspend();
}
System.out.println("2、買到包子,回家");
});
consumerThread.start();
// 3秒之後,生產一個包子
Thread.sleep(3000L);
shop = new Object();
consumerThread.resume();
}
輸出結果:
1、進入等待
2、買到包子,回家
3、通知消費者
這段代碼很簡單 啓動一個消費者線程,如果店鋪有包子就購買,沒有包子則掛起等待,主線程休眠三秒後開始生產包子後喚起消費線程。這段代碼開着沒有問題,但是它有個隱患,消費者線程在掛起的時候是沒有釋放資源的,容易引起死鎖!舉個例子:
public void suspendResumeDeadLockTest() throws Exception {
// 啓動線程
Thread consumerThread = new Thread(() -> {
if (shop == null) { // 如果沒包子,則進入等待
System.out.println("1、進入等待");
// 當前線程拿到鎖,然後掛起
synchronized (this) {
Thread.currentThread().suspend();
}
}
System.out.println("2、買到包子,回家");
});
consumerThread.start();
// 3秒之後,生產一個包子
Thread.sleep(3000L);
shop = new Object();
// 爭取到鎖以後,再恢復consumerThread
synchronized (this) {
consumerThread.resume();
}
System.out.println("3、通知消費者");
}
這段代碼是不會有結果的,在消費者進入等待過程中,沒有釋放鎖,主線程永遠拿不到鎖!正因爲suspend()、resume()和stop()方法帶來的副作用,這些方法才被標註爲不建議使用的過期方法,而暫停和恢復操作可以用後面提到的等待/通知機制來替代。
1.3 wait/notify
等待/通知機制,是指一個線程A調用了對象O的wait()方法進入等待狀態,而另一個線程B
調用了對象O的notify()或者notifyAll()方法,線程A收到通知後從對象O的wait()方法返回,進而執行後續操作。上述兩個線程通過對象O來完成交互,而對象上的wait()和notify/notifyAll()的關係就如同開關信號一樣,用來完成等待方和通知方之間的交互工作。
public static Object shop = null;
public void waitNotifyTest() throws Exception {
// 啓動線程
new Thread(() -> {
if (shop == null) { // 如果沒包子,則進入等待
synchronized (this) {
try {
System.out.println("1、進入等待");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
System.out.println("2、買到包子,回家");
}).start();
// 3秒之後,生產一個包子
Thread.sleep(3000L);
shop = new Object();
synchronized (this) {
this.notifyAll();
System.out.println("3、通知消費者");
}
}
調用結果:
1、進入等待
3、通知消費者
2、買到包子,回家
消費者獲取到鎖之後判斷沒有包子,就掛起等待,同時釋放鎖,主線程生產獲取資源後生產包子,通知消費者消費。通過上面代碼可以看出來消費者線程wait時釋放了鎖資源。
當然 wait和notify也有需要注意的地方。
/**
* 會導致程序永久等待的wait/notify
*/
public void waitNotifyDeadLockTest() throws Exception {
// 啓動線程
new Thread(() -> {
if (shop == null) { // 如果沒包子,則進入等待
try {
Thread.sleep(5000L);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
synchronized (this) {
try {
System.out.println("1、進入等待");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
System.out.println("2、買到包子,回家");
}).start();
// 3秒之後,生產一個包子
Thread.sleep(3000L);
shop = new Object();
synchronized (this) {
this.notifyAll();
System.out.println("3、通知消費者");
}
}
消費者休眠5秒,主線程生產者休眠3秒,當主線程休眠結束通知消費者線程的時候,是喚不起消費者的。
通過上面兩個例子,可以看出來wait、notify或者notifyAll,需要注意如下幾點:
- 使用wait()、notify()和notifyAll()時需要先對調用對象加鎖。
- 調用wait()方法後,線程狀態由RUNNING變爲WAITING,並將當前線程放置到對象的等待隊列。
- notify()或notifyAll()方法調用後,等待線程依舊不會從wait()返回,需要調用notify()或notifAll()的線程釋放鎖之後,等待線程纔有機會從wait()返回。
- notify()方法將等待隊列中的一個等待線程從等待隊列中移到同步隊列中,而notifyAll()方法則是將等待隊列中所有的線程全部移到同步隊列,被移動的線程狀態由WAITING變爲BLOCKED。
- 從wait()方法返回的前提是獲得了調用對象的鎖。
從上述細節中可以看到,等待/通知機制依託於同步機制,其目的就是確保等待線程從
wait()方法返回時能夠感知到通知線程對變量做出的修改。
WaitThread首先獲取了對象的鎖,然後調用對象的wait()方法,從而放棄了鎖並進入了對象的等待隊列WaitQueue中,進入等待狀態。由於WaitThread釋放了對象的鎖,NotifyThread隨後獲取了對象的鎖,並調用對象的notify()方法,將WaitThread從WaitQueue移到SynchronizedQueue中,此時WaitThread的狀態變爲阻塞狀態。NotifyThread釋放了鎖之後,WaitThread再次獲取到鎖並從wait()方法返回繼續執行。
1.4 wait/notify的經典範式
通過1.3 wait、notify的事例我們可以提煉出等待/通知的經典範式,該範式分爲兩部分,分
別針對等待方(消費者)和通知方(生產者)。
等待方遵循如下原則。
1)獲取對象的鎖。
2)如果條件不滿足,那麼調用對象的wait()方法,被通知後仍要檢查條件。
3)條件滿足則執行對應的邏輯。
synchronized(對象) {
while(條件不滿足) {
對象.wait();
}
對應的處理邏輯
}
通知方遵循如下原則。
1)獲得對象的鎖。
2)改變條件。
3)通知所有等待在對象上的線程。
synchronized(對象) {
改變條件
對象.notifyAll();
}
1.6 park/unpark 的使用
無論是wait/notify 或者是已經過時的suspend/resume ,在使用上都有過多的限制和需要注意的地方。有沒有一個用的更爽的方式,答案當然是有的啊,在J.U.C的包中LockSupport提供了一種更爲簡潔的方式:park/unpark。
public static Object shop = null;
public void parkUnparkTest() throws Exception {
// 啓動線程
Thread consumerThread = new Thread(() -> {
if (shop == null) { // 如果沒包子,則進入等待
System.out.println("1、進入等待");
LockSupport.park();
}
System.out.println("2、買到包子,回家");
});
consumerThread.start();
// 3秒之後,生產一個包子
Thread.sleep(3000L);
shop = new Object();
LockSupport.unpark(consumerThread);
System.out.println("3、通知消費者");
}
park 和unpark不要求調用順序,多次調用unpark之後在調用park,線程會直接運行。但不會疊加,也就是說連續多次調用park方法,第一次會拿到“許可”直接運行,後續調用會進入等待。
1.7 Thread.join()的使用
如果一個線程A執行了thread.join()語句,其含義是:當前線程A等待thread線程終止之後才
從thread.join()返回。線程Thread除了提供join()方法之外,還提供了join(long millis)join(long millis,int nanos)兩個具備超時特性的方法。這兩個超時方法表示,如果線程thread在給定的超時時間裏沒有終止,那麼將會從該超時方法中返回。
看下面事例代碼:
在下面所示的例子中,創建了10個線程,編號0~9,每個線程調用前一個線程的join()方法,也就是線程0結束了,線程1才能從join()方法中返回,而線程0需要等待main線程結束。
public class Join {
public static void main(String[] args) throws Exception {
Thread previous = Thread.currentThread();
for (int i = 0; i < 10; i++) {
// 每個線程擁有前一個線程的引用,需要等待前一個線程終止,才能從等待中返回
Thread thread = new Thread(new Domino(previous), String.valueOf(i));
thread.start();
previous = thread;
}
TimeUnit.SECONDS.sleep(5);
System.out.println(Thread.currentThread().getName() + " terminate.");
}
static class Domino implements Runnable {
private Thread thread;
public Domino(Thread thread) {
this.thread = thread;
}
public void run() {
try {
thread.join();
} catch (InterruptedException e) {
}
System.out.println(Thread.currentThread().getName() + " terminate.");
}
}
}
輸出如下:
main terminate.
0 terminate.
1 terminate.
2 terminate.
3 terminate.
4 terminate.
5 terminate.
6 terminate.
7 terminate.
8 terminate.
9 terminate.
從上述輸出可以看到,每個線程終止的前提是前驅線程的終止,每個線程等待前驅線程終止後,才從join()方法返回,這裏涉及了等待/通知機制(等待前驅線程結束,接收前驅線程結束通知)。
1.8 ThreadLocal的使用
ThreadLocal,即線程變量,是一個以ThreadLocal對象爲鍵、任意對象爲值的存儲結構。這個結構被附帶在線程上,也就是說一個線程可以根據一個ThreadLocal對象查詢到綁定在這個線程上的一個值。
可以通過set(T)方法來設置一個值,在當前線程下再通過get()方法獲取到原先設置的值。
說白了,JVM維護了一個Map<Thread,T>,每個線程就是一個map裏面的key,如果想使用value,必須通過線程這個key來獲取。
public class Demo7 {
/** threadLocal變量,每個線程都有一個副本,互不干擾 */
public static ThreadLocal<String> value = new ThreadLocal<>();
/**
* threadlocal測試
*
* @throws Exception
*/
public void threadLocalTest() throws Exception {
// threadlocal線程封閉示例
value.set("這是主線程設置的123"); // 主線程設置值
String v = value.get();
System.out.println("線程1執行之前,主線程取到的值:" + v);
new Thread(new Runnable() {
@Override
public void run() {
String v = value.get();
System.out.println("線程1取到的值:" + v);
// 設置 threadLocal
value.set("這是線程1設置的456");
v = value.get();
System.out.println("重新設置之後,線程1取到的值:" + v);
System.out.println("線程1執行結束");
}
}).start();
Thread.sleep(5000L); // 等待所有線程執行結束
v = value.get();
System.out.println("線程1執行之後,主線程取到的值:" + v);
}
public static void main(String[] args) throws Exception {
new Demo7().threadLocalTest();
}
}
執行結果如下:
線程1執行之前,主線程取到的值:這是主線程設置的123
線程1取到的值:null
重新設置之後,線程1取到的值:這是線程1設置的456
線程1執行結束
線程1執行之後,主線程取到的值:這是主線程設置的123