目前,多線程編程可以說是在大部分平臺和應用上都需要實現的一個基本需求。本系列文章就來對 Java 平臺下的多線程編程知識進行講解,從概念入門、底層實現到上層應用都會涉及到,預計一共會有五篇文章,希望對你有所幫助😎😎
本篇文章是第二篇,介紹實現多線程同步的各類方案,涉及多種多線程同步機制,是開發者在語言層面上對多線程運行所做的規則設定
一、線程同步機制
前面的文章有介紹到,多線程安全問題概括來說表現爲三個方面:原子性、可見性、有序性。多線程安全問題的產生前提是存在多個線程併發訪問(不全是讀)同一份共享數據,而會產生多線程安全問題的根本原因是多個線程間缺少一套用於協調各個線程間的數據訪問和行爲交互的機制,即缺少線程同步機制
多線程爲程序引入了異步行爲,相應的就必須提供一種線程同步機制來保障在需要時能夠強制多線程同步的方法。當多個線程間存在共享資源時,需要以某種方式來確保每次只有一個線程能夠使用資源。例如,如果希望兩個線程進行通信並共享某個複雜的數據結構(例如鏈表),就需要以某種方式來確保它們相互之間不會發生衝突。也就是說,當一個線程正在讀取該數據結構時,必須阻止另外一個線程向該數據結構寫入數據
Java 爲同步提供了語言級的支持,同步的關鍵是監視器,監視器是用作互斥鎖的對象。在給定時刻,只有一個線程可以擁有監視器。當線程取得鎖時,也就是進入了監視器。其它所有企圖進入加鎖監視器的線程都會被掛起,直到持有監視器的線程退出監視器
從廣義上來說,Java 平臺提供的線程同步機制包括:鎖、volatile、final、static 以及一些 API(Object.wait()、Object.notify() 等)
二、鎖的分類
既然線程安全問題的產生前提是存在多個線程併發訪問(不全是讀)共享數據,那麼爲了保障線程安全,我們就可以通過將多個線程對共享數據的併發訪問轉換爲串行訪問,從而來避免線程安全問題。將多個線程對共享數據的訪問限制爲串行訪問,即限制共享數據一次只能被一個線程訪問,該線程訪問結束後其它線程才能對其進行訪問
Java 就是通過這種思路提供了鎖(Lock) 這種線程同步機制來保障線程安全。鎖具有排他性,一次只能被一個線程持有(這裏所說的鎖不包含讀寫鎖這類共享鎖),這種鎖就被稱爲排他鎖或者互斥鎖。鎖的持有線程可以對鎖保護的共享數據進行訪問,訪問結束後持有線程就必須釋放鎖,以便其它線程能夠後續對共享數據進行訪問。鎖的持有線程在其獲得鎖之後和釋放鎖之前這段時間內所執行的代碼被稱爲臨界區。因此,臨界區一次只能被一個線程執行,共享數據只允許在臨界區內進行訪問
按照 Java 虛擬機對鎖的實現方式的劃分,Java 平臺中的鎖包括內部鎖和顯式鎖。內部鎖是通過 synchronize
關鍵字實現的。顯式鎖是通過 java.util.concurrent.locks.Lock
接口的實現類來實現的。內部鎖僅支持非公平調度策略,顯式鎖既支持公平調度策略也支持非公平調度策略
鎖能夠保護共享數據以實現線程安全,起的作用包括保障原子性、保障可見性和保障有序性
- 鎖通過互斥來保障原子性。鎖保證了臨界區代碼一次只能被一個線程執行,臨界區代碼被執行期間其它線程無法訪問相應的共享數據,從而排除了多個線程同時訪問共享變量從而導致競態的可能性,這使得臨界區所執行的操作具備了原子性。雖然實現併發是多線程編程的目標,但是這種併發往往是帶有局部串行
- 可見性的保障是通過寫線程沖刷處理器緩存和讀線程刷新處理器緩存這兩個動作實現的。在 Java 平臺。鎖的獲得隱含着刷新處理器緩存這個動作,這使得讀線程在獲得鎖之後且執行臨界區代碼之前,可以將寫線程對共享變量所做的更新同步到該線程執行處理器的高速緩存中;而鎖的釋放隱含着沖刷處理器緩存這個動作,這使得寫線程對共享變量所做的更新能夠被推送到該線程執行處理器的高速緩存中,從而對讀線程可見。因此,鎖能夠保障可見性
- 鎖能夠保障有序性。由於鎖對原子性和可見性的保障,使得鎖的持有線程對臨界區內對各個共享數據的更新同時對外部線程可見,相當於臨界區中執行的一系列操作在外部線程看來就是完全按照源代碼順序執行的,即外部線程對這些操作的感知順序與源代碼順序一致,所以說鎖保障了臨界區的有序性。儘管鎖能夠保障有序性,但臨界區內依然可能存在重排序,但臨界區代碼不會被重排序到臨界區之外,而臨界區之外的代碼有可能被重排序到臨界區之內
鎖的原子性及對可見性的保障合在一起,可保障臨界區內的代碼能夠讀取到共享數據的相對新值。再由於鎖的互斥性,同一個鎖所保護的共享數據一次只能被一個線程訪問,因此線程在臨界區中所讀取到的共享數據的相對新值同時也是最新值
需要注意的是,鎖對可見性、原子性和有序性的保障是有條件的,需要同時滿足以下兩個條件,否則就還是會存在線程安全問題
- 多個線程在訪問同一組共享數據的時候必須使用同一個鎖
- 即使是對共享數據進行只讀操作,其執行線程也必須持有相應的鎖
之所以需要保障以上兩個要求,是由於一旦某個線程進入了一個鎖句柄引導的同步方法/同步代碼塊,其它線程就都無法再進入同個鎖句柄引導的任何同步方法/同步代碼塊,但是仍然可以繼續調用其它非同步方法/非同步代碼塊,而如果非同步方法/非同步代碼塊也對共享數據進行了訪問,那麼此時依然會存在競態
1、內部鎖
Java 平臺中的任何一個對象都有一個唯一與之關聯的鎖,被稱爲監視器或者內部鎖。內部鎖是通過關鍵字 synchronize
實現的,可用來修飾實例方法、靜態方法、代碼塊等
synchronize
關鍵字修飾的方法就被稱爲同步方法,同步方法的整個方法體就是一個臨界區。用 synchronize
修飾的實例方法和靜態方法就分別稱爲同步實例方法和同步靜態方法
public class Test {
//同步靜態方法
public synchronized static void funName1() {
}
//同步方法
public synchronized void funName2() {
}
}
synchronize
關鍵字修飾的代碼塊就被稱爲同步塊。當中,lock
被稱爲鎖句柄。鎖句柄是對一個對象的引用,鎖句柄對應的監視器就稱爲相應同步塊的引導鎖
public class Test {
private final Object lock = new Object();
public void funName1() {
//同步塊
synchronized (lock) {
}
}
}
鎖句柄如果爲當前對象(this),那就相當於同步實例方法,如下兩個同步方法是等價的
public class Test {
public void funName1() {
synchronized (this) {
}
}
public synchronized void funName2() {
}
}
同步靜態方法則相當於以當前類對象爲引導鎖的同步塊,如下兩個同步方法是等價的
public class Test {
public synchronized static void funName1() {
}
public void funName2() {
synchronized (Test.class) {
}
}
}
作爲鎖句柄的變量通常使用 private final
修飾,這是因爲鎖句柄的變量一旦被改變,會導致執行同一個同步塊的多個線程實際上使用不同的鎖,從而導致競態
對於內部鎖來說,線程在執行臨界區內代碼之前必須獲得該臨界區的引導鎖,執行完後就會自動釋放引導鎖,引導鎖的申請和釋放是由 Java 虛擬機代爲執行的,這也是 synchronized
被稱爲內部鎖的原因。且由於 Java 編譯器對同步塊代碼的特殊處理,即使臨界區拋出異常,內部鎖也會被自動釋放,所以內部鎖不會導致鎖泄漏
2、顯式鎖
顯式鎖從 JDK 1.5 開始被引入 ,其作用與內部鎖相同,但相比內部鎖其功能會豐富很多。顯式鎖由 java.concurrent.locks.Lock
接口來定義,默認實現類是 java.util.concurrent.locks.ReentrantLock
Lock 的使用方式較爲靈活,可以在方法 A 內申請鎖,在方法 B 再進行釋放。其基本使用方式如下所示
private Lock lock = new ReentrantLock(false);
private void funName() {
//申請鎖
lock.lock();
try {
//action
} finally {
//釋放鎖
lock.unlock();
}
}
ReentrantLock 既支持公平調度策略也支持非公平調度策略,通過其一個參數的構造函數來指定,傳值爲 true 表示公平鎖,false 表示非公平鎖, 默認使用非公平調度策略。此外,由於虛擬機並不會自動爲我們釋放鎖,所以爲了避免鎖泄漏,一般會將 Lock.unlock()
方法放在 finally
中執行,以保證臨界區內的代碼不管是正常結束還是異常退出,相應的鎖釋放操作都會被執行
3、內部鎖和顯式鎖的比較
內部鎖是基於代碼塊的鎖
其缺點主要有以下幾點:
- 使用上缺少靈活性。鎖的申請和釋放操作被限制在一個代碼塊或者方法體內部
- 功能有限。例如,當一個線程申請某個正被其它線程持有的內部鎖時,該線程只能被暫停,等待鎖被釋放後再次申請,而無法取消申請或者是限時申請,且不支持線程中斷
- 僅支持非公平調度策略
其優點主要有以下幾點:
- 使用簡單
- 由於 Java 編譯器的保障,所以使用時不會造成鎖泄露,保障了安全性
顯式鎖是基於對象的鎖
其缺點主要有以下幾點:
- 需要開發者自己來保障不會發生鎖泄露
其優點主要有以下幾點:
- 相對內部鎖在使用上更具靈活性,可以跨方法來完成鎖的申請和釋放操作
- 功能相對內部鎖要豐富許多。例如,可以通過
Lock.isLocked()
判斷當前線程是否已經持有該鎖、通過Lock.tryLock()
嘗試申請鎖以避免由於鎖被其它線程持有而導致當前線程被暫停、通過Lock.tryLock(long,TimeUnit)
在指定時間範圍內嘗試申請鎖、Lock.lockInterruptibly()
支持線程中斷 - 同時支持公平調度策略和非公平調度策略
4、讀寫鎖
鎖的排他性使得多個線程無法以線程安全的方式在同一時刻對共享數據進行只讀取而不更新的操作,這在共享數據讀取頻繁但更新頻率較低的情況下降低了系統的併發性,讀寫鎖就是爲了應對這種問題而誕生的。讀寫鎖(Read/Wirte Lock)是一種改進型的排他鎖,也被稱爲共享/排他鎖。讀寫鎖允許多個線程同時讀取共享變量,但是一次只允許一個線程對共享變量進行更新。任何線程讀取共享變量的時候,其它線程無法更新這些變量;一個線程更新共享變量的時候,其它線程都無法讀取和更新這些變量
Java 平臺的讀寫鎖由 java.util.concurrent.locks.ReadWriteLock
接口來定義,其默認實現類是 java.util.concurrent.locks.ReentrantReadWriteLock
ReadWriteLock
接口定義了兩個方法,分別用來獲取讀鎖(ReadLock)和寫鎖(WriteLock)
public interface ReadWriteLock {
/**
* Returns the lock used for reading.
*
* @return the lock used for reading
*/
Lock readLock();
/**
* Returns the lock used for writing.
*
* @return the lock used for writing
*/
Lock writeLock();
}
讀線程在訪問共享變量的時候必須持有讀鎖,讀鎖是可以共享的,它可以同時被多個線程持有,提高了只讀操作的併發性。寫線程在訪問共享變量的時候必須持有寫鎖,寫鎖是排他的,即一個線程持有寫鎖的時候其它線程無法獲得同個讀寫鎖的讀鎖和寫鎖
讀寫鎖的使用方式與顯式鎖相似,也需要由開發者自己來保障避免鎖泄露
public class Test {
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Lock readLock = readWriteLock.readLock();
private final Lock writeLock = readWriteLock.writeLock();
public void reader() {
readLock.lock();
try {
//在此區域讀取共享變量
} finally {
readLock.unlock();
}
}
public void writer() {
writeLock.lock();
try {
//在此區域更新共享變量
} finally {
writeLock.unlock();
}
}
}
讀寫鎖在原子性、可見性和有序性保障方面,它所起的作用和普通的排他鎖是一樣的,但由於讀寫鎖內部實現比內部鎖和其它顯式鎖要複雜很多,因此讀寫鎖適合於在以下條件同時得以滿足的場景下使用:
- 只讀操作比寫操作要頻繁得多
- 讀線程持有鎖的時間比較長
只有同時滿足以上兩個條件的時候讀寫鎖纔是比較適合的,否則可能反而會比普通排他鎖增大性能開銷
此外,ReentrantReadWriteLock 支持鎖的降級,即一個線程持有寫鎖的同時可以繼續獲得相應的讀鎖。但 ReentrantReadWriteLock 不支持鎖的升級,即無法在持有讀鎖的同時獲得相應的寫鎖
5、內部鎖和讀寫鎖的性能比較
這裏,我們以一個簡單的例子來比較下內部鎖和讀寫鎖之間的性能差異。假設存在數量相等的讀線程和寫線程,讀線程負責打印出共享變量整數值 index 的當前值大小,寫線程負責對共享變量整數值 index 進行遞增加一。讀線程和寫線程各自有多個,每個線程間的行爲是互相獨立的。這裏分別通過使用“內部鎖”和“讀寫鎖”來規範每個線程的行爲必須是串行的,通過比較不同方式下所需的時間耗時來對比兩種鎖之間的性能高低
首先,Printer
接口定義了讀線程和寫線程需要做的行爲操作,ReadWriteLockPrinter
類是讀寫鎖方式的實現,SynchronizedPrinter
類是內部鎖方式的實現
/**
* 作者:leavesC
* 時間:2020/8/11 20:57
* 描述:
* GitHub:https://github.com/leavesC
*/
interface Printer {
fun read()
fun write()
fun sleep() {
Thread.sleep(200)
}
}
/**
* 作者:leavesC
* 時間:2020/8/11 20:58
* 描述:讀寫鎖
* GitHub:https://github.com/leavesC
*/
class ReadWriteLockPrinter : Printer {
private val readWriteLock = ReentrantReadWriteLock(true)
private val readLock = readWriteLock.readLock()
private val writeLock = readWriteLock.writeLock()
private var index = 0
override fun read() {
readLock.lock()
try {
sleep()
} finally {
println("讀取到數據: $index" + ",time: " + System.currentTimeMillis())
readLock.unlock()
}
}
override fun write() {
writeLock.lock()
try {
sleep()
index++
} finally {
println("寫入數據: $index" + ",time: " + System.currentTimeMillis())
writeLock.unlock()
}
}
}
/**
* 作者:leavesC
* 時間:2020/8/11 20:58
* 描述:內部鎖
* GitHub:https://github.com/leavesC
*/
class SynchronizedPrinter : Printer {
private var index = 0
@Synchronized
override fun read() {
sleep()
println("讀取到數據: $index" + ",time: " + System.currentTimeMillis())
}
@Synchronized
override fun write() {
sleep()
index++
println("寫入數據: $index" + ",time: " + System.currentTimeMillis())
}
}
再來定義讀線程和寫線程,兩種線程使用的是同個 Printer 對象
/**
* 作者:leavesC
* 時間:2020/8/11 21:00
* 描述:
* GitHub:https://github.com/leavesC
*/
class PrinterReadThread(private val printer: Printer) : Thread() {
override fun run() {
printer.read()
}
}
class PrinterWriteThread(private val printer: Printer) : Thread() {
override fun run() {
printer.write()
}
}
通過切換不同的 Printer 實現即可大致對比不同的鎖的性能高低
/**
* 作者:leavesC
* 時間:2020/8/11 21:01
* 描述:
* GitHub:https://github.com/leavesC
*/
fun main() {
val printer: Printer = SynchronizedPrinter()
// val printer: Printer = ReadWriteLockPrinter()
val threadNum = 10
val writeThreadList = mutableListOf<Thread>()
for (i in 1..threadNum) {
writeThreadList.add(PrinterWriteThread(printer))
}
val readThreadList = mutableListOf<Thread>()
for (i in 1..threadNum) {
readThreadList.add(PrinterReadThread(printer))
}
//啓動所有讀線程和所有寫線程
writeThreadList.forEach {
it.start()
}
readThreadList.forEach {
it.start()
}
}
最後的日誌輸出類似如下所示。雖然即使多次運行來取平均值也不具備嚴格的對比意義,但是也可以大致對比出不同鎖之間的性能高低。從日誌也可以看出,當使用讀寫鎖時多個讀線程讀取數據所需要的總耗時幾乎是零
# 內部鎖 消耗 3801 毫秒
寫入數據: 1,time: 1597151018862
讀取到數據: 1,time: 1597151019062
讀取到數據: 1,time: 1597151019262
讀取到數據: 1,time: 1597151019462
讀取到數據: 1,time: 1597151019662
讀取到數據: 1,time: 1597151019862
讀取到數據: 1,time: 1597151020062
讀取到數據: 1,time: 1597151020262
讀取到數據: 1,time: 1597151020462
讀取到數據: 1,time: 1597151020662
讀取到數據: 1,time: 1597151020862
寫入數據: 2,time: 1597151021062
寫入數據: 3,time: 1597151021262
寫入數據: 4,time: 1597151021462
寫入數據: 5,time: 1597151021663
寫入數據: 6,time: 1597151021863
寫入數據: 7,time: 1597151022063
寫入數據: 8,time: 1597151022263
寫入數據: 9,time: 1597151022463
寫入數據: 10,time: 1597151022663
# 讀寫鎖 消耗 2000 毫秒
寫入數據: 1,time: 1597151078704
寫入數據: 2,time: 1597151078904
寫入數據: 3,time: 1597151079104
寫入數據: 4,time: 1597151079304
寫入數據: 5,time: 1597151079504
寫入數據: 6,time: 1597151079704
寫入數據: 7,time: 1597151079904
寫入數據: 8,time: 1597151080104
寫入數據: 9,time: 1597151080304
寫入數據: 10,time: 1597151080504
讀取到數據: 10,time: 1597151080704
讀取到數據: 10,time: 1597151080704
讀取到數據: 10,time: 1597151080704
讀取到數據: 10,time: 1597151080704
讀取到數據: 10,time: 1597151080704
讀取到數據: 10,time: 1597151080704
讀取到數據: 10,time: 1597151080704
讀取到數據: 10,time: 1597151080704
讀取到數據: 10,time: 1597151080704
讀取到數據: 10,time: 1597151080704
6、鎖的開銷
鎖的開銷主要包含幾點:
- 上下文切換與線程調度開銷。一個線程在申請已經被其它線程持有的鎖時,該線程就有可能會被暫停運行,直到鎖被釋放後被該線程申請到,也有可能不會被暫停運行,而是採用忙等策略直到鎖被釋放。如果申請鎖的線程被暫停,Java 虛擬機就需要爲被暫停的線程維護一個等待隊列,以便後續鎖的持有線程釋放鎖時將這些線程喚醒。線程的暫停與喚醒就是一個上下文切換的過程,並且 Java 虛擬機維護等待隊列也是有着一定消耗。如果是非爭用鎖則不會產生上下文切換和等待隊列的開銷
- 內存同步、編譯器優化受限的開銷。鎖的底層實現需要使用到內存屏障,而內部屏障會產生直接和間接的開銷。直接開銷是內存屏障所的沖刷寫處理器、清空無效化隊列等行爲所導致的開銷。間接開銷包含:禁止部分代碼重排序從而阻礙編譯器優化。無論是爭用鎖還是非爭用鎖都會產生這部分開銷,但如果非爭用鎖最終可以被採用鎖消除技術進行優化的話,那麼就可以消除掉這個鎖帶來的開銷
- 限制可伸縮性。採用鎖的目的是使得多個線程間的併發改爲帶有局部串行的併發,實現這個目的後帶來的副作用就是使得系統的局部計算行爲(同步代碼塊)的吞吐率降低,限制系統的可伸縮性,導致處理器資源的浪費
三、wait / notify
在單線程編程中,如果程序要執行的操作需要滿足一定的運行條件後纔可以執行,那麼我們可以將目標操作放到一個 if 語句中,讓目標操作只有在運行條件得以滿足時纔會被執行
而在多線程編程中,目標操作的運行條件可能涉及到多個線程間的共享變量,即運行條件可能是由多個線程來共同決定的。對於目標操作的執行線程來說,運行條件可能只是暫時未滿足的,其它線程可能在稍後就會更新運行條件涉及的共享變量從而使得運行條件成立。因此,我們可以選擇將當前線程暫停,等待其它線程更新了共享變量使得運行條件成立後,再由其它線程來將被暫停的線程喚醒以便讓其執行目標操作
當中,一個線程因爲要執行的目標動作所需的保護條件未滿足而被暫停的過程就被稱爲等待(wait)。一個線程更新了共享變量,使得其它線程所需的保護條件得以滿足並喚醒那些被暫停的線程的過程就被稱爲通知(notify)
在 Java 平臺上,以下兩類方法可用於實現等待和通知,Object 可以是任何對象。由於等待線程和通知線程在實現等待和通知的時候必須是調用同一個對象的 wait、notify 方法,且其執行線程必須持有該對象的內部鎖,所以等待線程和通知線程是同步在同一個對象上的兩種線程
- Object.wait()/Object.wait(long)。這兩個方法的作用是使其執行線程暫停,生命週期變爲 WAITING,可用於實現等待。其執行線程就被稱爲等待線程
- Object.notify()/Object.notifyAll()。這兩個方法的作用是喚醒一個或多個被暫停的線程,可用於實現通知。其執行線程就被稱爲通知線程
1、wait
使用 Object.wait()
實現等待,其代碼模板如以下僞代碼所示:
//在調用 wait 方法前獲得相應對象的內部鎖
synchronized(someObject){
while(保護條件不成立){
//調用 wait 方法暫停當前線程,並同時釋放已持有的鎖
someObject.wait()
}
//能執行到這裏說明保護條件已經滿足
//執行目標動作
doAction()
}
當中,保護條件是一個包含共享變量的布爾表達式
當保護條件不成立時,因執行 someObject.wait()
而被暫停的線程就被稱爲對象 someObject 上的等待線程。由於一個對象的 wait()
方法可以被多個線程執行,因此一個對象可能存在多個等待線程。此外,由於一個線程只有在持有一個對象的內部鎖的情況下才能夠調用該對象的 wait()
方法,因此 Object.wait()
總是放在相應對象所引導的臨界區之中。someObject.wait()
會以原子操作的方式使其執行線程(即等待線程)暫停並使該線程釋放其持有的 someObject 對應的內部鎖。當等待線程被暫停的時候其對 someObject.wait()
方法的調用並不會返回,只有當等待線程被通知線程喚醒且重新申請到 someObject 對應的內部鎖時,纔會繼續執行 someObject.wait()
內部剩餘的指令,這時 wait()
纔會返回
當等待線程被喚醒時,等待線程在其被喚醒繼續運行到其再次申請到相應對象的內部鎖的這段時間內,其它線程有可能會搶先獲得相應的內部鎖並更新了相關共享變量導致保護條件再次不成立,因此 someObject.wait()
調用返回之後我們需要再次判斷此時保護條件是否成立。所以,對保護條件的判斷以及 someObject.wait()
的調用應該放在循環語句之中,以確保目標動作一定只在保護條件成立的情況下才會被執行
此外,等待線程對保護條件的判斷以及目標動作的執行必須是原子操作,否則可能產生競態,即目標動作被執行前的那一刻其它線程可能對共享變量進行了更新又使得保護條件重新不成立。因此,保護條件的判斷、目標動作的執行、Object.wait() 的調用都必須放在同一個對象所引導的臨界區中
2、notify
使用 Object.notify()
實現通知,其代碼模板如以下僞代碼所示:
synchronized(someObject){
//更新等待線程的保護條件涉及的共享變量
updateSharedState()
//喚醒等待線程
someObject.notify()
}
由於只有在持有一個對象的內部鎖的情況下才能夠執行該對象的 notify()
方法,所以 Object.notify()
方法也總是放在相應對象內部鎖所引導的臨界區之內。也正因爲如此, Object.wait()
在暫停其執行線程的同時也必須釋放 Object 的內部鎖,否則通知線程就永遠也無法來喚醒等待線程。和 Object.wait()
不同,Object.notify()
方法本身並不會釋放內部鎖,只有在其所在的臨界區代碼執行結束後纔會被釋放。因此,爲了使得等待線程在被喚醒後能夠儘快獲得相應的內部鎖,我們要儘量將 Object.notify()
代碼放在靠近臨界區結束的地方,否則如果 Object.notify()
喚醒了等待線程而通知線程又遲遲不釋放內部鎖,就有可能導致等待線程再次經歷上下文切換,從而浪費系統資源
調用 Object.notify()
所喚醒的線程僅是 Object 對象上的任意一個等待線程,所以被喚醒的線程有可能並不是我們真正想要喚醒的線程。因此,有時我們需要改用 Object.notifyAll()
方法,該方法可以喚醒 Object 上的所有等待線程。被喚醒的線程就都有了搶奪相應 Object 對象的內部鎖的機會。而如果被喚醒的線程在佔用處理器繼續運行後且申請到內部鎖之前,有其它線程(被喚醒的等待線程之一或者是新到來的線程)先持有了內部鎖,那麼這個被喚醒的線程可能又會再次被暫停,等待再次被喚醒的機會,而這個過程會導致上下文切換
wait/notify 機制也被應用於 Thread 類內部。例如,Thread.join()
方法提供了在某個線程運行結束前暫停該方法調用者線程的功能,內部也使用到 wait()
方法來暫停調用者線程,等到線程終止後 JVM 內部就會通過 notifyAll()
方法來喚醒所有等待線程
public final synchronized void join(long millis) throws InterruptedException {
···
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
···
}
}
3、wait / notify 存在的問題
用 wait / notify 實現的等待和通知可能會遇到以下兩個問題:
- 過早喚醒。假設存在多個等待線程同步在對象 someObject 上,每個等待線程的運行保護條件並不完成相同。當通知線程更新了某個等待線程的運行保護條件涉及的共享變量並使之成立時,由於
someObject.notify()
方法具體會喚醒哪個線程對於開發者來說是不可預知的,所以我們只能使用someObject.notifyAll()
方法,此時就會導致那些運行條件還不成立的等待線程也被喚醒,這種現象就叫做過早喚醒。過早喚醒會使得那些運行條件還不滿足的等待線程也被喚醒運行,當這些線程再次判斷到當前運行條件不滿足時又會再次調用someObject.wait()
方法暫停 - 多次的線程上下文切換。對於一次完整的 wait 和 notify 過程,等待線程執行
someObject.wait()
方法至少會導致等待線程對相應內部鎖的兩次申請和兩次釋放,通知線程執行someObject.notify()
方法則會導致通知線程對相應內部鎖的一次申請和一次釋放。每個線程每次鎖的申請與釋放操作都對應着一次線程上下文切換
4、生產者與消費者
wait 和 notify 兩個方法的協作可以通過一個經典的問題來展示:生產者與消費者問題。對於一家商店來說,其能承載的商品最大數是固定的,最多爲 MAX_COUNT。存在多個生產者爲商店生產商品,生產者生產商品不能使得商店內的商品數量超出 MAX_COUNT。存在多個消費者從商店消費商品,消費者消費過後最低使商店的商品總數變爲零。生產者只有當商店內的商品總數小於 MAX_COUNT 時才能繼續生產,消費者只有當商店內的商品總數大於零時才能進行消費。因此,當商店內的商品總數小於 MAX_COUNT 時需要通知生產者開始生產,否則需要暫停生產。當商店內的商品總數大於零時需要通知消費者來消費,否則需要暫停消費
上述的生產者與消費者分別對應多個線程,商店就相當於多個線程間的共享數據。生產者線程的運行保護條件是:當前商品總數不能大於等於 MAX_COUNT。消費者線程的運行保護條件是:當前商品總數要大於 0
/**
* 作者:leavesC
* 時間:2020/8/11 10:16
* 描述:
* GitHub:https://github.com/leavesC
*/
private val LOCK = Object()
private const val MAX_COUNT = 10
class Shop(var goodsCount: Int)
fun main() {
val shop = Shop(0)
val producerSize = 4
val consumerSize = 8
for (i in 1..producerSize) {
Producer(shop, "生產者-${i}").apply {
start()
}
}
for (i in 1..consumerSize) {
Consumer(shop, "消費者-${i}").apply {
start()
}
}
}
class Producer(private val shop: Shop, name: String) : Thread(name) {
override fun run() {
while (true) {
Tools.randomSleep()
synchronized(LOCK) {
while (shop.goodsCount >= MAX_COUNT) {
println("${name}-商品總數已達到最大數量,停止生產")
LOCK.wait()
}
val number = Tools.randomInt(1, MAX_COUNT - shop.goodsCount)
shop.goodsCount = shop.goodsCount + number
println("$name ===== 新增了 $number 件商品,當前剩餘: ${shop.goodsCount}")
LOCK.notifyAll()
}
}
}
}
class Consumer(private val shop: Shop, name: String) : Thread(name) {
override fun run() {
while (true) {
Tools.randomSleep()
synchronized(LOCK) {
while (shop.goodsCount <= 0) {
println("${name}-商品已經被消費光了,停止消費")
LOCK.wait()
}
val number = Tools.randomInt(1, shop.goodsCount)
shop.goodsCount = shop.goodsCount - number
println("$name ---- 消費了 $number 件商品,當前剩餘: ${shop.goodsCount}")
LOCK.notifyAll()
}
}
}
}
其運行結果類似於如下所示
生產者-2 ===== 新增了 7 件商品,當前剩餘: 7
消費者-7 ---- 消費了 4 件商品,當前剩餘: 3
消費者-7 ---- 消費了 2 件商品,當前剩餘: 1
消費者-8 ---- 消費了 1 件商品,當前剩餘: 0
生產者-4 ===== 新增了 2 件商品,當前剩餘: 2
消費者-2 ---- 消費了 1 件商品,當前剩餘: 1
生產者-1 ===== 新增了 8 件商品,當前剩餘: 9
消費者-1 ---- 消費了 3 件商品,當前剩餘: 6
消費者-1 ---- 消費了 2 件商品,當前剩餘: 4
消費者-3 ---- 消費了 1 件商品,當前剩餘: 3
消費者-1 ---- 消費了 2 件商品,當前剩餘: 1
消費者-5 ---- 消費了 1 件商品,當前剩餘: 0
消費者-8-商品已經被消費光了,停止消費
消費者-7-商品已經被消費光了,停止消費
生產者-3 ===== 新增了 1 件商品,當前剩餘: 1
消費者-7 ---- 消費了 1 件商品,當前剩餘: 0
消費者-8-商品已經被消費光了,停止消費
消費者-2-商品已經被消費光了,停止消費
生產者-2 ===== 新增了 3 件商品,當前剩餘: 3
消費者-2 ---- 消費了 2 件商品,當前剩餘: 1
消費者-8 ---- 消費了 1 件商品,當前剩餘: 0
消費者-6-商品已經被消費光了,停止消費
······
四、線程同步工具類
1、Condition
wait/notify 存在過早喚醒、可能多次線程上下文切換次數、無法區分 Obejct.wait(long)
方法在返回時是由於超時還是由於線程被喚醒等一系列問題,可以使用 JDK 1.5 開始引入的 java.util.concurrent.locks.Condition
接口來解決這些問題
Condition 接口定義的 await()、singal()、singalAll() 等方法相當於 Object.wait()、Object.notify()、Object.notifyAll()。Object.wait()/notify() 方法要求其執行線程必須持有相應對象的內部鎖,類似的,Condition.await()/singal() 也要求其執行線程必須持有創建該 Condition 實例的顯式鎖
使用 Condition 實現等待和通知,其代碼模板如以下僞代碼所示。Lock.newCondition()
方法的返回值就是一個 Condition 實例。每個 Condition 實例內部都維護了一個用於存儲等待線程的隊列,Condition.await()
方法的執行線程會被暫停並存入等待隊列中。Condition.notify()
方法會分別使等待隊列中的一個線程被喚醒,而用同一個 Lock 創建的其它 Condition 實例中的等待線程並不會收到影響。這就使得我們可以精準喚醒目標線程,避免過早喚醒,減少線程上下文切換的次數
class ConditionDemo {
private val lock = ReentrantLock()
private val conditionA = lock.newCondition()
private val conditionB = lock.newCondition()
fun waitA() {
lock.lock()
try {
while (運行保護條件A不成立) {
conditionA.await()
}
//執行目標操作
doAction()
} finally {
lock.unlock()
}
}
fun notifyA() {
lock.lock()
try {
//更新等待線程的保護條件涉及的共享變量
updateSharedStateA()
//喚醒等待線程
conditionA.signal()
} finally {
lock.unlock()
}
}
fun waitB() {
lock.lock()
try {
while (運行保護條件B不成立) {
conditionB.await()
}
//執行目標操作
doAction()
} finally {
lock.unlock()
}
}
fun notifyB() {
lock.lock()
try {
//更新等待線程的保護條件涉及的共享變量
updateSharedStateB()
//喚醒等待線程
conditionB.signal()
} finally {
lock.unlock()
}
}
}
這裏通過設計一個簡單的阻塞隊列來演示下 Condition 的用法
在 Queue 中,Lock 保障了 put 操作和 take 操作的線程安全性,兩個不同的 Condition 實例又保障了 putThread 和 takeThread 在各自運行保護條件成立時,可以只喚醒相應的線程
class Queue<T> constructor(private val size: Int) {
private val lock = ReentrantLock()
//當隊列已滿時,put thread 就成爲 notFull 上的等待線程
private val notFull = lock.newCondition()
//當隊列爲空時,take thread 就成爲 notEmpty 上的等待線程
private val notEmpty = lock.newCondition()
private val items = mutableListOf<T>()
fun put(x: T) {
lock.lock()
try {
while (items.size == size) {
println("當前隊列已滿,暫停 put 操作...")
notFull.await()
}
println("當前隊列未滿,執行 put 操作...")
items.add(x)
//喚醒 TakeThread
notEmpty.signal()
} finally {
lock.unlock()
}
}
fun take(): T {
lock.lock()
try {
while (items.size == 0) {
println("當前隊列爲空,暫停 take 操作...")
notEmpty.await()
}
println("當前隊列不爲空,執行 take 操作...")
val x = items[0]
items.removeAt(0)
//喚醒 PutThread
notFull.signal()
return x
} finally {
lock.unlock()
}
}
}
PutThread 負責循環向阻塞隊列存入八條數據,TakeThread 負責循環從阻塞隊列獲取九條數據,TakeThread 隨機休眠的時間相對 PutThread 會更長,而阻塞隊列的隊長爲四,那麼在程序運行過程中大概率可以看到由於阻塞隊列已滿從而導致 PutThread 被暫停的現象,且程序運行到最後 TakeThread 會爲了獲取第九條數據而一直處於等待狀態
class PutThread(private val intQueue: Queue<Int>) : Thread() {
override fun run() {
for (i in 1..8) {
sleep(Random.nextLong(1, 50))
intQueue.put(i)
}
}
}
class TakeThread(private val intQueue: Queue<Int>) : Thread() {
override fun run() {
for (i in 1..9) {
sleep(Random.nextLong(10, 100))
println("TakeThread get value: " + intQueue.take())
}
}
}
/**
* 作者:leavesC
* 時間:2020/8/15 17:11
* 描述:
* GitHub:https://github.com/leavesC
*/
fun main() {
val intQueue = Queue<Int>(4)
val putThread = PutThread(intQueue)
val takeThread = TakeThread(intQueue)
putThread.start()
takeThread.start()
}
從程序的輸出結果可以看到 TakeThread 最終將一直處於等待狀態
當前隊列未滿,執行 put 操作...
當前隊列未滿,執行 put 操作...
當前隊列未滿,執行 put 操作...
當前隊列不爲空,執行 take 操作...
TakeThread get value: 1
當前隊列未滿,執行 put 操作...
當前隊列未滿,執行 put 操作...
當前隊列已滿,暫停 put 操作...
當前隊列不爲空,執行 take 操作...
TakeThread get value: 2
當前隊列未滿,執行 put 操作...
當前隊列不爲空,執行 take 操作...
TakeThread get value: 3
當前隊列未滿,執行 put 操作...
當前隊列不爲空,執行 take 操作...
TakeThread get value: 4
當前隊列未滿,執行 put 操作...
當前隊列不爲空,執行 take 操作...
TakeThread get value: 5
當前隊列不爲空,執行 take 操作...
TakeThread get value: 6
當前隊列不爲空,執行 take 操作...
TakeThread get value: 7
當前隊列不爲空,執行 take 操作...
TakeThread get value: 8
當前隊列爲空,暫停 take 操作...
2、CountDownLatch
有時候會存在某個線程需要等待其它線程完成特定操作後才能繼續運行的需求,此時使用 Object.wait()
和 Object.notify()
也可以滿足需求,但是使用上會比較繁瑣,此時可以考慮通過 CountDownLatch 來實現
CountDownLatch 可用來實現一個或多個線程等待其它線程完成特定操作後才繼續運行的功能,這組操作被稱爲先決條件。CountDownLatch 內部會維護一個用於標記需要等待完成的先決條件的數量的計數器,當每個先決條件完成時,先決條件的執行線程就通過調用 CountDownLatch.countDown()
來使計算器減一。而 CountDownLatch.await()
就相當於一個受保護方法,其保護條件爲“計算器值爲零”,當計算器值不爲零時,調用了 CountDownLatch.await()
方法的執行線程都會被暫停。當所有先決條件都完成時,即當“計算器值爲零”的保護條件成立時,CountDownLatch 上的所有等待線程就都會被喚醒,繼續運行
當計數器的值達到 0 之後,該計數器的值就不再發生變化,後續繼續調用 CountDownLatch.countDown()
也不會導致拋出異常,且再次調用 CountDownLatch.await()
方法也不會導致線程被暫停。因此,一個 CountDownLatch 實例只能用來實現一次等待和一次通知
來看一個簡單的例子。假設在程序啓動時需要確保三個基礎服務(ServiceA、ServiceB、ServiceC)先被初始化完成,且爲了加快初始化速度,每個基礎服務均交由一個工作者線程來完成初始化任務。此時就可以通過 CountDownLatch 來保證 main 線程一直處於等待狀態直到所有的工作者線程的任務均結束(不管初始化成功還是失敗)
/**
* 作者:leavesC
* 時間:2020/8/12 14:31
* 描述:
* GitHub:https://github.com/leavesC
*/
fun main() {
val serviceManager = ServicesManager()
serviceManager.startServices()
println("等待所有 Services 執行完畢")
val allSuccess = serviceManager.checkState()
println("執行結果: $allSuccess")
}
class ServicesManager {
private val countDownLatch = CountDownLatch(3)
private val serviceList = mutableListOf<AbstractService>()
init {
serviceList.add(ServiceA("ServiceA", countDownLatch))
serviceList.add(ServiceB("ServiceB", countDownLatch))
serviceList.add(ServiceC("ServiceC", countDownLatch))
}
fun startServices() {
serviceList.forEach {
it.start()
}
}
fun checkState(): Boolean {
countDownLatch.await()
return serviceList.find { !it.checkState() } == null
}
}
abstract class AbstractService(private val countDownLatch: CountDownLatch) {
private var success = false
abstract fun doTask(): Boolean
fun start() {
thread {
try {
success = doTask()
} finally {
countDownLatch.countDown()
}
}
}
fun checkState(): Boolean {
return success
}
}
class ServiceA(private val serviceName: String, countDownLatch: CountDownLatch) : AbstractService(countDownLatch) {
override fun doTask(): Boolean {
Thread.sleep(2000)
println("${serviceName}執行完畢")
return true
}
}
class ServiceB(private val serviceName: String, countDownLatch: CountDownLatch) : AbstractService(countDownLatch) {
override fun doTask(): Boolean {
Thread.sleep(4000)
println("${serviceName}執行完畢")
return true
}
}
class ServiceC(private val serviceName: String, countDownLatch: CountDownLatch) : AbstractService(countDownLatch) {
override fun doTask(): Boolean {
Thread.sleep(3000)
if (Random.nextBoolean()) {
throw RuntimeException("$serviceName failed")
} else {
println("${serviceName}執行完畢")
}
return true
}
}
ServiceC 會隨機拋出異常,所以程序的運行結果會分爲以下兩種可能。且爲了保證當任務失敗時 main 線程也可以收到喚醒通知,需要確保 CountDownLatch.countDown()
是放在 finally 代碼塊中
# 成功的情況
等待所有 Services 執行完畢
ServiceA執行完畢
ServiceC執行完畢
ServiceB執行完畢
執行結果: true
# 失敗的情況
等待所有 Services 執行完畢
ServiceA執行完畢
Exception in thread "Thread-2" java.lang.RuntimeException: ServiceC failed
at thread.ServiceC.doTask(CountDownLatchTest.kt:93)
at thread.AbstractService$start$1.invoke(CountDownLatchTest.kt:55)
at thread.AbstractService$start$1.invoke(CountDownLatchTest.kt:46)
at kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)
ServiceB執行完畢
執行結果: false
3、CyclicBarrier
JDK 1.5 引入了 java.util.concurrent.CyclicBarrier
類用於實現多個線程間的相互等待。CyclicBarrier 可用於這麼一種場景:假設存在一個集合點,在所有線程均執行到集合點之前,每個執行到指定集合點的線程均會被暫停。當所有線程均執行到指定集合點時,即當最後一個線程執行到集合點時,所有被暫停的線程都會自動被喚醒並繼續執行
CyclicBarrier 的字面意思可以理解爲:可循環使用的屏障。而集合點就相當於一個“屏障”,除非所有線程都抵達到了屏障,否則每個到達的線程都會被拒之門外(即被暫停運行)。而當最後一個線程到來時,“屏障”就會自動消失,即最後一個到來的線程不會被暫停,而是繼續向下執行代碼,同時喚醒所有其它之前被暫停的線程
CyclicBarrier 類涉及到的線程數量可以通過其構造參數 parties 來指定,CyclicBarrier.await()
方法就用於標記當前線程執行到了指定集合點。在功能上 CyclicBarrier 與 CountDownLatch 相似,但 CyclicBarrier 實例是可以重複使用的,在所有線程都被喚醒之後,任何線程再次執行 CyclicBarrier.await()
方法又會被暫停,直到最後一個線程也執行了該方法。所以,CyclicBarrier.await()
方法既是等待方法也是通知方法,最後一個執行線程就相當於通知線程,其它線程就相當於等待線程,線程的具體類別由其運行時序來動態區分,而非靠調用方法的不同
再來看一個簡單的例子。存在三個輸出不同字符串內容的 PrintThread 線程,每個線程每輸出一次,均需要等待其它線程也輸出一次後才能再次輸出,但三個線程每次的輸出先後順序可以隨意
class PrintThread(private val cyclicBarrier: CyclicBarrier, private val content: String) : Thread() {
override fun run() {
while (true) {
sleep(Random.nextLong(300, 1000))
println("打印完成:${content}")
if (cyclicBarrier.parties == cyclicBarrier.numberWaiting + 1) {
println()
}
cyclicBarrier.await()
}
}
}
/**
* 作者:leavesC
* 時間:2020/8/15 20:57
* 描述:
* GitHub:https://github.com/leavesC
*/
fun main() {
val threadNum = 3
val cyclicBarrier = CyclicBarrier(threadNum)
val threadList = mutableListOf<Thread>()
for (i in 1..threadNum) {
threadList.add(PrintThread(cyclicBarrier, "index_$i"))
}
threadList.forEach {
it.start()
}
}
程序的輸出結果類似如下所示。每一輪輸出的三條數據先後順序並不固定,但每一輪的內容一定不會重複
打印完成:index_2
打印完成:index_1
打印完成:index_3
打印完成:index_2
打印完成:index_1
打印完成:index_3
打印完成:index_2
打印完成:index_1
打印完成:index_3
打印完成:index_3
打印完成:index_1
打印完成:index_2
打印完成:index_1
打印完成:index_3
打印完成:index_2
...
4、Semaphore
Semaphore 可用於實現互斥以及流量控制,在某些資源有限的場景下限制可以同時訪問資源的最大線程數。例如,假設當前有幾十上百個線程需要連接數據庫進行數據存取,而數據庫支持的最大連接數只有十個,此時就可以通過 Semaphore 來限制線程的最大併發數,當已經有十個線程連接到了數據庫時,多餘的請求線程就會被暫停
看個簡單的例子。對於以下代碼,線程池同時發起的請求有三十個,而 Semaphore 限制了最大線程併發數是四個,所以最終的輸出結果就是會每隔兩秒輸出四行內容
/**
* 作者:leavesC
* 時間:2020/9/1 11:41
* 描述:
* GitHub:https://github.com/leavesC
*/
fun main() {
val threadNum = 30
val threadPool = Executors.newFixedThreadPool(threadNum)
val semaphore = Semaphore(4)
for (index in 1..threadNum) {
threadPool.execute {
semaphore.acquire()
try {
Thread.sleep(2000)
println("over $index")
} finally {
semaphore.release()
}
}
}
}
5、Exchanger
Exchanger 可用於實現在兩個線程之間交換數據的功能。當線程 A 先通過 Exchanger 發起交換數據的請求時,線程 A 會被暫停運行直到線程 B 也發起交換數據的請求,當數據交換完成後,兩個線程就會各自繼續運行
/**
* 作者:leavesC
* 時間:2020/9/1 14:41
* 描述:
* GitHub:https://github.com/leavesC
*/
fun main() {
val exchanger = Exchanger<String>()
val threadA = object : Thread() {
override fun run() {
sleep(2000)
val result = exchanger.exchange("A")
println("Thread A: $result")
}
}
val threadB = object : Thread() {
override fun run() {
sleep(2000)
val result = exchanger.exchange("B")
println("Thread B: $result")
}
}
threadA.start()
threadB.start()
}
Thread B: A
Thread A: B
五、線程中斷機制
以上介紹的幾種線程間協作方法的使用初衷都是希望線程完成各自操作後能互相通知。可是還存在這麼一種情況:一個線程請求另外一個線程停止其正在執行的任務。例如,對於一個地圖應用來說,當用戶退出應用時,就需要停止後臺線程正在執行的定位任務,因爲此時該任務對於用戶來說是不需要的了
一個線程向另一個線程發起請求,希望其停止任務的機制就稱爲線程中斷機制。中斷(interrupt)是由發起線程向目標線程發送的一種指示,該指示用於表示發起線程希望目標線程停止其正在執行的任務。發起線程的中斷請求並不是一個強制性的行爲,目標線程可能會在收到中斷指示時停止任務,也可能完全不做任何響應,這取決於目標線程對中斷請求的處理邏輯
Java 平臺會爲每個線程維護一個被稱爲中斷標記的布爾型變量來表示相應線程是否收到了中斷,值爲 true 則表示收到了中斷請求。Thread 類包含以下幾個和中斷相關的方法
public class Thread implements Runnable {
//向此線程發起中斷請求
public void interrupt() {
...
}
//在獲取中斷標記的同時將中斷標記置爲 false
public static boolean interrupted() {
...
}
//獲取中斷標記
public boolean isInterrupted() {
...
}
}
目標線程收到中斷請求後所執行的操作,被稱爲目標線程對中斷的響應,簡稱中斷響應。目標線程對中斷的響應類型一般包括:
- 無影響。例如,
ReentrantLock.lock()
或者內部鎖申請等操作時,都不會對中斷進行響應,即不會停止當前正在執行的操作 - 取消任務的運行。例如,目標線程可以在每次執行任務前均檢查中斷標記,當中斷標記爲 true 時則取消當前任務,但還是會繼續處理其他任務
- 停止線程。即令目標線程放棄執行所有任務,生命週期狀態變更爲 TERMINATED
Java 標準庫中的許多阻塞方法對中斷的響應都是拋出 InterruptedException 等異常。能夠響應中斷的方法通常是在執行阻塞操作前判斷中斷標記,若中斷標記爲 true 則直接拋出 InterruptedException。例如,ReentrantLock.lockInterruptibly()
方法會在執行申請鎖這個阻塞操作前檢查當前線程的中斷標記,當中斷標記爲 true 時則會拋出 InterruptedException。而按照慣例,拋出 InterruptedException 異常的方法一般都會在拋出該異常之前將當前線程的中斷標記重置爲 false。例如,ReentrantLock.lockInterruptibly()
方法會通過在 acquireInterruptibly()
方法裏調用 Thread.interrupted()
來獲取並重置中斷標記
public final void acquireInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}
如果目標線程在收到中斷請求的時候已經由於執行了一些阻塞操作而處於暫停狀態,那麼 Java 虛擬機可能會將目標線程喚醒,從而使得目標線程被喚醒後繼續執行的代碼可以再次得到響應中斷的機會
六、如何實現單例模式
單例模式是 GOF 設計模式中比較容易理解且應用非常廣泛的一種設計模式,但是實現一個能夠在多線程環境下正常運行且兼顧到性能的單例模式卻不是一個簡單的事情,這需要我們同時運用到鎖、volatile 變量、原子性、可見性、有序性等多方面的知識
1、單線程環境
在單線程環境下,我們無需考慮原子性、可見性、有序性等問題,所以僅需要做到懶加載即可
public final class Singleton {
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) { //操作1
instance = new Singleton(); //操作2
}
return instance;
}
}
2、雙重檢查鎖定
對於上述的在單線程環境下可以正常使用的單例模式,在多線程環境下就很容易出現問題。getInstance()
方法本身是基於 check-then-act 操作來判斷是否需要初始化共享變量的,該操作並不是一個原子操作。在 instance 還爲 null 時,假設有兩個線程 T1 和 T2 同時執行到操作1,接着在 T1 執行操作2之前 T2 已經執行完操作2,在下一時刻,當 T1 執行到操作2的時候,即使 instance 當前已經不爲 null,但是 T1 此時依然會多創建一個實例,這就導致了多個實例的創建
首先,我們最先想到的可能是通過加鎖來避免這種情況
public static Singleton getInstance() {
synchronized (Singleton.class){
if (instance == null) {
instance = new Singleton();
}
}
return instance;
}
上述方式實現的單例模式固然是線程安全的,但是這也意味着 getInstance()
方法的任何一個執行線程都需要申請鎖,爲了避免無謂的鎖開銷,人們又想到以下這種方法,即雙重檢查鎖定。在執行臨界區代碼前先判斷 instance 是否爲 null,如果不爲 null ,則直接返回 instance 變量,否則才執行臨界區代碼來完成 instance 變量的初始化
public static Singleton getInstance() {
if (instance == null) { //操作1
synchronized (Singleton.class) {
if (instance == null) { //操作2
instance = new Singleton(); //操作3
}
}
}
return instance;
}
上述代碼表現出來的初始化邏輯可以分爲兩種情況,這兩種情況的前置前提是:存在兩個線程 T1 和 T2 ,線程 T1 執行到了操作1,線程 T2 執行到了臨界區
- 當線程 T1 執行到操作1的時候線程 T2 已經執行完了操作3,發現此時 instance 不爲 null,直接返回 instance 變量,避免了鎖的開銷
- 當線程 T1 執行到操作1的時候發現 instance 爲 null,此時線程 T2 還處於執行操作3之前,那麼當線程 T2 執行臨界區結束之前,線程 T1 均會處於等待狀態。當線程 T2 執行完畢,線程 T1 進入臨界區後,由於此時線程 T1 是在臨界區內讀取共享變量 instance 的,因此 T1 可以發現此刻 instance 不爲 null,於是 T1 不會執行操作3,從而避免了再次創建一個實例
上述代碼看起來似乎避免了鎖的開銷又保障了線程安全,但還是有着一些邏輯缺陷,因爲該方法僅考慮到了可見性,而沒有考慮到發生重排序的情況
操作3可以分解爲以下三條僞指令所代表的子操作
objRef = allocate(Singleton.class) //子操作1,分配對象所需的存儲空間
invokeConstructor(objRef) //子操作2,初始化 objRef 引用的對象
instance = objRef //子操作3,將對象引用寫入共享變量
由於臨界區內的代碼是有可能被重排序的,因此,JIT 編譯器可能將上述的子操作重排序爲:子操作1 -> 子操作3 -> 子操作2。即在初始化對象之前將對象的引用寫入實例變量 instance。由於鎖對有序性的保障是有條件的,而線程 T1 在臨界區之外檢查 instance 是否爲 null 的時候並沒有加鎖,因此上述重排序對於線程 T1 來說是有影響的,這會使得線程 T1 得到一個不爲 null 但內部還未完全初始化完畢的 instance 變量,從而造成一些意想不到的錯誤
在分析清楚問題的原因後,解決方法也就不難想到:只要將 instance 變量採用 volatile 修飾即可,這實際上是利用了 volatile 關鍵字的以下兩個作用:
- 保障可見性。一個線程通過執行
instance = new Singleton()
修改了 instance 變量值,其它線程可以讀取到相應的值 - 保障有序性。由於 volatile 能夠禁止 volatile 變量寫操作與該操作之前的任何讀、寫操作進行重排序,因此,用 volatile 修飾 instance 相當於禁止 JIT 編譯器以及處理器將子操作2重排序到子操作3之後,這保障了一個線程讀取到 instance 變量所引用的實例時該實例已經初始化完畢
因此,雙重檢查鎖定的單例模式其正確的實現方式如下所示
public final class Singleton {
private static volatile Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
}
}
3、靜態內部類
類的靜態變量被初次訪問時會觸發 Java 虛擬機對該類進行初始化,即該類的靜態變量的值會變爲其初始值而不再是默認值(例如,引用型變量的默認值是 null,int 的默認值是 0)。因此,靜態方法 getInstance()
被調用的時候 Java 虛擬機會初始化這個方法所訪問的內部靜態類 InstanceHolder。這使得 InstanceHolder 的靜態變量 INSTANCE 被初始化,從而使得 Singleton 類的唯一實例得以創建。由於類的靜態變量只會創建一次,因此 Singleton 也只會有一個實例變量
public final class Singleton {
private Singleton() {
}
private final static class InstanceHolder {
final static Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return InstanceHolder.INSTANCE;
}
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
}
}
4、枚舉類
枚舉類 Singleton 相當於一個單例類,其字段 INSTANCE 相當於該類的唯一實例。這個實例是在 Singleton.INSTANCE
初次被引用的時候纔會被初始化的。僅訪問 Singleton 本身(例如 Singleton.class.getName()
)並不會導致 Singleton 的唯一實例被初始化
public class SingletonExample {
public static void main(String[] args) {
Singleton.INSTANCE.doSomething();
}
public enum Singleton {
INSTANCE;
void doSomething() {
}
}
}