本文目錄
- 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)存模型,實(shí)際上指的是 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í)例數(shù)據(jù)部分
- 線程工作內(nèi)存 (本地內(nèi)存):對應(yīng)于虛擬機(jī)棧中的部分區(qū)域,是JMM的一個抽象概念,并不真實(shí)存在
在理解這兩個內(nèi)存的時候,我曾一直想把他們和之前提到過的 堆內(nèi)存 和 棧內(nèi)存 進(jìn)行比較,但是實(shí)際上來說,主內(nèi)存和工作內(nèi)存與堆、棧內(nèi)存并沒有什么直接的聯(lián)系。關(guān)于這幾種內(nèi)存聯(lián)系的爭論,可以參考這個知乎問答:
JVM中內(nèi)存模型里的『主內(nèi)存』是不是就是指『堆』,而『工作內(nèi)存』是不是就是指『?!唬?/a>
言歸正傳,我們可以用一個簡單的抽象示意圖來理解 Java 內(nèi)存模型:

從上面的圖可以看到,假設(shè)有三個線程Thread1、Thread2 和 Thread3,它們在運(yùn)行的過程中都會對變量 a 進(jìn)行一定程度的操作,這些操作都是基于 JMM 給出的規(guī)定:
- 所有的變量都存儲在主內(nèi)存中
- 每個線程都有自己獨(dú)立的工作內(nèi)存,里面保存該線程使用到的變量的副本(主內(nèi)存中該變量的一份拷貝)
- 線程對共享變量的所有操作都必須在自己的工作內(nèi)存中進(jìn)行,不能直接從主內(nèi)存中讀寫
- 不同線程之間無法直接訪問其他線程工作內(nèi)存中的變量,線程間變量值的傳遞需要通過主內(nèi)存來完成。
也就是說,線程想要對變量 a 進(jìn)行操作,首先得從主內(nèi)存之中獲取一個 a 的副本,然后在自己的本地內(nèi)存(工作內(nèi)存)之中對 a 的副本進(jìn)行修改。當(dāng)修改操作完成以后,再將本地內(nèi)存中的 “新版a” 更新到主內(nèi)存之中。
說了這么多,這些東西和可見性有什么關(guān)系呢?我們先看下面的圖:

在圖中,一開始Thread1和Thread2都從主內(nèi)存中獲取了共享變量a的一個副本:a1和a2,它們的初始值滿足:a1 = a2 = a = 0,但是隨著線程操作的進(jìn)行,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)存造成的不可見性,指令重排序也會對線程間的語意和運(yùn)行結(jié)果造成一定程度的影響。那么,什么是重排序?
以前有一句古話 “所見即所得” ,但是在計(jì)算機(jī)程序執(zhí)行的時候卻不是這個樣子的,為了提高程序的性能,編譯器或處理器會對程序執(zhí)行的順序進(jìn)行優(yōu)化,使得代碼書寫的順序與實(shí)際執(zhí)行的順序未必相同。

而計(jì)算機(jī)程序重排序主要又可以分為以下幾類:
- 編譯器優(yōu)化的重排序(編譯器優(yōu)化)
- 指令集并行重排序(處理器優(yōu)化)
- 內(nèi)存系統(tǒng)的重排序(處理器優(yōu)化)
雖然代碼執(zhí)行不一定按照其書寫順序執(zhí)行,但是為了保證在單線程中代碼最終輸出結(jié)果不會因?yàn)橹噶钪嘏判蚨淖儯幾g器、運(yùn)行時環(huán)境和處理器都會遵循一定的規(guī)范,這里主要是指 as-if-serial語義 和 happens- before的程序順序規(guī)則。
as-if-serial語義: 不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執(zhí)行結(jié)果不能被改變。
為了遵守as-if-serial語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關(guān)系的操作做重排序,因?yà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ù)的依賴性,因?yàn)榈谝恍械诙械某晒\(yùn)行不需要對方的計(jì)算結(jié)果,但是第三行C的計(jì)算結(jié)果卻是依賴于A和B的。這個依賴關(guān)系可以用下面的示意圖表示:

