概述
多線程三大特性:原子性睬涧、可見性、有序性旗唁。
1. 原子性
原子性是指:多個(gè)操作作為一個(gè)整體畦浓,不能被分割與中斷,也不能被其他線程干擾检疫。如果被中斷與干擾讶请,則會(huì)出現(xiàn)數(shù)據(jù)異常、邏輯異常屎媳。
多個(gè)操作合并的整體夺溢,我們稱之為復(fù)合操作。一個(gè)復(fù)合操作烛谊,往往存在前后依賴關(guān)系风响,后一個(gè)操作依賴上一個(gè)操作的結(jié)果。如果上一個(gè)操作結(jié)果被其他線程干擾丹禀,對(duì)于當(dāng)前線程看來整個(gè)復(fù)合操作的結(jié)果便不符合預(yù)期状勤。同理線程也不能在復(fù)合操作中間被中斷,中斷必須發(fā)生在進(jìn)入復(fù)合操作之前或者等到復(fù)合操作結(jié)束之后双泪。
保證原子性就是在多線程環(huán)境下持搜,保證單個(gè)線程執(zhí)行復(fù)合操作符合預(yù)期邏輯。
典型的復(fù)合操作:『先檢查后執(zhí)行』和『讀取—修改—寫入』
1.1 先檢查后執(zhí)行
@NotThreadSafe
public class LazyInitClass {
private static LazyInitClass instance ;
public static LazyInitClass getInstance() {
if(instance == null)
instance = new LazyInitClass() ;
return instance ;
}
}
LazyInitClass
的 getInstance
中包含先檢查后執(zhí)行的復(fù)合操作攒读,通常我們也可以稱 getInstance
中包含競(jìng)態(tài)條件朵诫。假設(shè)線程 A 和線程 B 同時(shí)執(zhí)行 getInstance
辛友。A 看到 instance
為空薄扁,便執(zhí)行 new LazyInitClass()
邏輯。A 還未完成初始化并設(shè)置 instance
废累,B 檢查 instance
邓梅,此時(shí) instance
為空,B 便也會(huì)執(zhí)行 new LazyInitClass()
邑滨。那么兩次調(diào)用 getInstance
時(shí)可能會(huì)得到不同的結(jié)果日缨。通常 getInstance
的預(yù)期結(jié)果是多次調(diào)用得到相同的對(duì)象實(shí)例。
LazyInitClass
的 getInstance
方法雖然存在競(jìng)態(tài)條件掖看,多數(shù)情況下并不會(huì)造成業(yè)務(wù)異常匣距,影響僅僅是增加了 JVM 垃圾回收負(fù)擔(dān)而已面哥。這也是多線程問題隱蔽性強(qiáng)且偶發(fā)的原因之一。
但話說回來毅待,編程原則之一就是所有邏輯都必須建立在確定性之上尚卫,任何建立在不確定性上的邏輯都是隱患。雖然從業(yè)務(wù)上看多數(shù)情況下沒問題尸红,但競(jìng)態(tài)條件的存在吱涉,讓代碼邏輯建立在不確定性之上。作為編碼者應(yīng)該重視此類問題外里。
1.2 讀取—修改—寫入
@NotThreadSafe
public class ReadModifyAndWriteClass {
private int count = 0 ;
public int increase() {
return count++ ;
}
}
由于 i++ 本身不是原子操作怎爵,屬于復(fù)合操作。ReadModifyAndWriteClass
的 increase
包含了讀取—修改—寫入盅蝗。假設(shè)線程 A 和線程 B 同時(shí)執(zhí)行 increase
鳖链。A 看到 count 為 0,執(zhí)行 ++ 邏輯墩莫。當(dāng) ++ 操作還未完成撒轮,此時(shí) B 讀取 count 看到的仍然是 0。A贼穆、B 各自完成 ++ 邏輯后题山,count 的值等于 1。這就造成了雖然調(diào)用了兩次 increase
方法故痊,但 count 只增加了 1顶瞳。這也與預(yù)期:每調(diào)用一次 increase
,count 增加 1 的結(jié)果不符愕秫。
2. 可見性
可見性問題是指慨菱,一個(gè)線程修改的共享變量,其他線程是否能夠立刻看到戴甩。對(duì)于串行程序而言符喝,并不存在可見性問題,前一個(gè)操作修改的變量甜孤,后一個(gè)操作一定能讀取到最新值协饲。但在多線程環(huán)境下如果沒有正確的同步則不一定。
有很多因素會(huì)使得線程無法立即看到甚至永遠(yuǎn)無法看到另一個(gè)線程的操作結(jié)果缴川。在編譯器中生成的指令順序茉稠,可以與源代碼中的順序不同,此外編譯器還會(huì)把變量保存在寄存器而非內(nèi)存中把夸;處理器可以采用亂序或并行等方式來執(zhí)行指令而线;緩存可能會(huì)改變將寫入變量提交到主內(nèi)存的次序;而且,保存在處理器本地緩存中的值膀篮,對(duì)于其他處理器是不可見的嘹狞。這些因素都會(huì)使得一個(gè)線程無法看到變量的最新值,并且會(huì)導(dǎo)致其他線程中的內(nèi)存操作似乎在亂序執(zhí)行誓竿。
2.1 緩存引起的可見性
上圖是多核 CPU 內(nèi)存圖刁绒,其中 individual memory 表示核心多級(jí)緩存。main memory 表示主內(nèi)存烤黍,即共享內(nèi)存知市。共享內(nèi)存(shared memory)是線程之間共享的內(nèi)存,也稱為堆內(nèi)存(heap memory)速蕊。所有實(shí)例域(instance fields)嫂丙、靜態(tài)域(static fields)和數(shù)組元素(array elements)都保存在堆內(nèi)存中。
A 線程與 B 線程共同操作共享變量 V(初始值為 0)规哲,A跟啤、B 線程分別將 V 變量從主內(nèi)存復(fù)制到 CPU 內(nèi)核的多級(jí)緩存中,此時(shí) A 與 B 都讀到 V 的值為 0唉锌。A 更新自己的 individual memory 中的 V 的值為 1隅肥,此時(shí)如果沒有將 V 值同步至主內(nèi)存中,B 從自己的 individual memory 中讀到 V 的值仍然為 0袄简。當(dāng) V 值同步到主內(nèi)存后腥放,多級(jí)緩存失效,此時(shí) B 才能夠從主內(nèi)存中讀取到最新的 V 值為 1绿语。由于多線程環(huán)境下何時(shí)將多級(jí)緩存同步到主內(nèi)存時(shí)間上不確定秃症,所以造成了可見性問題,即 A 線程對(duì)共享變量 V 的寫操作吕粹,位于寫操作后執(zhí)行的 B 線程的讀操作不能立即感知种柑。
3. 有序性
有序性問題是指從觀察到的結(jié)果推測(cè),代碼執(zhí)行的順序與代碼組織的順序不一致匹耕。
3.1 指令重排序引起的有序性問題
在計(jì)算機(jī)體系結(jié)構(gòu)中聚请,為了提高執(zhí)行部件的處理速度,經(jīng)常在部件中采用流水線技術(shù)稳其。所謂流水線技術(shù)驶赏,是指將一個(gè)重復(fù)的時(shí)序過程,分解成若干個(gè)子過程欢际,而每一個(gè)子過程都可有效地在其專用功能段上與其他子過程同時(shí)執(zhí)行母市。
以 DLX 指令集結(jié)構(gòu)為例,一條指令的執(zhí)行簡(jiǎn)單說可以分為以下幾個(gè)步驟:
- 取指令(IF)
- 指令譯碼/讀寄存器(ID)
- 執(zhí)行/有效地址計(jì)算(EX)
- 存儲(chǔ)器訪問/分支完成(MEM)
- 寫回(WB)
由上圖所示,如果沒有指令流水線,指令2 需要等待指令1 完全執(zhí)行完成后執(zhí)行浑槽。假設(shè)每一個(gè)步驟(子過程)需要花費(fèi) 1 個(gè) CPU 時(shí)鐘周期蒋失,則指令2 需要等待 5 個(gè)時(shí)鐘周期。而使用指令流水線后篙挽,指令2 只需等待 1 個(gè)時(shí)鐘周期就可以開始執(zhí)行。指令2 開始執(zhí)行時(shí)镊靴,指令1 根本還沒開始執(zhí)行铣卡,僅僅完成了取指操作而已。這僅僅是 DLX 指令集結(jié)構(gòu)的流水線偏竟,實(shí)際商用 CPU 的流水線級(jí)別甚至可以達(dá)到 10 級(jí)以上煮落,性能提升可謂是非常明顯。
由于流水線技術(shù)的引入踊谋,不得不面對(duì)流水線的三種類型的相關(guān):結(jié)構(gòu)相關(guān)蝉仇、數(shù)據(jù)相關(guān)、控制相關(guān)殖蚕。
- 結(jié)構(gòu)相關(guān):當(dāng)指令在重疊執(zhí)行過程中轿衔,硬件資源滿足不了指令重疊執(zhí)行的要求,發(fā)生資源沖突時(shí)將產(chǎn)生“結(jié)構(gòu)相關(guān)”睦疫。
- 數(shù)據(jù)相關(guān):當(dāng)一條指令需要用到前面指令的執(zhí)行結(jié)果害驹,而這些指令均在流水線中重疊執(zhí)行時(shí),就可能引起“數(shù)據(jù)相關(guān)”蛤育。
- 控制相關(guān):當(dāng)流水線遇到分支指令和其他會(huì)改變 PC 值的指令時(shí)就會(huì)發(fā)生“控制相關(guān)”裙秋。
一旦流水線中出現(xiàn)相關(guān),指令在流失線中的執(zhí)行就會(huì)出現(xiàn)問題缨伊,消除相關(guān)的最基本方法是讓流水線中的某些指令暫停執(zhí)行摘刑。一旦暫停,所有硬件設(shè)備都會(huì)進(jìn)入一個(gè)停頓周期刻坊,直接影響是性能的下降枷恕。
我們說的指令重排序就是在產(chǎn)生數(shù)據(jù)相關(guān)時(shí)替代流水線暫停的重要方法。指令重排序僅僅是減少流水線暫停技術(shù)的一種谭胚,在 CPU 設(shè)計(jì)中還有很多其他軟硬件技術(shù)來防止流水線暫停徐块。
下圖展示了 A = B + C 操作的執(zhí)行過程。LW 表示加載灾而,LW R1, B 表示把 B 的值加載到寄存器 R1 中胡控。ADD 表示加法,ADD R3, R1, R2 表示把寄存器 R1 和 R2 中的值相加保存到寄存器 R3 中旁趟。SW 表示存儲(chǔ)昼激,SW A, R3 表示將寄存器 R3 中的值保存到變量 A 中。可以看到,ADD 指令的流水線上出現(xiàn)了一個(gè) stall橙困,表示一個(gè)暫停瞧掺。之所以出現(xiàn)暫停,是因?yàn)?R2 的數(shù)據(jù)還沒準(zhǔn)備好( LW R2, C 的操作還沒完成 )凡傅。由于 ADD 暫停的出現(xiàn)辟狈,后續(xù)的操作都暫停了一個(gè)周期。
雖然指令重排序會(huì)導(dǎo)致有序性問題,但指令重排序?qū)π阅艿奶岣哂蟹浅V卮蟮囊饬x懂鸵。
3.2 CPU 緩存引起的有序性問題
2.1 節(jié)已經(jīng)討論過 CPU 緩存導(dǎo)致的可見性問題偏螺。CPU 緩存也會(huì)導(dǎo)致有序性問題。
從結(jié)果推測(cè) Thread1 中的 D = true 先于 A = b + c 執(zhí)行了。
當(dāng) D = true 執(zhí)行完成后续镇,A = b + c 還沒來得及執(zhí)行美澳,此時(shí) Thread2 輸出 A 的值,才會(huì)出現(xiàn)結(jié)果為 0 的情況摸航。
分析:Thread1 將 A制跟、D 共享變量從主內(nèi)存復(fù)制到當(dāng)前 CPU 內(nèi)核的多級(jí)緩存中,按順序執(zhí)行完 A = b + c 和 D = true 后酱虎,多級(jí)緩存中 A = 2, D = true雨膨。然后 Thread1 將 D 的值優(yōu)先同步到主緩存,A 的值沒有同步到主緩存读串。此時(shí) Thread2 執(zhí)行聊记,能看到 D 的最新值 true撒妈,卻不能看到 A 的最新值,只能看到主緩存中 A 的初始值 0甥雕。
所以從 Thread2 看踩身,Thread1 線程的執(zhí)行出現(xiàn)了有序性問題胀茵,但從 Thread1 看社露,自己的確是按照代碼組織順序執(zhí)行的。
4. 總結(jié)
本章詳細(xì)講解了多線程的三大特性:原子性琼娘、可見性峭弟、有序性。想要正確編寫多線程程序脱拼,一定要正確理解這三大特性瞒瘸。
5. 參考資料
- 《The Java? LanguageSpecification Java SE 8 Edition》作者:James Gosling、Bill Joy熄浓、Guy Steele情臭、Gilad Bracha、Alex Buckley
- 《Java Concurrency in Practice》作者:Brain Goetz赌蔑、Tim Peierls俯在、Joshua Bloch、Joseph Bowbeer娃惯、David Holmes跷乐、Doug Lea
- 《計(jì)算機(jī)體系結(jié)構(gòu)》作者:張晨曦、王志英趾浅、張春元愕提、戴葵、朱海濱