1、什么是 Java 內存模型镜遣?
1.1 從 Java 代碼到 CPU 指令
編寫的 Java 代碼己肮,最終還是要轉化為 CPU 指令才能執(zhí)行的。為了理解 Java 內存模型的作用悲关,首先看一下從 Java 代碼到最終執(zhí)行的 CPU 指令的大致流程:
- 最開始谎僻,編寫的 Java 代碼,是 *.java 文件寓辱;
- 在編譯(包含詞法分析艘绍、語義分析等步驟)后,在剛才的 * .java 文件之外秫筏,會多出一個新的 Java 字節(jié)碼文件(*.class)诱鞠;
- JVM 會分析剛才生成的字節(jié)碼文件(*.class),并根據平臺等因素这敬,把字節(jié)碼文件轉化為具體平臺上的機器指令航夺;
- 機器指令則可以直接在 CPU 上運行,也就是最終的程序執(zhí)行崔涂。
1.2 為什么需要 JMM(Java Memory Model)阳掐?
在更早期的語言中,其實是不存在內存模型的概念的冷蚂。
所以程序最終執(zhí)行的效果會依賴于具體的處理器缭保,而不同的處理器的規(guī)則又不一樣,不同的處理器之間可能差異很大帝雇,因此同樣的一段代碼涮俄,可能在處理器 A 上運行正常,而在處理器 B 上運行的結果卻不一致尸闸。同理彻亲,在沒有 JMM 之前,不同的 JVM 的實現吮廉,也會帶來不一樣的“翻譯”結果苞尝。
所以 Java 非常需要一個標準,來讓 Java 開發(fā)者宦芦、編譯器工程師和 JVM 工程師能夠達成一致宙址。達成一致后,就可以很清楚的知道什么樣的代碼最終可以達到什么樣的運行效果调卑,讓多線程運行結果可以預期抡砂,這個標準就是 JMM大咱,這就是需要 JMM 的原因。
1.3 JMM 是規(guī)范注益,是工具類和關鍵字的原理
JMM 是和多線程相關的一組規(guī)范碴巾,需要各個 JVM 的實現來遵守 JMM 規(guī)范,以便于開發(fā)者可以利用這些規(guī)范丑搔,更方便地開發(fā)多線程程序厦瓢。這樣一來,即便同一個程序在不同的虛擬機上運行啤月,得到的程序結果也是一致的煮仇。
JMM 與處理器、緩存谎仲、并發(fā)浙垫、編譯器有關。它解決了 CPU 多級緩存强重、處理器優(yōu)化绞呈、指令重排等導致的結果不可預期的問題。
之前使用的各種同步工具和關鍵字间景,包括 volatile佃声、synchronized、Lock 等倘要,其實它們的原理都涉及 JMM圾亏。正是 JMM 的參與和幫忙,才讓各個同步工具和關鍵字能夠發(fā)揮作用封拧,幫我們開發(fā)出并發(fā)安全的程序志鹃。
從 Java 代碼到 CPU 指令的這個轉化過程要遵守哪些和并發(fā)相關的原則和規(guī)范,這就是 JMM 的重點內容(重排序泽西、原子性曹铃、內存可見性)。如果不加以規(guī)范捧杉,那么同樣的 Java 代碼陕见,完全可能產生不一樣的執(zhí)行效果,這也違背了 Java “書寫一次味抖、到處運行”的特點评甜。
2、指令重排序
2.1 什么是重排序仔涩?
假設我們寫了一個 Java 程序忍坷,包含一系列的語句,我們會默認期望這些語句的實際運行順序和寫的代碼順序一致。但實際上佩研,編譯器柑肴、JVM 或者 CPU 都有可能出于優(yōu)化等目的,對于實際指令執(zhí)行的順序進行調整韧骗,這就是重排序嘉抒。
2.2 為什么要重排序零聚?
重排序通過減少執(zhí)行指令袍暴,從而提高整體的運行速度,這就是重排序帶來的優(yōu)化和好處隶症。
2.3 重排序的 3 種情況
(1)編譯器優(yōu)化
編譯器(包括 JVM政模、JIT 編譯器等)出于優(yōu)化的目的,例如當前有了數據 a蚂会,把對 a 的操作放到一起效率會更高淋样,避免讀取 b 后又返回來重新讀取 a 的時間開銷,此時在編譯的過程中會進行一定程度的重排胁住。不過重排序并不意味著可以任意排序趁猴,需要保證重排序后,不改變單線程內的語義彪见,否則如果能任意排序儡司,程序早就邏輯混亂了。
(2)CPU 重排序
CPU 同樣會有優(yōu)化行為余指,這里的優(yōu)化和編譯器優(yōu)化類似捕犬,都是通過亂序執(zhí)行的技術來提高整體的執(zhí)行效率。所以即使之前編譯器不發(fā)生重排酵镜,CPU 也可能進行重排碉碉。在開發(fā)中,一定要考慮到重排序帶來的后果淮韭。
(3) 內存的“重排序”
內存系統內不存在真正的重排序垢粮,但是內存會帶來看上去和重排序一樣的效果,所以這里的“重排序”打了雙引號靠粪。由于內存有緩存的存在蜡吧,在 JMM 里表現為主存和本地內存,而主存和本地內存的內容可能不一致庇配,所以這也會導致程序表現出亂序的行為斩跌。
3、Java 中的原子性和原子操作
3.1 什么是原子性和原子操作捞慌?
在編程中耀鸦,具備原子性的操作被稱為原子操作。原子操作是指一系列的操作,要么全部發(fā)生袖订,要么全部不發(fā)生氮帐,不會出現執(zhí)行一半就終止的情況。
比如轉賬行為就是一個原子操作洛姑,該過程包含扣除余額上沐、銀行系統生成轉賬記錄、對方余額增加等一系列操作楞艾。雖然整個過程包含多個操作参咙,但由于這一系列操作被合并成一個原子操作,所以它們要么全部執(zhí)行成功硫眯,要么全部不執(zhí)行蕴侧,不會出現執(zhí)行一半的情況。而具有原子性的原子操作两入,天然具備線程安全的特性净宵。
3.2 Java 中的原子操作有哪些?
- 除了 long 和 double 之外的基本類型(int裹纳、byte择葡、boolean、short剃氧、char敏储、float)的讀/寫操作,都天然的具備原子性她我;
- 所有引用 reference 的讀/寫操作虹曙;
- 加了 volatile 關鍵字后,所有變量的讀/寫操作(包含 long 和 double)番舆,同樣具備原子性酝碳;
- 在 java.concurrent.Atomic 包中的一部分類的一部分方法是具備原子性的,比如 AtomicInteger 的 incrementAndGet 方法恨狈。
long 和 double 和其他的基本類型不太一樣疏哗,好像不具備原子性,這是什么原因造成的呢禾怠?
從 JVM 規(guī)范中可以知道返奉,long 和 double 的值需要占用 64 位的內存空間,而對于 64 位值的寫入吗氏,可以分為兩個 32 位的操作來進行芽偏。
這樣一來,本來是一個整體的賦值操作弦讽,就可能被拆分為低 32 位和高 32 位的兩個操作污尉。如果在這兩個操作之間發(fā)生了其他線程對這個值的讀操作膀哲,就可能會讀到一個錯誤、不完整的值被碗。
在實際開發(fā)中某宪,讀取到“半個變量”的情況非常罕見,這個情況在目前主流的 Java 虛擬機中不會出現锐朴。因為 JVM 規(guī)范雖然不強制虛擬機把 long 和 double 的變量寫操作實現為原子操作兴喂,但它其實是“強烈建議”虛擬機去把該操作作為原子操作來實現的。
而在目前各種平臺下的主流虛擬機的實現中焚志,幾乎都會把 64 位數據的讀寫操作作為原子操作來對待衣迷,因此我們在編寫代碼時一般不需要為了避免讀到“半個變量”而把 long 和 double 聲明為 volatile 的。
注意:原子操作 + 原子操作 != 原子操作
值得注意的是娩嚼,簡單地把原子操作組合在一起蘑险,并不能保證整體依然具備原子性。比如連續(xù)轉賬兩次的操作行為岳悟,顯然不能合并當做一個原子操作,雖然每一次轉賬操作都是具備原子性的泼差,但是將兩次轉賬合為一次的操作贵少,這個組合就不具備原子性了,因為在兩次轉賬之間可能會插入一些其他的操作堆缘,例如系統自動扣費等滔灶,導致第二次轉賬失敗,而且第二次轉賬失敗并不會影響第一次轉賬成功
4吼肥、內存可見性
什么是“內存可見性”問題录平?
可見性問題:某個變量 a 的值已經被第 1 個線程修改了,但是其他線程卻看不到缀皱。
原因:a的值被線程1修改后還沒來得及寫回主內存斗这;a的值寫回主內存,但是其他線程還沒來得及將a的值從主內存同步到工作內存中啤斗。
如何避免可見性問題呢表箭?
除了 volatile 關鍵字可以讓變量保證可見性外,synchronized钮莲、Lock免钻、并發(fā)集合等一系列工具都可以在一定程度上保證可見性。
synchronized 不僅保證了原子性崔拥,還保證了可見性
關于 synchronized 之前可能一致認為极舔,使用了 synchronized 之后,它會設立一個臨界區(qū)链瓦,這樣在一個線程操作臨界區(qū)內的數據的時候拆魏,另一個線程無法進來同時操作,所以保證了線程安全。
其實這是不全面的稽揭,這種說法沒有考慮到可見性問題俺附,完整的說法是:synchronized 不僅保證了臨界區(qū)內最多同時只有一個線程執(zhí)行操作,同時還保證了在前一個線程釋放鎖之后溪掀,之前所做的所有修改事镣,都能被獲得同一個鎖的下一個線程所看到,也就是能讀取到最新的值揪胃。因為如果其他線程看不到之前所做的修改璃哟,依然也會發(fā)生線程安全問題。
5喊递、JMM的抽象:主內存和工作內存
5.1 CPU 有多級緩存随闪,導致讀的數據過期
由于 CPU 的處理速度很快,相比之下骚勘,內存的速度就顯得很慢铐伴,所以為了提高 CPU 的整體運行效率,減少空閑時間俏讹,在 CPU 和內存之間會有 cache 層当宴,也就是緩存層的存在。雖然緩存的容量比內存小泽疆,但是緩存的速度卻比內存的速度要快得多户矢,其中 L1 緩存的速度僅次于寄存器的速度。結構示意圖如下所示:
在圖中殉疼,從下往上分別是內存梯浪,L3 緩存、L2 緩存瓢娜、L1 緩存挂洛,寄存器,然后最上層是 CPU 的 4個核心恋腕。從內存抹锄,到 L3 緩存,再到 L2 和 L1 緩存荠藤,它們距離 CPU 的核心越來越近了伙单,越靠近核心,其容量就越小哈肖,但是速度也越快吻育。正是由于緩存層的存在,才讓我們的 CPU 能發(fā)揮出更好的性能淤井。
其實布疼,線程間對于共享變量的可見性問題摊趾,并不是直接由多核引起的,而是由這些 L3 緩存游两、L2 緩存砾层、L1 緩存,也就是多級緩存引起的:每個核心在獲取數據時贱案,都會將數據從內存一層層往上讀取肛炮,同樣,后續(xù)對于數據的修改也是先寫入到自己的 L1 緩存中宝踪,然后等待時機再逐層往下同步侨糟,直到最終刷回內存。
5.2 什么是主內存和工作內存瘩燥?
每個線程只能夠直接接觸到工作內存秕重,無法直接操作主內存,而工作內存中所保存的正是主內存的共享變量的副本厉膀,主內存和工作內存之間的通信是由 JMM 控制的溶耘。
5.3 主內存和工作內存的關系
JMM 有以下規(guī)定:
(1)所有的變量都存儲在主內存中,同時每個線程擁有自己獨立的工作內存站蝠,而工作內存中的變量的內容是主內存中該變量的拷貝汰具;
(2)線程不能直接讀 / 寫主內存中的變量,但可以操作自己工作內存中的變量菱魔,然后再同步到主內存中,這樣吟孙,其他線程就可以看到本次修改澜倦;
(3) 主內存是由多個線程所共享的,但線程間不共享各自的工作內存杰妓,如果線程間需要通信藻治,則必須借助主內存中轉來完成。
6巷挥、先行發(fā)生原則(happens-before)
6.1 什么是 happens-before 關系桩卵?
Happens-before 關系是用來描述和可見性相關問題的:如果第一個操作 happens-before 第二個操作(也可以描述為,第一個操作和第二個操作之間滿足 happens-before 關系)倍宾,那么我們就說第一個操作對于第二個操作一定是可見的雏节,也就是第二個操作在執(zhí)行時就一定能保證看見第一個操作執(zhí)行的結果。
6.2 Happens-before 關系的規(guī)則有哪些高职?
如果分別有操作 x 和操作 y钩乍,用 hb(x, y) 來表示 x happens-before y。
(1)單線程規(guī)則:
在一個單獨的線程中怔锌,按照程序代碼的順序寥粹,先執(zhí)行的操作 happen-before 后執(zhí)行的操作变过。也就是說,如果操作 x 和操作 y 是同一個線程內的兩個操作涝涤,并且在代碼里 x 先于 y 出現媚狰,那么有 hb(x, y),正如下圖所示:
只要重排序后的結果依然符合 happens-before 關系阔拳,也就是能保證可見性的話崭孤,那么就不會因此限制重排序的發(fā)生。
(2)鎖操作規(guī)則(synchronized 和 Lock 接口等):
如果操作 A 是解鎖衫生,而操作 B 是對同一個鎖的加鎖裳瘪,那么 hb(A, B) 。正如下圖所示:
從上圖中可以看到罪针,有線程 A 和線程 B 這兩個線程彭羹。線程 A 在解鎖之前的所有操作,對于線程 B 的對同一個鎖的加鎖之后的所有操作而言泪酱,都是可見的派殷。這就是鎖操作的 happens-before 關系的規(guī)則。
(3)volatile 變量規(guī)則:
對一個 volatile 變量的寫操作 happen-before 后面對該變量的讀操作墓阀。
這就代表了如果變量被 volatile 修飾毡惜,那么每次修改之后,其他線程在讀取這個變量的時候一定能讀取到該變量最新的值斯撮。volatile 關鍵字能保證可見性经伙,正是由本條規(guī)則所規(guī)定的。
(4)線程啟動規(guī)則:
Thread 對象的 start 方法 happen-before 此線程 run 方法中的每一個操作勿锅。如下圖所示:
在圖中的例子中帕膜,左側區(qū)域是線程 A 啟動了一個子線程 B,而右側區(qū)域是子線程 B溢十,那么子線程 B 在執(zhí)行 run 方法里面的語句的時候垮刹,它一定能看到父線程在執(zhí)行 threadB.start() 前的所有操作的結果。
(5)線程 join 規(guī)則:
join 可以讓線程之間等待张弛,假設線程 A 通過調用 threadB.start() 啟動了一個新線程 B早抠,然后調用 threadB.join() 股毫,那么線程 A 將一直等待到線程 B 的 run 方法結束(不考慮中斷等特殊情況),然后 join 方法才返回。在 join 方法返回后看彼,線程 A 中的所有后續(xù)操作都可以看到線程 B 的 run 方法中執(zhí)行的所有操作的結果椒惨,也就是線程 B 的 run 方法里面的操作 happens-before 線程 A 的 join 之后的語句拣度。如下圖所示:
(6)中斷規(guī)則:
對線程 interrupt 方法的調用 happens-before 檢測該線程的中斷事件俊性。
也就是說,如果一個線程被其他線程 interrupt透敌,那么在檢測中斷時(比如調用 Thread.interrupted 或者 Thread.isInterrupted 方法)一定能看到此次中斷的發(fā)生盯滚,不會發(fā)生檢測結果不準的情況踢械。
(7)并發(fā)工具類的規(guī)則:
- 線程安全的并發(fā)容器(如 ConcurrentHashMap)在 get 某個值時一定能看到在此之前發(fā)生的 put 等存入操作的結果。也就是說魄藕,線程安全的并發(fā)容器的存入操作 happens-before 讀取操作内列。
- 信號量(Semaphore)它會釋放許可證,也會獲取許可證背率。這里的釋放許可證的操作 happens-before 獲取許可證的操作话瞧,也就是說,如果在獲取許可證之前有釋放許可證的操作寝姿,那么在獲取時一定可以看到交排。
- Future:Future 有一個 get 方法,可以用來獲取任務的結果饵筑。那么埃篓,當 Future 的 get 方法得到結果的時候,一定可以看到之前任務中所有操作的結果根资,也就是說 Future 任務中的所有操作 happens-before Future 的 get 操作架专。
- 線程池:要想利用線程池,就需要往里面提交任務(Runnable 或者 Callable)玄帕,這里面也有一個 happens-before 關系的規(guī)則部脚,那就是提交任務的操作 happens-before 任務的執(zhí)行。
7裤纹、volatile關鍵字
7.1 volatile 是什么委刘?
volatile 是 Java 中的一個關鍵字,是一種同步機制鹰椒。當某個變量是共享變量钱雷,且這個變量是被 volatile 修飾的,那么在修改了這個變量的值之后吹零,再讀取該變量的值時,可以保證獲取到的是修改后的最新的值拉庵,而不是過期的值灿椅。
相比于 synchronized 或者 Lock,volatile 是更輕量的钞支,因為使用 volatile 不會發(fā)生上下文切換等開銷很大的情況茫蛹,不會讓線程阻塞。但正是由于它的開銷相對比較小烁挟,所以它的效果婴洼,也就是能力,相對也小一些撼嗓。
雖然說 volatile 是用來保證線程安全的柬采,但是它做不到像 synchronized 那樣的同步保護欢唾,volatile 僅在很有限的場景中才能發(fā)揮作用。
7.2 volatile 的適用場合
volatile 不適合運用于需要保證原子性的場景粉捻,比如更新時需要依賴原來的值礁遣,而最典型的就是 a++ 的場景,僅靠 volatile 是不能保證 a++ 的線程安全的肩刃。
(1)適用場合1:布爾標記位
如果某個共享變量自始至終只是被各個線程所賦值或讀取祟霍,而沒有其他的操作(比如讀取并在此基礎上進行修改這樣的復合操作)的話,就可以使用 volatile 來代替 synchronized 或者代替原子類盈包,因為賦值操作自身是具有原子性的沸呐,volatile 同時又保證了可見性,這就足以保證線程安全了呢燥。
一個比較典型的場景就是布爾標記位的場景崭添,例如 volatile boolean flag。因為通常情況下疮茄,boolean 類型的標記位是會被直接賦值的滥朱,此時不會存在復合操作(如 a++),只存在單一操作力试,就是去改變 flag 的值徙邻,而一旦 flag 被 volatile 修飾之后,就可以保證可見性了畸裳,那么這個 flag 就可以當作一個標記位缰犁,此時它的值一旦發(fā)生變化,所有線程都可以立刻看到怖糊,所以這里就很適合運用 volatile 了帅容。
(2)適用場合 2:作為觸發(fā)器
作為觸發(fā)器,保證其他變量的可見性伍伤。
7.3 volatile 的作用
第一層的作用是保證可見性并徘。Happens-before 關系中對于 volatile 是這樣描述的:對一個 volatile 變量的寫操作 happen-before 后面對該變量的讀操作。
這就代表了如果變量被 volatile 修飾扰魂,那么每次修改之后麦乞,接下來在讀取這個變量的時候一定能讀取到該變量最新的值。
第二層的作用就是禁止重排序劝评。先介紹一下 as-if-serial語義:不管怎么重排序姐直,(單線程)程序的執(zhí)行結果不會改變。在滿足 as-if-serial 語義的前提下蒋畜,由于編譯器或 CPU 的優(yōu)化声畏,代碼的實際執(zhí)行順序可能與我們編寫的順序是不同的,這在單線程的情況下是沒問題的姻成,但是一旦引入多線程插龄,這種亂序就可能會導致嚴重的線程安全問題愿棋。用了 volatile 關鍵字就可以在一定程度上禁止這種重排序。
7.4 volatile 和 synchronized 的關系
相似性:volatile 可以看作是一個輕量版的 synchronized辫狼,比如一個共享變量如果自始至終只被各個線程賦值和讀取初斑,而沒有其他操作的話,那么就可以用 volatile 來代替 synchronized 或者代替原子變量膨处,足以保證線程安全见秤。實際上,對 volatile 字段的每次讀取或寫入都類似于“半同步”——讀取 volatile 與獲取 synchronized 鎖有相同的內存語義真椿,而寫入 volatile 與釋放 synchronized 鎖具有相同的語義鹃答。
不可代替:但是在更多的情況下,volatile 是不能代替 synchronized 的突硝,volatile 并沒有提供原子性和互斥性测摔。
性能方面:volatile 屬性的讀寫操作都是無鎖的,正是因為無鎖解恰,所以不需要花費時間在獲取鎖和釋放鎖上锋八,所以它是高性能的,比 synchronized 性能更好护盈。
8挟纱、單例模式的雙重檢查鎖模式
雙重檢查鎖模式的寫法如下:
public class Singleton {
private static volatile Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
getInstance 方法中首先進行了一次 if (singleton == null) 的檢查,然后是 synchronized 同步塊腐宋,然后又是一次 if (singleton == null) 的檢查紊服,最后是 singleton = new Singleton() 來生成實例。
其中進行了兩次 if (singleton == null) 檢查胸竞,這就是“雙重檢查鎖”這個名字的由來欺嗤。這種寫法是可以保證線程安全的,假設有兩個線程同時到達 synchronized 語句塊卫枝,那么實例化代碼只會由其中先搶到鎖的線程執(zhí)行一次煎饼,而后搶到鎖的線程會在第二個 if 判斷中發(fā)現 singleton 不為 null,所以跳過創(chuàng)建實例的語句校赤。再后面的其他線程再來調用 getInstance 方法時腺占,只需判斷第一次的 if (singleton == null) ,然后會跳過整個 if 塊痒谴,直接 return 實例化后的對象。
這種寫法的優(yōu)點是不僅線程安全铡羡,而且延遲加載积蔚、效率也更高。
為什么要 double-check烦周?去掉任何一次的 check 行不行尽爆?
先來看第二次的 check怎顾,這時需要考慮這樣一種情況,有兩個線程同時調用 getInstance 方法漱贱,由于 singleton 是空的 槐雾,因此兩個線程都可以通過第一重的 if 判斷;然后由于鎖機制的存在幅狮,會有一個線程先進入同步語句募强,并進入第二重 if 判斷 ,而另外的一個線程就會在外面等待崇摄。
不過擎值,當第一個線程執(zhí)行完 new Singleton() 語句后,就會退出 synchronized 保護的區(qū)域逐抑,這時如果沒有第二重 if (singleton == null) 判斷的話鸠儿,那么第二個線程也會創(chuàng)建一個實例,此時就破壞了單例厕氨,這肯定是不行的进每。
而對于第一個 check 而言,如果去掉它命斧,那么所有線程都會串行執(zhí)行田晚,效率低下,所以兩個 check 都是需要保留的冯丙。
在雙重檢查鎖模式中為什么需要使用 volatile 關鍵字肉瓦?
主要就在于 singleton = new Singleton() ,它并非是一個原子操作胃惜,事實上泞莉,在 JVM 中上述語句至少做了以下這 3 件事:
這里需要留意一下 1-2-3 的順序,因為存在指令重排序的優(yōu)化船殉,也就是說第2 步和第 3 步的順序是不能保證的鲫趁,最終的執(zhí)行順序,可能是 1-2-3利虫,也有可能是 1-3-2挨厚。
如果是 1-3-2,那么在第 3 步執(zhí)行完以后糠惫,singleton 就不是 null 了疫剃,可是這時第 2 步并沒有執(zhí)行,singleton 對象未完成初始化硼讽,它的屬性的值可能不是我們所預期的值巢价。假設此時線程 2 進入 getInstance 方法,由于 singleton 已經不是 null 了,所以會通過第一重檢查并直接返回壤躲,但其實這時的 singleton 并沒有完成初始化城菊,所以使用這個實例的時候會報錯,詳細流程如下圖所示:
使用了 volatile 之后碉克,相當于是表明了該字段的更新可能是在其他線程中發(fā)生的凌唬,因此應確保在讀取另一個線程寫入的值時,可以順利執(zhí)行接下來所需的操作漏麦。在 JDK 5 以及后續(xù)版本所使用的 JMM 中客税,在使用了 volatile 后,會一定程度禁止相關語句的重排序唁奢,從而避免了上述由于重排序所導致的讀取到不完整對象的問題的發(fā)生霎挟。
使用 volatile 的意義主要在于它可以防止避免拿到沒完成初始化的對象,從而保證了線程安全麻掸。