所以根據(jù)依賴關(guān)系,as-if-serial語義將會允許上述程序的第一行和第二行進(jìn)行重排序,而第三行的執(zhí)行一定會放在前兩行程序之后。as-if-serial 語義把單線程程序保護(hù)了起來,遵守as-if-serial語義的編譯器、運(yùn)行時環(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 進(jìn)行寫入,然后Thread2 隨即執(zhí)行read()方法,那么,當(dāng)Thread2運(yùn)行到第四行的時候,是否能夠看到Thread1對變量count進(jìn)行的寫入操作呢?
答案是不一定能夠看得見。
我們對write()來分析,語句1和語句2實(shí)際上并沒有數(shù)據(jù)依賴關(guān)系,根據(jù)as-if-serial 語義,這兩行代碼在實(shí)際運(yùn)行的時候很可能會被重排序過。同樣的,對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,而不進(jìn)行重排序的話結(jié)果是2,如此一來,重排序在多線程環(huán)境中破壞了原有的語意。同樣,對于語句3和語句4,大家也可以對重排序是否會導(dǎo)致線程不安全做出類似的分析(先考慮數(shù)據(jù)依賴關(guān)系和控制流程依賴關(guān)系)。
使用 volatile 關(guān)鍵字保證可見性
為了解決 Java 內(nèi)存模型之中多線程變量可見性的問題,在上一篇文章中,我們可以利用synchronized互斥鎖的特性來保證多線程之間的變量可見性。
但是之前也有提到,synchronized關(guān)鍵字實(shí)際上是一種重量級的鎖,為了在這種情況下優(yōu)化它,我們可以使用volatile關(guān)鍵字。volatile關(guān)鍵字可以修飾變量,一個被其修飾的變量將會具有如下特性:
保證了不同線程對這個變量進(jìn)行操作時的可見性(一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的)
禁止進(jì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的底層實(shí)現(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的使用其實(shí)很簡單,讓我們一起來看個demo:
public class VolatileUse {
private volatile boolean running = true; // 對比一下有無 volatile 關(guān)鍵字的時候,運(yù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)中,永遠(yuǎn)也不會輸出m end...。
那么volatile關(guān)鍵字能不能取代synchronized呢?我們再來看一個demo:
import java.util.ArrayList;
import java.util.List;
/**
* volatile 關(guān)鍵字,使一個變量在多個線程間可見。
* volatile 只有可見性,synchronized 既保證了可見性,又保證了原子性,但是效率遠(yuǎn)不如 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);
}
}
嘗試運(yùn)行了一下:
94141
再運(yùn)行一次:
97096
我們可以看到兩次運(yùn)行的結(jié)果不同,并且都沒有達(dá)到理論上所需要達(dá)到的目標(biāo)值:100000。這是為什么呢?(count++語句包含了讀取count的值,自增,重新賦值操作)
可以這樣理解:有兩個線程 (線程A 和 線程B) 都對變量count進(jìn)行自加操作,如果某一個時刻線程 A 讀取了count的值為100,這時候被阻塞了,因?yàn)闆]有對變量進(jìn)行修改,觸發(fā)不了volatile的規(guī)則。
線程B 此時也讀讀count的值,主內(nèi)存里count的值依舊為100,做自增,然后立刻就被寫回主存了,為101。此時又輪到 線程A 執(zhí)行,由于工作內(nèi)存里保存的是100,所以繼續(xù)做自增,再寫回主存,101又被寫了一遍。所以雖然兩個線程執(zhí)行了兩次自增操作,結(jié)果卻只加了一次。
有人說,volatile不是會使緩存行無效的嗎?但是這里從線程A開始讀取count的值一直到 線程B 也進(jìn)行操作之前,并沒有修改count的值,所以 當(dāng)線程B 讀取的時候,還是讀的100。
又有人說,線程B將101寫回主內(nèi)存,不會把線程A的緩存設(shè)為無效嗎?但是線程A的讀取操作已經(jīng)做過了啊,只有在做讀取操作時,發(fā)現(xiàn)自己緩存行無效,才會去讀主內(nèi)存的值,所以這里線程A只能繼續(xù)做自增了。
總的來說,volatile其實(shí)是無法完全替代synchronied關(guān)鍵字的,因?yàn)樵谀承?fù)雜的業(yè)務(wù)邏輯里面,volatile并不能保證多線程之間的完全同步和操作的原子性。
使用 synchronized 關(guān)鍵字保證可見性
在看過《深入淺出 Java 并發(fā)編程 (1)》 之后,想必大家都對synchronized關(guān)鍵字同步鎖的性質(zhì)有所了解了,但是關(guān)于為什么synchronized關(guān)鍵字能夠保證可見性還需要從synchronized實(shí)現(xiàn)的步驟和原理去理解。
在 Java 內(nèi)存模型中,對synchronized關(guān)鍵字有兩條規(guī)定:
線程解鎖前,必須把共享變量的最新值刷新到主內(nèi)存中。
線程加鎖前,將清空工作內(nèi)存中共享變量的值,從而使用共享變量時需要從主內(nèi)存中重新讀取最新的值(注意:加鎖和解鎖需要是同一把鎖)。
這兩條規(guī)定保證了線程解鎖前對共享變量的修改在下次加鎖時對其他線程可見,從而實(shí)現(xiàn)了可見性,我們再來看一下synchronized加鎖前后代碼具體的實(shí)現(xiàn)步驟:
- 獲得互斥鎖
- 清空工作內(nèi)存
- 從主內(nèi)存拷貝變量的最新副本到工作內(nèi)存
- 執(zhí)行代碼
- 將更改后的共享變量的值刷新到主內(nèi)存
- 釋放互斥鎖
保證可見性的步驟顯而易見。
synchronized 和 volatile 關(guān)鍵字的異同
最后我們再來聊一聊這兩個關(guān)鍵字的異同,這在很多互聯(lián)網(wǎng)公司面試的過程中都屬于熱門考點(diǎn)。
簡要總結(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)系作者更新