本文目錄
- Java 內(nèi)存模型與可見性
- 指令重排序
- 使用 volatile 關(guān)鍵字保證可見性
- 使用 synchronized 關(guān)鍵字保證可見性
- synchronized 和 volatile 關(guān)鍵字的異同
Java 內(nèi)存模型與可見性
上一篇文章主要介紹了 synchronized
關(guān)鍵字的使用,synchronized
關(guān)鍵字本質(zhì)是互斥鎖,保證了程序在不同線程之間執(zhí)行的順序以及同步。對于 Java 程序之中的變量,在不同的線程之中凿歼,還有一個關(guān)鍵的性質(zhì)需要了解:可見性。
那么什么是可見性呢鹰溜?
在理解可見性之前我們需要稍微了解一下 Java 的內(nèi)存模型 (JMM)愧沟,所謂 Java 內(nèi)存模型,實際上指的是 Java 用于管理內(nèi)存的一種規(guī)范粤咪,它描述了Java程序中各種變量(線程共享變量)的訪問規(guī)則,以及在 JVM 中將變量存儲到內(nèi)存和從內(nèi)存中讀取變量這樣的底層細(xì)節(jié)渴杆。對于 Java 線程來說寥枝,Java 內(nèi)存模型主要把內(nèi)存分成了兩類:
- 主內(nèi)存:主要對應(yīng)于Java堆中的對象實例數(shù)據(jù)部分
- 線程工作內(nèi)存 (本地內(nèi)存):對應(yīng)于虛擬機棧中的部分區(qū)域,是JMM的一個抽象概念磁奖,并不真實存在
在理解這兩個內(nèi)存的時候囊拜,我曾一直想把他們和之前提到過的 堆內(nèi)存 和 棧內(nèi)存 進行比較,但是實際上來說比搭,主內(nèi)存和工作內(nèi)存與堆冠跷、棧內(nèi)存并沒有什么直接的聯(lián)系。關(guān)于這幾種內(nèi)存聯(lián)系的爭論身诺,可以參考這個知乎問答:
JVM中內(nèi)存模型里的『主內(nèi)存』是不是就是指『堆』蜜托,而『工作內(nèi)存』是不是就是指『棧』霉赡?
言歸正傳橄务,我們可以用一個簡單的抽象示意圖來理解 Java 內(nèi)存模型:
從上面的圖可以看到,假設(shè)有三個線程Thread1
穴亏、Thread2
和 Thread3
蜂挪,它們在運行的過程中都會對變量 a
進行一定程度的操作,這些操作都是基于 JMM 給出的規(guī)定:
- 所有的變量都存儲在主內(nèi)存中
- 每個線程都有自己獨立的工作內(nèi)存嗓化,里面保存該線程使用到的變量的副本(主內(nèi)存中該變量的一份拷貝)
- 線程對共享變量的所有操作都必須在自己的工作內(nèi)存中進行棠涮,不能直接從主內(nèi)存中讀寫
- 不同線程之間無法直接訪問其他線程工作內(nèi)存中的變量,線程間變量值的傳遞需要通過主內(nèi)存來完成刺覆。
也就是說严肪,線程想要對變量 a
進行操作,首先得從主內(nèi)存之中獲取一個 a
的副本隅津,然后在自己的本地內(nèi)存(工作內(nèi)存)之中對 a
的副本進行修改诬垂。當(dāng)修改操作完成以后,再將本地內(nèi)存中的 “新版a” 更新到主內(nèi)存之中伦仍。
說了這么多结窘,這些東西和可見性有什么關(guān)系呢?我們先看下面的圖:
在圖中充蓝,一開始Thread1
和Thread2
都從主內(nèi)存中獲取了共享變量a
的一個副本:a1
和a2
隧枫,它們的初始值滿足:a1 = a2 = a = 0
喉磁,但是隨著線程操作的進行,Thread2
把a2
的值改為了1官脓,由于線程1和線程2之間的不可見性协怒,所以造成了a1
和a2
值不一致,為了解決這個問題卑笨,線程2需要把自己修改過的a2
先同步到主內(nèi)存中(如圖中紅色箭頭所示)孕暇,然后再經(jīng)由主內(nèi)存刷新到Thread1
中,這就是 Java 內(nèi)存模型中線程同步變量的方法赤兴。
所以稍微總結(jié)一下妖滔,可見性指的是在不同的線程之中,一個線程對共享變量值的修改桶良,能夠及時地被其他線程看到座舍。而線程1對共享變量的修改要想被線程2及時看到,必須要經(jīng)過如下2個步驟:
- 把工作內(nèi)存1中更新過的共享變量刷新到主內(nèi)存中
- 將主內(nèi)存中最新的共享變量的值更新到工作內(nèi)存2中
指令重排序
在多線程環(huán)境里陨帆,除了 Java 線程本地工作內(nèi)存造成的不可見性曲秉,指令重排序也會對線程間的語意和運行結(jié)果造成一定程度的影響。那么疲牵,什么是重排序承二?
以前有一句古話 “所見即所得” ,但是在計算機程序執(zhí)行的時候卻不是這個樣子的瑰步,為了提高程序的性能矢洲,編譯器或處理器會對程序執(zhí)行的順序進行優(yōu)化,使得代碼書寫的順序與實際執(zhí)行的順序未必相同缩焦。
而計算機程序重排序主要又可以分為以下幾類:
- 編譯器優(yōu)化的重排序(編譯器優(yōu)化)
- 指令集并行重排序(處理器優(yōu)化)
- 內(nèi)存系統(tǒng)的重排序(處理器優(yōu)化)
雖然代碼執(zhí)行不一定按照其書寫順序執(zhí)行读虏,但是為了保證在單線程中代碼最終輸出結(jié)果不會因為指令重排序而改變,編譯器袁滥、運行時環(huán)境和處理器都會遵循一定的規(guī)范盖桥,這里主要是指 as-if-serial語義 和 happens- before的程序順序規(guī)則。
as-if-serial語義: 不管怎么重排序(編譯器和處理器為了提高并行度)题翻,(單線程)程序的執(zhí)行結(jié)果不能被改變揩徊。
為了遵守as-if-serial語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關(guān)系的操作做重排序嵌赠,因為這種重排序會改變執(zhí)行結(jié)果塑荒。但是,如果操作之間不存在數(shù)據(jù)依賴關(guān)系姜挺,這些操作可能被編譯器和處理器重排序齿税。為了具體說明,我們繼續(xù)使用上面的例子:
int A = 1; // 1
int B = 2; // 2
int C = A + B; // 3
其中第一行和第二行執(zhí)行的結(jié)果之間不存在數(shù)據(jù)的依賴性炊豪,因為第一行第二行的成功運行不需要對方的計算結(jié)果凌箕,但是第三行C
的計算結(jié)果卻是依賴于A
和B
的拧篮。這個依賴關(guān)系可以用下面的示意圖表示:
所以根據(jù)依賴關(guān)系,as-if-serial語義將會允許上述程序的第一行和第二行進行重排序牵舱,而第三行的執(zhí)行一定會放在前兩行程序之后串绩。as-if-serial 語義把單線程程序保護了起來,遵守as-if-serial語義的編譯器芜壁、運行時環(huán)境和處理器共同為編寫單線程程序的程序員們創(chuàng)建了一個幻覺:單線程程序是按程序的順序來執(zhí)行的礁凡。as-if-serial 語義使單線程程序員無需擔(dān)心重排序會干擾他們,也無需擔(dān)心內(nèi)存可見性問題沿盅。
然而在多線程情況下就不是這么簡單的了把篓,指令重排序有可能會導(dǎo)致交叉工作的線程在執(zhí)行完相同的程序之后得到不同的結(jié)果。為此我們可以看一下下面的這個小程序:
public class Test {
int count = 0;
boolean running = false;
public void write() {
count = 1; // 1
running = true; // 2
}
public void read() {
if (running) { // 3
int result = count++; // 4
}
}
}
這里我們定義了一個布爾值標(biāo)記 running
腰涧,用來表示變量 count
的值是否已經(jīng)被寫入。我們假設(shè)這里現(xiàn)在有兩個線程(分別為Thread1
和Thread2
)紊浩,Thread1
首先執(zhí)行 write()
窖铡,對變量 count
進行寫入,然后Thread2
隨即執(zhí)行read()
方法坊谁,那么费彼,當(dāng)Thread2
運行到第四行的時候,是否能夠看到Thread1
對變量count
進行的寫入操作呢口芍?
答案是不一定能夠看得見箍铲。
我們對write()
來分析,語句1
和語句2
實際上并沒有數(shù)據(jù)依賴關(guān)系鬓椭,根據(jù)as-if-serial 語義颠猴,這兩行代碼在實際運行的時候很可能會被重排序過。同樣的小染,對read()
方法來說翘瓮,if(runnig)
和int result = count++;
這兩個語句也沒有數(shù)據(jù)依賴關(guān)系,也會被重排序裤翩。那么對于線程Thread1
和Thread2
來說资盅,語句1
和語句2
被重排序的時候,程序執(zhí)行會出現(xiàn)如下的效果:
在這種情況下踊赠,count++
這句話在 Thread2
里面比在 Thread1
中 count = 1
更早得到了執(zhí)行呵扛,相比于重排序之前,這樣得到的 count
最終的值為1筐带,而不進行重排序的話結(jié)果是2今穿,如此一來,重排序在多線程環(huán)境中破壞了原有的語意烫堤。同樣荣赶,對于語句3
和語句4
凤价,大家也可以對重排序是否會導(dǎo)致線程不安全做出類似的分析(先考慮數(shù)據(jù)依賴關(guān)系和控制流程依賴關(guān)系)。
使用 volatile 關(guān)鍵字保證可見性
為了解決 Java 內(nèi)存模型之中多線程變量可見性的問題拔创,在上一篇文章中利诺,我們可以利用synchronized
互斥鎖的特性來保證多線程之間的變量可見性。
但是之前也有提到剩燥,synchronized
關(guān)鍵字實際上是一種重量級的鎖慢逾,為了在這種情況下優(yōu)化它,我們可以使用volatile
關(guān)鍵字灭红。volatile
關(guān)鍵字可以修飾變量侣滩,一個被其修飾的變量將會具有如下特性:
保證了不同線程對這個變量進行操作時的可見性(一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的)
禁止進行指令重排序
當(dāng)寫一個volatile
變量時变擒,JMM會把該線程對應(yīng)的本地內(nèi)存中的共享變量刷新到主內(nèi)存君珠。另外的,當(dāng)讀一個volatile
變量時娇斑,JMM會把該線程對應(yīng)的本地內(nèi)存置為無效策添,線程接下來將從主內(nèi)存中讀取共享變量。這也是為什么volatile
關(guān)鍵字能夠保證不同線程對同一個變量的可見性毫缆。
關(guān)于volatile
的底層實現(xiàn)唯竹,我不打算深究,但是可以簡要的了解一下:如果把加入volatile
關(guān)鍵字的代碼和未加入volatile
關(guān)鍵字的代碼都生成匯編代碼苦丁,會發(fā)現(xiàn)加入volatile
關(guān)鍵字的代碼會多出一個lock
前綴指令浸颓。
那這個lock
前綴指令是干嘛用的呢?
- 重排序時不能把后面的指令重排序到內(nèi)存屏障之前的位置
- 使得本CPU的 cache 寫入內(nèi)存
- 寫入動作也會引起別的CPU或者別的內(nèi)核無效化其cache旺拉,相當(dāng)于讓新寫入的值對別的線程可見
說了那么多产上,volatile
的使用其實很簡單,讓我們一起來看個demo:
public class VolatileUse {
private volatile boolean running = true; // 對比一下有無 volatile 關(guān)鍵字的時候账阻,運行結(jié)果的差別蒂秘。
void m() {
System.out.println("m start...");
while (running) {
}
System.out.println("m end...");
}
public static void main(String[] args) {
VolatileUse t = new VolatileUse();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
t.running = false;
}
}
在這個小程序中,如果對 running
加上了 volatile
關(guān)鍵字淘太,那么最后處于主線程的操作t.running = false;
將會被 線程t 所看到姻僧,從而打破死循環(huán),使方法m()
正常結(jié)束蒲牧。如果不加關(guān)鍵字撇贺,那么程序?qū)⒁恢笨ㄔ?code>m()方法的死循環(huán)中,永遠也不會輸出m end...
冰抢。
那么volatile
關(guān)鍵字能不能取代synchronized
呢松嘶?我們再來看一個demo:
import java.util.ArrayList;
import java.util.List;
/**
* volatile 關(guān)鍵字,使一個變量在多個線程間可見挎扰。
* volatile 只有可見性翠订,synchronized 既保證了可見性巢音,又保證了原子性,但是效率遠不如 volatile尽超。
*
* @author huangyz0918
*/
public class VolatileUse02 {
volatile int count = 0;
void m() {
for (int i = 0; i < 10000; i++) {
count++;
}
}
public static void main(String[] args) {
VolatileUse02 t = new VolatileUse02();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10; i++) {
threads.add(new Thread(t::m, "thread-" + i));
}
threads.forEach((o) -> o.start());
threads.forEach((o) -> {
try {
o.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(t.count);
}
}
嘗試運行了一下:
94141
再運行一次:
97096
我們可以看到兩次運行的結(jié)果不同官撼,并且都沒有達到理論上所需要達到的目標(biāo)值:100000。這是為什么呢似谁?(count++
語句包含了讀取count
的值傲绣,自增,重新賦值操作)
可以這樣理解:有兩個線程 (線程A 和 線程B) 都對變量count
進行自加操作巩踏,如果某一個時刻線程 A 讀取了count
的值為100秃诵,這時候被阻塞了,因為沒有對變量進行修改塞琼,觸發(fā)不了volatile
的規(guī)則菠净。
線程B 此時也讀讀count
的值,主內(nèi)存里count
的值依舊為100彪杉,做自增嗤练,然后立刻就被寫回主存了,為101在讶。此時又輪到 線程A 執(zhí)行,由于工作內(nèi)存里保存的是100霜大,所以繼續(xù)做自增构哺,再寫回主存,101又被寫了一遍战坤。所以雖然兩個線程執(zhí)行了兩次自增操作曙强,結(jié)果卻只加了一次。
有人說途茫,volatile
不是會使緩存行無效的嗎碟嘴?但是這里從線程A開始讀取count
的值一直到 線程B 也進行操作之前,并沒有修改count
的值囊卜,所以 當(dāng)線程B 讀取的時候娜扇,還是讀的100。
又有人說栅组,線程B將101寫回主內(nèi)存雀瓢,不會把線程A的緩存設(shè)為無效嗎?但是線程A的讀取操作已經(jīng)做過了啊玉掸,只有在做讀取操作時刃麸,發(fā)現(xiàn)自己緩存行無效,才會去讀主內(nèi)存的值司浪,所以這里線程A只能繼續(xù)做自增了泊业。
總的來說把沼,volatile
其實是無法完全替代synchronied
關(guān)鍵字的,因為在某些復(fù)雜的業(yè)務(wù)邏輯里面吁伺,volatile
并不能保證多線程之間的完全同步和操作的原子性饮睬。
使用 synchronized 關(guān)鍵字保證可見性
在看過《深入淺出 Java 并發(fā)編程 (1)》 之后,想必大家都對synchronized
關(guān)鍵字同步鎖的性質(zhì)有所了解了箱蝠,但是關(guān)于為什么synchronized
關(guān)鍵字能夠保證可見性還需要從synchronized
實現(xiàn)的步驟和原理去理解续捂。
在 Java 內(nèi)存模型中,對synchronized
關(guān)鍵字有兩條規(guī)定:
線程解鎖前宦搬,必須把共享變量的最新值刷新到主內(nèi)存中牙瓢。
線程加鎖前,將清空工作內(nèi)存中共享變量的值间校,從而使用共享變量時需要從主內(nèi)存中重新讀取最新的值(注意:加鎖和解鎖需要是同一把鎖)矾克。
這兩條規(guī)定保證了線程解鎖前對共享變量的修改在下次加鎖時對其他線程可見,從而實現(xiàn)了可見性憔足,我們再來看一下synchronized
加鎖前后代碼具體的實現(xiàn)步驟:
- 獲得互斥鎖
- 清空工作內(nèi)存
- 從主內(nèi)存拷貝變量的最新副本到工作內(nèi)存
- 執(zhí)行代碼
- 將更改后的共享變量的值刷新到主內(nèi)存
- 釋放互斥鎖
保證可見性的步驟顯而易見胁附。
synchronized 和 volatile 關(guān)鍵字的異同
最后我們再來聊一聊這兩個關(guān)鍵字的異同,這在很多互聯(lián)網(wǎng)公司面試的過程中都屬于熱門考點滓彰。
簡要總結(jié)概括如下:
volatile
不需要加鎖控妻,比synchronized
更輕量級,不會阻塞線程揭绑。- 從內(nèi)存可見性角度弓候,
volatile
讀相當(dāng)于加鎖,volatile
寫相當(dāng)于解鎖他匪。synchronized
既能保證可見性菇存,又能保證原子性,而volatile
只能保證可見性邦蜜,無法保證原子性依鸥。volatile
只能修飾變量,synchronized
還可修飾方法悼沈。
關(guān)于所謂線程阻塞和死鎖以及相關(guān)的問題和解決方法贱迟,我們將在以后的文章中具體介紹。
相關(guān)閱讀:
本教程純屬原創(chuàng)井辆,轉(zhuǎn)載請聲明
本文提供的鏈接若是失效請及時聯(lián)系作者更新