前幾天路過一個(gè)經(jīng)常負(fù)責(zé)面試的同事附近,看到幾個(gè)人在討論volatile的可見性問題,當(dāng)時(shí)第一感覺是 :“可見性還不簡單嗎?volatile修飾一個(gè)變量時(shí),那么在一個(gè)線程都對這個(gè)變量的更改,其他線程都立即可見?!?/p>
后面聽到這樣一句話:“實(shí)際運(yùn)行結(jié)果能刷新你的三觀,網(wǎng)上的例子很多都是有問題的”,讓我瞬間產(chǎn)生了興趣。湊近一看,果然跟我的很多認(rèn)知都產(chǎn)生了偏差。
為了解決其中的疑惑,查閱的不少文章,撥開了一些迷霧,現(xiàn)將結(jié)果整理出來,與大家一同探討。
基礎(chǔ)Java環(huán)境:
java version "1.8.0_172" Java(TM) SE Runtime Environment (build 1.8.0_172-b11) Java HotSpot(TM) 64-Bit Server VM (build 25.172-b11, mixed mode)
基本概念
Java內(nèi)存模型
首先先復(fù)習(xí)一下內(nèi)存模型的概念:
Java內(nèi)存模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,并不真實(shí)存在,它描述的是一組規(guī)則或規(guī)范,通過這組規(guī)范 定義了程序中各個(gè)變量(包括實(shí)例字段,靜態(tài)字段和構(gòu)成數(shù)組對象的元素)的訪問方式。
JVM程序運(yùn)行的實(shí)體是線程,而每個(gè)線程創(chuàng)建時(shí)JVM都會(huì)為其創(chuàng)建一個(gè)工作內(nèi)存(有些地方稱為棧空間),用于存儲(chǔ)線程私有的數(shù)據(jù),而Java內(nèi)存模型中規(guī)定所有變量都存儲(chǔ)在主內(nèi)存,主內(nèi)存是共享內(nèi)存區(qū)域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內(nèi)存中進(jìn)行,首先要將變量從主內(nèi)存拷貝的自己的工作內(nèi)存空間,然后對變量進(jìn)行操作,操作完成后再將變量寫回主內(nèi)存,不能直接操作主內(nèi)存中的變量,工作內(nèi)存中存儲(chǔ)著主內(nèi)存中的變量副本拷貝,前面說過,工作內(nèi)存是每個(gè)線程的私有數(shù)據(jù)區(qū)域,因此不同的線程間無法訪問對方的工作內(nèi)存,線程間的通信(傳值)必須通過主內(nèi)存來完成,其簡要訪問過程如下圖:

