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

volatile關(guān)鍵字
volatile是老生常談的一個(gè)關(guān)鍵字,大家在編程中其實(shí)用得都很少,面試中比較常見(jiàn),也正是這個(gè)原因,讓大家對(duì)這一塊的理解與實(shí)際結(jié)果產(chǎn)生了偏差。
volatile是Java虛擬機(jī)提供的輕量級(jí)的同步機(jī)制。volatile關(guān)鍵字有如下兩個(gè)作用。
1)保證被volatile修飾的共享變量對(duì)所有線(xiàn)程 總是可見(jiàn)的,也就是當(dāng)一個(gè)線(xiàn)程修改了一個(gè)被volatile修飾共享變量的值,新值總是可以被其他線(xiàn)程立即得知。
2)禁止指令重排序優(yōu)化。
可見(jiàn)性
關(guān)于內(nèi)存模型和volatile的概念本篇不做詳細(xì)贅述,不熟悉的看官建議先百度一下。JMM是圍繞 原子性、有序性、可見(jiàn)性 展開(kāi)的,本文主要圍繞內(nèi)存模型的可見(jiàn)性出發(fā),通過(guò)實(shí)際例子來(lái)探究其運(yùn)行原理。
先思考一個(gè)問(wèn)題:volatile保證的“立即可見(jiàn)”的反義是什么?
這是大家最容易想到的答案,應(yīng)該是“不可見(jiàn)”,且有實(shí)實(shí)在在的例子讓我們覺(jué)得“不可見(jiàn)”深根不移。
示例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)行過(guò)這兩段(或者相似的)代碼,大部分人對(duì)結(jié)果很滿(mǎn)意,因?yàn)榉项A(yù)期,沒(méi)有加volatile關(guān)鍵字的成員變量多線(xiàn)程之間不可見(jiàn)。
回到剛剛那個(gè)問(wèn)題,“立即可見(jiàn)”的反義是什么?
通過(guò)上述實(shí)踐我們可以“肯定”的回答:“立即可見(jiàn)”的反義是“不可見(jiàn)”?。。《沂恰耙恢辈豢梢?jiàn)”
說(shuō)到這里,可能有部分人有疑問(wèn)了,“立即可見(jiàn)”的反義應(yīng)該是“不立即可見(jiàn)”,說(shuō)人話(huà)就是“可能過(guò)一段時(shí)間后可見(jiàn),不一定是馬上可見(jiàn)”。可是即使我們運(yùn)行一萬(wàn)遍示例1的代碼,都是一直不可見(jiàn)。怎么辦?繼續(xù)往下看。
實(shí)戰(zhàn)
讓沒(méi)有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:主線(xiàn)程可能由于停頓時(shí)間太短,導(dǎo)致while循環(huán)根本沒(méi)進(jìn)去。重試幾次,當(dāng)i的值不為0即代表已經(jīng)進(jìn)入循環(huán)。
對(duì)比示例1和示例3我們可以得出一個(gè)結(jié)論:
- 當(dāng)主線(xiàn)程停頓時(shí)間很極短(1~2ms)時(shí),可以跳出循環(huán);
- 當(dāng)主線(xiàn)程停頓時(shí)間較長(zhǎng)時(shí),無(wú)法跳出循環(huán);
結(jié)論變種1:
- 當(dāng)子線(xiàn)程循環(huán)執(zhí)行時(shí)間極短(1~2ms)時(shí),可以跳出循環(huán);
- 當(dāng)子線(xiàn)程循環(huán)執(zhí)行時(shí)間較長(zhǎng)時(shí),無(wú)法跳出循環(huán);
結(jié)論變種2:
- 當(dāng)子線(xiàn)程循環(huán)次數(shù)較少時(shí),可以跳出循環(huán);
- 當(dāng)子線(xiàn)程循環(huán)次數(shù)較多時(shí),無(wú)法跳出循環(huán);
看上去是不是有點(diǎn)意思?代碼的執(zhí)行結(jié)果居然跟執(zhí)行時(shí)間、循環(huán)次數(shù)有關(guān)?推斷到這里,有些看官可能已經(jīng)想到了JIT即使編譯優(yōu)化。沒(méi)錯(cuò),正是JIT的優(yōu)化對(duì)運(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)行過(guò)程中會(huì)被即時(shí)編譯器編譯的“熱點(diǎn)代碼”有兩類(lèi):
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都無(wú)法填出循環(huán)。
總結(jié)一下:mac下默認(rèn)為-Xmixed混合模式,使用java -version可以查看,混合模式下只有熱點(diǎn)代碼達(dá)到一定閾值才會(huì)發(fā)生JIT優(yōu)化,因此導(dǎo)致了上述看到的運(yùn)行時(shí)間長(zhǎng)短對(duì)運(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)神奇吧?
為了找出原因,我對(duì)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)。為什么呢?究竟是什么影響了線(xiàn)程對(duì)成員變量的可見(jiàn)性呢?我的結(jié)論如下:
根據(jù)java的內(nèi)存模型規(guī)范,一個(gè)線(xiàn)程對(duì)普通變量的修改并不需要立即寫(xiě)回到主存,且另一個(gè)線(xiàn)程讀取也不需要每一次都從主存中去讀取。至于什么時(shí)候與主內(nèi)存同步,虛擬機(jī)只需保證方法出棧時(shí)將修改的值同步到主內(nèi)存。因此這其中有比較寬松的優(yōu)化空間。而上述幾個(gè)方法,都存在一定的同步空間。虛擬機(jī)會(huì)在此時(shí)與主內(nèi)存同步。
ps:以上結(jié)論純屬猜測(cè),沒(méi)有很好的論據(jù),歡迎大家探討!
volatile的傳播范圍
思考兩個(gè)問(wèn)題:
- 把volatile對(duì)象傳遞給另一個(gè)對(duì)象,新對(duì)象是否立即可見(jiàn)呢?
- 當(dāng)volatile修飾對(duì)象時(shí),如果對(duì)象的嵌套的層級(jí)較深,那該對(duì)象的內(nèi)部是否立即可見(jiàn)呢?
示例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中,使用了引用嵌套的方式來(lái)驗(yàn)證volatile是否可以傳遞給一個(gè)局部變量,示例中的引用都是用來(lái)volatile關(guān)鍵字來(lái)修飾,運(yùn)行結(jié)果是無(wú)法跳出。
結(jié)論一:當(dāng)使用一個(gè)變量來(lái)接受一個(gè)volatile修飾的變量時(shí),volatile的可見(jiàn)性并不會(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è)多層嵌套的對(duì)象,只有最外層使用volatile修飾,當(dāng)其內(nèi)部的值改變后,使用鏈?zhǔn)秸{(diào)用的方式,則一直可以取到最新的值。
結(jié)論二:對(duì)于多層嵌套的對(duì)象,最外層使用volatile修飾,使用鏈?zhǔn)秸{(diào)用的方式,volatile的可見(jiàn)性可以傳播。
ps:結(jié)論二沒(méi)有很好的理論依據(jù),僅從實(shí)踐上看是如此。
總結(jié)
本篇結(jié)合實(shí)際的幾個(gè)例子,講述了幾個(gè)認(rèn)識(shí)誤區(qū)。僅通過(guò)運(yùn)行結(jié)果說(shuō)明了一些問(wèn)題,但依然不夠深入,不足之處,還望指出。想深入探究的看官,可以參考下面的幾篇文章。