Java并發(fā)編程多線程間的同步控制和通信詳解
正文
使用多線程并發(fā)處理,目的是為了讓程序更充分地利用CPU ,好能加快程序的處理速度和用戶體驗。如果每個線程各自處理的部分互不相干,那真是極好的,我們在程序主線程要做的同步控制最多也就是等待幾個工作線程的執(zhí)行完畢,如果不 Care 結(jié)果的話,連同步等待都能省去,主線程撒開手讓這些線程干就行了。
不過,現(xiàn)實還是很殘酷的,大部分情況下,多個線程是會有競爭操作同一個對象的情況的,這個時候就會導(dǎo)致并發(fā)常見的一個問題--數(shù)據(jù)競爭(Data Racing)。
這篇文章我們就來討論一下這個并發(fā)導(dǎo)致的問題,以及多線程間進(jìn)行同步控制和通信的知識,本文大綱如下:
并發(fā)導(dǎo)致的Data Racing問題
怎么理解這個問題呢,拿一個多個線程同時對累加器對象進(jìn)行累加的例子來解釋吧。
package com.learnthread; public class DataRacingTest { public static void main(String[] args) throws InterruptedException { final DataRacingTest test = new DataRacingTest(); // 創(chuàng)建兩個線程,執(zhí)行 add100000() 操作 // 創(chuàng)建Thread 實例時的 Runnable 接口實現(xiàn),這里直接使用了 Lambda Thread th1 = new Thread(()-> test.add100000()); Thread th2 = new Thread(()-> test.add100000()); // 啟動兩個線程 th1.start(); th2.start(); // 等待兩個線程執(zhí)行結(jié)束 th1.join(); th2.join(); System.out.println(test.count); } private long count = 0; // 想復(fù)現(xiàn) Data Racing,去掉這里的 synchronized private void add100000() { int idx = 0; while(idx++ < 100000) { count += 1; } } }
上面這個例程,如果我們不啟動 th2 線程,只用 th1 一個線程進(jìn)行累加操作的話結(jié)果是 100000。按照這個思維,如果我們啟動兩個線程那么最后累加的結(jié)果就應(yīng)該是 200000。 但實際上并不是,我們運行一下上面的例程,得到的結(jié)果是:
168404
Process finished with exit code 0
當(dāng)然這個在每個人的機(jī)器上的結(jié)果是不一樣的,而且也是有可能恰好等于 200000,需要多運行幾次,或者是多開幾個線程執(zhí)行累加,出現(xiàn) Data Racing 的幾率才高。
程序出現(xiàn) Data Racing 的現(xiàn)象,就意味著最終拿到的數(shù)據(jù)是不正確的。那么為了避免這個問題就需要通過加鎖來解決了,讓同一時間只有持有鎖的線程才能對數(shù)據(jù)對象進(jìn)行操作。當(dāng)然針對簡單的運算、賦值等操作我們也能直接使用原子操作實現(xiàn)無鎖解決 Data Racing, 我們?yōu)榱耸纠銐蚝唵我锥排e了一個累加的例子,實際上如果是一段業(yè)務(wù)邏輯操作的話,就只能使用加鎖來保證不會出現(xiàn) Data Racing了。
加鎖,只是線程并發(fā)同步控制的一種,還有釋放鎖、喚醒線程、同步等待線程執(zhí)行完畢等操作,下面我們會逐一進(jìn)行學(xué)習(xí)。
同步控制--synchronized
開頭的那個例程,如果想避免 Data Racing,那么就需要加上同步鎖,讓同一個時間只能有一個線程操作數(shù)據(jù)對象。 針對我們的例程,我們只需要在 add100000
方法的聲明中加上 synchronized
即可。
// 想復(fù)現(xiàn) Data Racing,去掉這里的 synchronized private synchronized void add100000() { int idx = 0; while(idx++ < 100000) { count += 1; } }
是不是很簡單,當(dāng)然 synchronized
的用法遠(yuǎn)不止這個,它可以加在實例方法、靜態(tài)方法、代碼塊上,如果使用的不對,就不能正確地給需要同步鎖保護(hù)的對象加上鎖。
synchronized 是 Java 中的關(guān)鍵字,是利用鎖的機(jī)制來實現(xiàn)互斥同步的。 synchronized 可以保證在同一個時刻,只有一個線程可以執(zhí)行某個方法或者某個代碼塊。 如果不需要 Lock
、讀寫鎖ReadWriteLock
所提供的高級同步特性,應(yīng)該優(yōu)先考慮使用synchronized
這種方式加鎖,主要原因如下:
- Java 自 1.6 版本以后,對
synchronized
做了大量的優(yōu)化,其性能已經(jīng)與 JUC 包中的Lock
、ReadWriteLock
基本上持平。從趨勢來看,Java 未來仍將繼續(xù)優(yōu)化synchronized
,而不是 ReentrantLock 。 - ReentrantLock 是 Oracle JDK 的 API,在其他版本的 JDK 中不一定支持;而 synchronized 是 JVM 的內(nèi)置特性,所有 JDK 版本都提供支持。
synchronized
可以應(yīng)用在實例方法、靜態(tài)方法和代碼塊上:
- 用
synchronized
關(guān)鍵字修飾實例方法,即為同步實例方法,鎖是當(dāng)前的實例對象。 - 用
synchronized
關(guān)鍵字修飾類的靜態(tài)方法,即為同步靜態(tài)方法,鎖是當(dāng)前的類的 Class 對象。 - 如果把
synchronized
應(yīng)用在代碼塊上,鎖是synchronized
括號里配置的對象,synchronized(this) {..}
鎖就是代碼塊所在實例的對象,synchronized(類名.class) {...}
,鎖就是類的Class
對象。
同步實例方法和代碼塊
上面我們已經(jīng)看過怎么給實例方法加 synchronized
讓它變成同步方法了。下面我們看一下,synchronized
給實例方法加鎖時,不能保證資源被同步鎖保護(hù)的例子。
class Account { private int balance; // 轉(zhuǎn)賬 synchronized void transfer(Account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } }
在這段代碼中,臨界區(qū)內(nèi)有兩個資源,分別是轉(zhuǎn)出賬戶的余額 this.balance
和轉(zhuǎn)入賬戶的余額 target.balance
,并且用的是一把實例對象的鎖。問題就出在 this
這把鎖上,this
這把鎖可以保護(hù)自己的余額 this.balance
,卻保護(hù)不了別人的余額 target.balance
,就像你不能用自家的鎖來保護(hù)別人家的資產(chǎn)一個道理。
應(yīng)該保證使用的鎖能保護(hù)所有應(yīng)受保護(hù)資源。我們可以使用Account.class 作為加鎖的對象。Account.class
是所有 Account
類的對象共享的,而且是 Java 虛擬機(jī)在加載 Account 類的時候創(chuàng)建的,保證了它的全局唯一性。
class Account { private int balance; // 轉(zhuǎn)賬 void transfer(Account target, int amt){ synchronized(Account.class) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }
用 synchronized 給 Account.class 加鎖,這樣就保證出賬、入賬兩個 Account 對象在同步代碼塊里都能收到保護(hù)。
當(dāng)然我們也可以使用這筆轉(zhuǎn)賬的交易對象作為加鎖的對象,保證只有這比交易的兩個 Account 對象受保護(hù),這樣就不會影響到其他轉(zhuǎn)賬交易里的出賬、入賬 Account 對象了。
class Account { private Trans trans; private int balance; private Account(); // 創(chuàng)建 Account 時傳入同一個 交易對象作為 lock 對象 public Account(Trans trans) { this.trans = trans; } // 轉(zhuǎn)賬 void transfer(Account target, int amt){ // 此處檢查所有對象共享的鎖 synchronized(trans) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }
通過解決上面這個問題我們順道就把 synchronized
修飾同步代碼塊的知識點學(xué)了, 現(xiàn)在我們來看 synchronized
的最后一個用法--修飾同步靜態(tài)方法。
同步靜態(tài)方法
靜態(tài)方法的同步是指,用 synchronized
修飾的靜態(tài)方法,與使用所在類的 Class
對象實現(xiàn)的同步代碼塊,效果類似。因為在 JVM 中一個類只能對應(yīng)一個類的 Class 對象,所以同時只允許一個線程執(zhí)行同一個類中的靜態(tài)同步方法。
對于同一個類中的多個靜態(tài)同步方法,持有鎖的線程可以執(zhí)行每個類中的靜態(tài)同步方法而無需等待。不管類中的哪個靜態(tài)同步方法被調(diào)用,一個類只能由一個線程同時執(zhí)行。
package com.learnthread; public class SynchronizedStatic implements Runnable { private static final int MAX = 100000; private static int count = 0; public static void main(String[] args) throws InterruptedException { SynchronizedStatic instance = new SynchronizedStatic(); Thread t1 = new Thread(instance); Thread t2 = new Thread(instance); t1.start(); t2.start(); // 等待工作線程執(zhí)行結(jié)束 t1.join(); t2.join(); System.out.println(count); } @Override public void run() { for (int i = 0; i < MAX; i++) { increase(); } } /** * synchronized 修飾靜態(tài)方法 */ public synchronized static void increase() { count++; } }
線程掛起和喚醒
上面我們看了使用 synchronized
給對象加同步鎖,讓同一時間只有一個線程能操作臨界區(qū)的控制。接下來,我們看一下線程的掛起和喚醒,這兩個操作使用被線程成功加鎖的對象的 wait
和 notify
方法來完成,喚醒除了notify
外還有 notifyAll
方法用來喚醒所有線程。下面我們先看一下這幾個方法的解釋。
wait
-wait
會自動釋放當(dāng)前線程占有的對象鎖,并請求操作系統(tǒng)掛起當(dāng)前線程,讓線程從Running
狀態(tài)轉(zhuǎn)入Waiting
狀態(tài),等待被notify / notifyAll
來喚醒。如果沒有釋放鎖,那么其它線程就無法進(jìn)入對象的同步方法或者同步代碼塊中,那么就無法執(zhí)行notify
或者notifyAll
來喚醒掛起的線程,會造成死鎖。notify
- 喚醒一個正在Waiting
狀態(tài)的線程,并讓它拿到對象鎖,具體喚醒哪一個線程由 JVM 控制 。notifyAll
- 喚醒所有正在Waiting
狀態(tài)的線程,接下來它們需要競爭對象鎖。
這里有兩點需要各位注意的地方, 第一個是 wait
、notify
、notifyAll
都是 Object 類中的方法,而不是 Thread 類的。
因為 Object 是始祖類,是不是意味著所有類的對象都能調(diào)用這幾個方法呢?是,也不是... 因為 wait、notify、notifyAll 只能用在 synchronized 方法或者 synchronized 代碼塊中使用,否則會在運行時拋出 IllegalMonitorStateException。換句話說,只有被 synchronized 加上鎖的對象,才能調(diào)用這三個方法。
為什么 wait
、notify
、notifyAll
不定義在 Thread
類中?為什么 wait
、notify
、notifyAll
要配合 synchronized
使用? 理解為什么這么設(shè)計,需要了解幾個基本知識點:
- 每一個 Java 對象都有一個與之對應(yīng)的監(jiān)視器(monitor)
- 每一個監(jiān)視器里面都有一個 對象鎖 、一個 等待隊列、一個 同步隊列
了解了以上概念,我們回過頭來理解前面兩個問題。
為什么這幾個方法不定義在 Thread 中?
- 由于每個對象都擁有對象鎖,讓當(dāng)前線程等待某個對象鎖,自然應(yīng)該基于這個對象(Object)來操作,而非使用當(dāng)前線程(Thread)來操作。因為當(dāng)前線程可能會等待多個線程釋放鎖,如果基于線程(Thread)來操作,就非常復(fù)雜了。
為什么 wait、notify、notifyAll 要配合 synchronized 使用?
- 如果調(diào)用某個對象的 wait 方法,當(dāng)前線程必須擁有這個對象的對象鎖,因此調(diào)用 wait 方法必須在 synchronized 方法和 synchronized 代碼塊中。
下面看一個 wait、notify、notifyAll 的一個經(jīng)典使用案例,實現(xiàn)一個生產(chǎn)者、消費者模式:
package com.learnthread; import java.util.PriorityQueue; public class ThreadWaitNotifyDemo { private static final int QUEUE_SIZE = 10; private static final PriorityQueue<Integer> queue = new PriorityQueue<>(QUEUE_SIZE); public static void main(String[] args) { new Producer("生產(chǎn)者A").start(); new Producer("生產(chǎn)者B").start(); new Consumer("消費者A").start(); new Consumer("消費者B").start(); } static class Consumer extends Thread { Consumer(String name) { super(name); } @Override public void run() { while (true) { synchronized (queue) { while (queue.size() == 0) { try { System.out.println("隊列空,等待數(shù)據(jù)"); queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); queue.notifyAll(); } } queue.poll(); // 每次移走隊首元素 queue.notifyAll(); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " 從隊列取走一個元素,隊列當(dāng)前有:" + queue.size() + "個元素"); } } } } static class Producer extends Thread { Producer(String name) { super(name); } @Override public void run() { while (true) { synchronized (queue) { while (queue.size() == QUEUE_SIZE) { try { System.out.println("隊列滿,等待有空余空間"); queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); queue.notifyAll(); } } queue.offer(1); // 每次插入一個元素 queue.notifyAll(); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " 向隊列取中插入一個元素,隊列當(dāng)前有:" + queue.size() + "個元素"); } } } } }
上面的例程有兩個生產(chǎn)者和兩個消費者。生產(chǎn)者向隊列中放數(shù)據(jù),每次向隊列中放入數(shù)據(jù)后使用 notifyAll
喚醒消費者線程,當(dāng)隊列滿后生產(chǎn)者會 wait
讓出線程,等待消費者取走數(shù)據(jù)后再被喚醒 (消費者取數(shù)據(jù)后也會調(diào)用 notifyAll
)。同理消費者在隊列空后也會使用 wait
讓出線程,等待生產(chǎn)者向隊列中放入數(shù)據(jù)后被喚醒。
線程等待--join
與 wait
和 notify
方法一樣,join
是另一種線程間同步機(jī)制。當(dāng)我們調(diào)用線程對象 join
方法時,調(diào)用線程會進(jìn)入等待狀態(tài),它會一直處于等待狀態(tài),直到被引用的線程執(zhí)行結(jié)束。在上面的幾個例子中,我們已經(jīng)使用過了 join
方法
... public static void main(String[] args) throws InterruptedException { SynchronizedStatic instance = new SynchronizedStatic(); Thread t1 = new Thread(instance); Thread t2 = new Thread(instance); t1.start(); t2.start(); // 等待工作線程執(zhí)行結(jié)束 t1.join(); t2.join(); System.out.println(count); }
這個例子里,主線程調(diào)用 t1 和 t2 的 join 方法后,就會一直等待,直到他們兩個執(zhí)行結(jié)束。如果 t1 或者 t2 線程處理時間過長,調(diào)用它們 join 方法的主線程將一直等待,程序阻塞住。為了避免這些情況,可以使用能指定超時時間的重載版本的 join 方法。
t2.join(1000); // 最長等待1s
如果引用的線程被中斷,join方法也會返回。在這種情況下,還會觸發(fā) InterruptedException
。所以上面的main
方法為了演示方便,直接選擇拋出了 InterruptedException
。
總結(jié)
同步控制的一大思路就是加鎖,除了本問學(xué)習(xí)到的 sychronized 同步控制,Java 里還有 JUC 的可重入鎖、讀寫鎖這種加鎖方式,這個我們后續(xù)介紹 JUC 的時候會給大家講解。
另外一種思路是不加鎖,讓線程和線程之間盡量不要使用共享數(shù)據(jù),ThreadLocal 就是這種思路,下篇我們介紹 Java 的線程本地存儲 -- ThreadLocal,更多關(guān)于Java 并發(fā)多線程同步控制通信的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
詳解SpringSecurity如何實現(xiàn)前后端分離
這篇文章主要為大家介紹了詳解SpringSecurity如何實現(xiàn)前后端分離,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-03-03Java實現(xiàn)反轉(zhuǎn)一個鏈表的示例代碼
本文主要介紹了Java實現(xiàn)反轉(zhuǎn)一個鏈表的示例代碼,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-07-07String s = new String(''a '') 到底產(chǎn)生幾個對象
這篇文章主要介紹了String s = new String(" a ") 到底產(chǎn)生幾個對象,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-05-05