volatile關(guān)鍵字
volatile是老生常談的一個(gè)關(guān)鍵字,大家在編程中其實(shí)用得都很少,面試中比較常見,也正是這個(gè)原因,讓大家對這一塊的理解與實(shí)際結(jié)果產(chǎn)生了偏差。
volatile是Java虛擬機(jī)提供的輕量級的同步機(jī)制。volatile關(guān)鍵字有如下兩個(gè)作用。
1)保證被volatile修飾的共享變量對所有線程 總是可見的,也就是當(dāng)一個(gè)線程修改了一個(gè)被volatile修飾共享變量的值,新值總是可以被其他線程立即得知。
2)禁止指令重排序優(yōu)化。
可見性
關(guān)于內(nèi)存模型和volatile的概念本篇不做詳細(xì)贅述,不熟悉的看官建議先百度一下。JMM是圍繞 原子性、有序性、可見性 展開的,本文主要圍繞內(nèi)存模型的可見性出發(fā),通過實(shí)際例子來探究其運(yùn)行原理。
先思考一個(gè)問題:volatile保證的“立即可見”的反義是什么?
這是大家最容易想到的答案,應(yīng)該是“不可見”,且有實(shí)實(shí)在在的例子讓我們覺得“不可見”深根不移。
示例1:
package com.youzan;
/**
* Date: 2018/8/12
* @author xuzhiyi
*/
public class Test1 {
private static boolean flag = true;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (flag) {
i++;
}
System.out.printf("**********test1 跳出成功, i=%d **********\n", i);
});
thread.start();
Thread.sleep(100);
flag = false;
System.out.printf("**********test1 main thread 結(jié)束, i=%d **********\n", i);
}
}
示例2:
package com.youzan;
/**
* Date: 2018/8/12
* @author xuzhiyi
*/
public class Test2 {
private static volatile boolean flag = true;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (flag) {
i++;
}
System.out.printf("**********test2 跳出成功, i=%d **********\n", i);
});
thread.start();
Thread.sleep(100);
flag = false;
System.out.printf("**********test2 main thread 結(jié)束, i=%d **********\n", i);
}
}
示例1和示例2的唯一區(qū)別在于,示例2的flag有volatile修飾。上述示例的運(yùn)行結(jié)果大家都“知道”,示例1會(huì)一直死循環(huán),示例2會(huì)立即跳出循環(huán)。大家可能都運(yùn)行過這兩段(或者相似的)代碼,大部分人對結(jié)果很滿意,因?yàn)榉项A(yù)期,沒有加volatile關(guān)鍵字的成員變量多線程之間不可見。
回到剛剛那個(gè)問題,“立即可見”的反義是什么?
通過上述實(shí)踐我們可以“肯定”的回答:“立即可見”的反義是“不可見”?。。《沂恰耙恢辈豢梢姟?/p>
說到這里,可能有部分人有疑問了,“立即可見”的反義應(yīng)該是“不立即可見”,說人話就是“可能過一段時(shí)間后可見,不一定是馬上可見”??墒羌词刮覀冞\(yùn)行一萬遍示例1的代碼,都是一直不可見。怎么辦?繼續(xù)往下看。
實(shí)戰(zhàn)
讓沒有volatile也能跳出循環(huán)
方式一
示例3:
package com.youzan;
/**
* Date: 2018/8/12
* @author xuzhiyi
*/
public class Test3 {
private static boolean flag = true;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (flag) {
i++;
}
System.out.printf("**********test3 跳出成功, i=%d **********\n", i);
});
thread.start();
Thread.sleep(1);
flag = false;
System.out.printf("**********test3 main thread 結(jié)束, i=%d **********\n", i);
}
}
在示例3中,我僅將示例1中的sleep時(shí)間改為1毫秒,while循環(huán)即可成功跳出,輸出結(jié)果如下:
**********test3 main thread 結(jié)束, i=60167 **********
**********test3 跳出成功, i=60167 **********
ps:主線程可能由于停頓時(shí)間太短,導(dǎo)致while循環(huán)根本沒進(jìn)去。重試幾次,當(dāng)i的值不為0即代表已經(jīng)進(jìn)入循環(huán)。
對比示例1和示例3我們可以得出一個(gè)結(jié)論:
- 當(dāng)主線程停頓時(shí)間很極短(1~2ms)時(shí),可以跳出循環(huán);
- 當(dāng)主線程停頓時(shí)間較長時(shí),無法跳出循環(huán);
結(jié)論變種1:
- 當(dāng)子線程循環(huán)執(zhí)行時(shí)間極短(1~2ms)時(shí),可以跳出循環(huán);
- 當(dāng)子線程循環(huán)執(zhí)行時(shí)間較長時(shí),無法跳出循環(huán);
結(jié)論變種2:
- 當(dāng)子線程循環(huán)次數(shù)較少時(shí),可以跳出循環(huán);
- 當(dāng)子線程循環(huán)次數(shù)較多時(shí),無法跳出循環(huán);
看上去是不是有點(diǎn)意思?代碼的執(zhí)行結(jié)果居然跟執(zhí)行時(shí)間、循環(huán)次數(shù)有關(guān)?推斷到這里,有些看官可能已經(jīng)想到了JIT即使編譯優(yōu)化。沒錯(cuò),正是JIT的優(yōu)化對運(yùn)行結(jié)果產(chǎn)生了影響。
關(guān)于JIT
當(dāng)虛擬機(jī)發(fā)現(xiàn)某個(gè)方法或代碼塊運(yùn)行特別頻繁時(shí),就會(huì)把這些代碼認(rèn)定為“Hot Spot Code”(熱點(diǎn)代碼),為了提高熱點(diǎn)代碼的執(zhí)行效率,在運(yùn)行時(shí),虛擬機(jī)將會(huì)把這些代碼編譯成與本地平臺(tái)相關(guān)的機(jī)器碼,并進(jìn)行各層次的優(yōu)化,完成這項(xiàng)任務(wù)的正是 JIT 編譯器。
運(yùn)行過程中會(huì)被即時(shí)編譯器編譯的“熱點(diǎn)代碼”有兩類:
1)被多次調(diào)用的方法。
2)被多次調(diào)用的循環(huán)體。
如何驗(yàn)證上述結(jié)論呢?
- -Xint :強(qiáng)制使用解釋執(zhí)行的方式啟動(dòng)java虛擬機(jī),此模式下,不會(huì)使用JIT優(yōu)化,示例1和示例3的代碼都會(huì)跳出循環(huán)。
- -Xcomp:強(qiáng)制使用編譯執(zhí)行的方式啟動(dòng)java虛擬機(jī),此模式下,代碼會(huì)被優(yōu)化并編譯成機(jī)器碼,示例1和示例3都無法填出循環(huán)。
總結(jié)一下:mac下默認(rèn)為-Xmixed混合模式,使用java -version可以查看,混合模式下只有熱點(diǎn)代碼達(dá)到一定閾值才會(huì)發(fā)生JIT優(yōu)化,因此導(dǎo)致了上述看到的運(yùn)行時(shí)間長短對運(yùn)行結(jié)果的影響。
方式二
不少熱心的網(wǎng)友在自己運(yùn)行示例1代碼的時(shí)候,會(huì)不由自主的加上一行print,如下:
示例4:
package com.youzan;
/**
* Date: 2018/8/12
* @author xuzhiyi
*/
public class Test4 {
private static boolean flag = true;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (flag) {
i++;
System.out.println("i=" + i);
}
System.out.printf("**********test4 跳出成功, i=%d **********\n", i);
});
thread.start();
Thread.sleep(100);
flag = false;
System.out.printf("**********test4 main thread 結(jié)束, i=%d **********\n", i);
}
}
上述代碼一運(yùn)行后成功跳出,可能又驚倒了一批看官,為什么多一行print結(jié)果又不一樣了。而且就算在-Xcomp模式優(yōu)化后也可以跳出。有點(diǎn)神奇吧?
為了找出原因,我對print代碼進(jìn)行了幾次不同的替換:
示例5:
package com.youzan;
import java.util.HashMap;
/**
* Date: 2018/8/12
* @author xuzhiyi
*/
public class Test5 {
private static boolean flag = true;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (flag) {
doSomeThing1();
}
System.out.printf("**********test4 跳出成功, i=%d **********\n", i);
});
thread.start();
Thread.sleep(10);
flag = false;
System.out.printf("**********test4 main thread 結(jié)束, i=%d **********\n", i);
}
private static void doSomeThing1() {
System.out.println("doSomeThing1");
}
private static void doSomeThing2() {
synchronized (Test5.class) {
i++;
}
}
private static void doSomeThing3() {
i++;
Thread.yield();
}
private static void doSomeThing4() {
new HashMap<>();
}
}
上述代碼中,不論是在循環(huán)體內(nèi)執(zhí)行哪一個(gè)方法(doSomeThing1~ doSomeThing4),都可以正常跳出循環(huán)。為什么呢?究竟是什么影響了線程對成員變量的可見性呢?我的結(jié)論如下:
根據(jù)java的內(nèi)存模型規(guī)范,一個(gè)線程對普通變量的修改并不需要立即寫回到主存,且另一個(gè)線程讀取也不需要每一次都從主存中去讀取。至于什么時(shí)候與主內(nèi)存同步,虛擬機(jī)只需保證方法出棧時(shí)將修改的值同步到主內(nèi)存。因此這其中有比較寬松的優(yōu)化空間。而上述幾個(gè)方法,都存在一定的同步空間。虛擬機(jī)會(huì)在此時(shí)與主內(nèi)存同步。
ps:以上結(jié)論純屬猜測,沒有很好的論據(jù),歡迎大家探討!
volatile的傳播范圍
思考兩個(gè)問題:
- 把volatile對象傳遞給另一個(gè)對象,新對象是否立即可見呢?
- 當(dāng)volatile修飾對象時(shí),如果對象的嵌套的層級較深,那該對象的內(nèi)部是否立即可見呢?
示例6:
package com.youzan;
/**
* Date: 2018/8/12
* @author xuzhiyi
*/
public class Test6 {
private static volatile ReferenceFlag referenceFlag = new ReferenceFlag();
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
BaseFlag baseFlag = referenceFlag.baseFlag;
while (baseFlag.flag) {
i++;
}
System.out.printf("**********test6 跳出成功, i=%d **********\n", i);
});
thread.start();
Thread.sleep(100);
referenceFlag.baseFlag.flag = false;
System.out.printf("**********test6 main thread 結(jié)束, i=%d **********\n", i);
}
static class BaseFlag {
boolean flag = true;
}
static class ReferenceFlag {
volatile BaseFlag baseFlag = new BaseFlag();
}
}
在示例6中,使用了引用嵌套的方式來驗(yàn)證volatile是否可以傳遞給一個(gè)局部變量,示例中的引用都是用來volatile關(guān)鍵字來修飾,運(yùn)行結(jié)果是無法跳出。
結(jié)論一:當(dāng)使用一個(gè)變量來接受一個(gè)volatile修飾的變量時(shí),volatile的可見性并不會(huì)傳遞。即新的變量不再具有volatile特性。
示例2:
package com.youzan;
/**
* Date: 2018/8/12
* @author xuzhiyi
*/
public class Test7 {
private static int i = 0;
private static volatile DeapReferenceInnerFlag deapReferenceInnerFlag = new DeapReferenceInnerFlag();
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (deapReferenceInnerFlag.referenceInnerFlag.baseFlag.flag) {
i++;
}
System.out.printf("**********test7 跳出成功, i=%d **********\n", i);
});
thread.start();
Thread.sleep(100);
deapReferenceInnerFlag.referenceInnerFlag.baseFlag.flag = false;
System.out.printf("**********test7 main thread 結(jié)束, i=%d **********\n", i);
}
static class BaseFlag {
boolean flag = true;
}
static class ReferenceInnerFlag {
BaseFlag baseFlag = new BaseFlag();
}
static class DeapReferenceInnerFlag {
ReferenceInnerFlag referenceInnerFlag = new ReferenceInnerFlag();
}
}
示例7是一個(gè)多層嵌套的對象,只有最外層使用volatile修飾,當(dāng)其內(nèi)部的值改變后,使用鏈?zhǔn)秸{(diào)用的方式,則一直可以取到最新的值。
結(jié)論二:對于多層嵌套的對象,最外層使用volatile修飾,使用鏈?zhǔn)秸{(diào)用的方式,volatile的可見性可以傳播。
ps:結(jié)論二沒有很好的理論依據(jù),僅從實(shí)踐上看是如此。
總結(jié)
本篇結(jié)合實(shí)際的幾個(gè)例子,講述了幾個(gè)認(rèn)識(shí)誤區(qū)。僅通過運(yùn)行結(jié)果說明了一些問題,但依然不夠深入,不足之處,還望指出。想深入探究的看官,可以參考下面的幾篇文章。