
Java中volatile這個(gè)熱門的關(guān)鍵字,在面試中經(jīng)常會(huì)被提及,在各種技術(shù)交流群中也經(jīng)常被討論:volatile關(guān)鍵字在java多線程中有著比較重要作用,volatile主要作用是可以保持變量在多線程中是實(shí)時(shí)可見的,是java中提供的最輕量的同步機(jī)制。
一、JAVA內(nèi)存模型概述
在了解volatile關(guān)鍵字之前,我們先來認(rèn)識一下Java的內(nèi)存模型。
Java線程之間的通信由Java內(nèi)存模型(本文簡稱為JMM)控制,JMM決定一個(gè)線程對共享變量的寫入何時(shí)對另一個(gè)線程可見。從抽象的角度來看,JMM定義了線程和主內(nèi)存之間的抽象關(guān)系:線程之間的共享變量存儲在主內(nèi)存(Main Memory)中,每個(gè)線程都有一個(gè)私有的本地內(nèi)存(Local Memory),本地內(nèi)存中存儲了該線程以讀/寫共享變量的副本。本地內(nèi)存是JMM的一個(gè)抽象概念,并不真實(shí)存在。它涵蓋了緩存、寫緩沖區(qū)、寄存器以及其他的硬件和編譯器優(yōu)化。Java內(nèi)存模型的抽象示意如圖所示

如果線程A與線程B之間要通信的話,必須要經(jīng)歷下面2個(gè)步驟。
1)線程A把本地內(nèi)存A中更新過的共享變量刷新到主內(nèi)存中去。
2)線程B到主內(nèi)存中去讀取線程A之前已更新過的共享變量。
這個(gè)模型在單線程中沒有什么問題,但是在多線程中就會(huì)產(chǎn)生一些數(shù)據(jù)的“臟讀”等問題。
舉個(gè)簡單的例子:在java中,使用兩個(gè)線程執(zhí)行下面這個(gè)語句:
int i = 0;
i = i + 1;
我們期望的是在兩個(gè)線程執(zhí)行完之后獲得i的結(jié)果是2,但事實(shí)真的會(huì)這樣的嗎?
我們反復(fù)執(zhí)行后可以發(fā)現(xiàn):結(jié)果可能是2,也可能是1。
每條線程執(zhí)行時(shí)需要將i的值從主內(nèi)存中讀取到工作內(nèi)存中。其中存在這么一種情況:初始時(shí),兩個(gè)線程分別讀取i的值存入各自所在的工作內(nèi)存當(dāng)中,然后線程1進(jìn)行加1操作,然后把i的最新值1寫入到內(nèi)存。此時(shí)線程2的工作內(nèi)存當(dāng)中i的值還是0,進(jìn)行加1操作之后,i的值為1,然后線程2把i的值寫入內(nèi)存。當(dāng)出現(xiàn)這種情況后返回的結(jié)果就成了1了。
這就是緩存一致性的問題,在解決這個(gè)問題前我們要先了解一下并發(fā)編程的三個(gè)概念:原子性,有序性,可見性。
二、并發(fā)編程中的三個(gè)概念
1.原子性
定義:原子操作意 為“不可被中斷的一個(gè)或一系列操作。
比如 a=0;(a非long和double類型) 這個(gè)操作是不可分割的,那么我們說這個(gè)操作是原子操作。再比如:a++; 這個(gè)操作實(shí)際是a = a + 1;是可分割的,所以他不是一個(gè)原子操作。非原子操作都會(huì)存在線程安全問題,需要我們使用同步技術(shù)(sychronized)來讓它變成一個(gè)原子操作。如果一個(gè)操作是原子操作,那么我們稱它具有原子性。java的concurrent包下提供了一些原子類,我們可以通過閱讀API來了解這些原子類的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。
如果要實(shí)現(xiàn)更大范圍操作的原子性,可以通過鎖和CAS算法來實(shí)現(xiàn)。
2.可見性
定義:可見性是指當(dāng)一個(gè)線程修改一個(gè)共享變量時(shí),另外一個(gè)線程能讀到這個(gè)修改的值。
舉個(gè)簡單的例子,看下面這段代碼:
//線程1執(zhí)行的代碼
int i = 0;
i = 10;
//線程2執(zhí)行的代碼
j = i;
由上面的分析可知,當(dāng)線程1執(zhí)行 i =10這句時(shí),會(huì)先把i的初始值加載到工作內(nèi)存中,然后賦值為10,那么在線程1的工作內(nèi)存當(dāng)中i的值變?yōu)?0了,卻沒有立即寫入到主存當(dāng)中。
此時(shí)線程2執(zhí)行 j = i,它會(huì)先去主存讀取i的值并加載到線程2的工作內(nèi)存當(dāng)中,注意此時(shí)內(nèi)存當(dāng)中i的值還是0,那么就會(huì)使得j的值為0,而不是10.
這就是可見性問題,線程1對變量i修改了之后,線程2沒有立即看到線程1修改的值。
3.有序性
定義:即程序執(zhí)行的順序按照代碼的先后順序執(zhí)行。
int i = 0;
boolean flag = false;
i = 1; //語句1
flag = true; //語句2
上面代碼定義了一個(gè)int型變量,定義了一個(gè)boolean類型變量,然后分別對兩個(gè)變量進(jìn)行賦值操作。從代碼順序上看,語句1是在語句2前面的,那么JVM在真正執(zhí)行這段代碼的時(shí)候會(huì)保證語句1一定會(huì)在語句2前面執(zhí)行嗎?不一定,為什么呢?這里可能會(huì)發(fā)生指令重排序(Instruction Reorder)。
下面解釋一下什么是指令重排序,一般來說,處理器為了提高程序運(yùn)行效率,可能會(huì)對輸入代碼進(jìn)行優(yōu)化,它不保證程序中各個(gè)語句的執(zhí)行先后順序同代碼中的順序一致,但是它會(huì)保證程序最終執(zhí)行結(jié)果和代碼順序執(zhí)行的結(jié)果是一致的。
比如上面的代碼中,語句1和語句2誰先執(zhí)行對最終的程序結(jié)果并沒有影響,那么就有可能在執(zhí)行過程中,語句2先執(zhí)行而語句1后執(zhí)行。
但是要注意,雖然處理器會(huì)對指令進(jìn)行重排序,但是它會(huì)保證程序最終結(jié)果會(huì)和代碼順序執(zhí)行結(jié)果相同,那么它靠什么保證的呢?再看下面一個(gè)例子:
int a = 10; //語句1
int r = 2; //語句2
a = a + 3; //語句3
r = a*a; //語句4
這段代碼有4個(gè)語句,那么可能的一個(gè)執(zhí)行順序是:

不可能,因?yàn)樘幚砥髟谶M(jìn)行重排序時(shí)是會(huì)考慮指令之間的數(shù)據(jù)依賴性,如果一個(gè)指令I(lǐng)nstruction 2必須用到Instruction 1的結(jié)果,那么處理器會(huì)保證Instruction 1會(huì)在Instruction 2之前執(zhí)行。
雖然重排序不會(huì)影響單個(gè)線程內(nèi)程序執(zhí)行的結(jié)果,但是多線程呢?下面看一個(gè)例子:
//線程1:
context = loadContext(); //語句1
inited = true; //語句2
//線程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代碼中,由于語句1和語句2沒有數(shù)據(jù)依賴性,因此可能會(huì)被重排序。假如發(fā)生了重排序,在線程1執(zhí)行過程中先執(zhí)行語句2,而此是線程2會(huì)以為初始化工作已經(jīng)完成,那么就會(huì)跳出while循環(huán),去執(zhí)行doSomethingwithconfig(context)方法,而此時(shí)context并沒有被初始化,就會(huì)導(dǎo)致程序出錯(cuò)。
從上面可以看出,指令重排序不會(huì)影響單個(gè)線程的執(zhí)行,但是會(huì)影響到線程并發(fā)執(zhí)行的正確性。
也就是說,要想并發(fā)程序正確地執(zhí)行,必須要保證原子性、可見性以及有序性。只要有一個(gè)沒有被保證,就有可能會(huì)導(dǎo)致程序運(yùn)行不正確。
三、深入理解volatile關(guān)鍵字
在多線程并發(fā)編程中synchronized和volatile都扮演著重要的角色,volatile是輕量級的 synchronized。如果volatile變量修飾符使用恰當(dāng) 的話,它比synchronized的使用和執(zhí)行成本更低,因?yàn)樗粫?huì)引起線程上下文的切換和調(diào)度。
1.volatile的作用
一旦一個(gè)共享變量(類的成員變量、類的靜態(tài)成員變量)被volatile修飾之后,那么就具備了兩層語義:
1)保證了內(nèi)存的可見性,即一個(gè)線程修改了某個(gè)變量的值,這新值對其他線程來說是立即可見的。
2)禁止進(jìn)行指令重排序。
1.volatile不能保證原子性
我們來看下面這一段代碼
/*
* i++ 的原子性問題:i++ 的操作實(shí)際上分為三個(gè)步驟“讀-改-寫”
* int i = 10;
* i = i++; //10
*
* int temp = i;
* i = i + 1;
* i = temp;
*/
public class TestAtomicDemo {
public static void main(String[] args) {
AtomicDemo ad = new AtomicDemo();
for (int i = 0; i < 10; i++) {
new Thread(ad).start();
}
}
}
class AtomicDemo implements Runnable{
private volatile int serialNumber = 0;
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
}
System.out.println(getSerialNumber());
}
public int getSerialNumber(){
return serialNumber++;
}
}
反復(fù)運(yùn)行這段代碼,發(fā)現(xiàn)結(jié)果并不是每次都是0-9,而是有可能會(huì)有重復(fù)結(jié)果出現(xiàn)。
自增操作是不具備原子性的,它包括讀取變量的原始值、進(jìn)行加1操作、寫入工作內(nèi)存。那么就是說自增操作的三個(gè)子操作可能會(huì)分割開執(zhí)行:
線程1對變量進(jìn)行讀取操作之后,被阻塞了的話,并沒有對serialNumber值進(jìn)行修改。雖然volatile能保證線程2對變量serialNumber的值讀取是從內(nèi)存中讀取的,但是線程1沒有進(jìn)行修改,所以線程2根本就不會(huì)看到修改的值。
根源就在這里,自增操作不是原子性操作,而且volatile也無法保證對變量的任何操作都是原子性的。那么原子性問題究竟應(yīng)該怎么解決呢?我們在下篇文章中會(huì)給出詳細(xì)解答。
四、volatile關(guān)鍵字的應(yīng)用場景
synchronized關(guān)鍵字是防止多個(gè)線程同時(shí)執(zhí)行一段代碼,那么就會(huì)很影響程序執(zhí)行效率,而volatile關(guān)鍵字在某些情況下性能要優(yōu)于synchronized,但是要注意volatile關(guān)鍵字是無法替代synchronized關(guān)鍵字的,因?yàn)関olatile關(guān)鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個(gè)條件:
1)對變量的寫操作不依賴于當(dāng)前值
2)該變量沒有包含在具有其他變量的不變式中
下面列舉幾個(gè)Java中使用volatile的幾個(gè)場景。
1.狀態(tài)標(biāo)記量
volatile boolean flag = false;
//線程1
while(!flag){
doSomething();
}
//線程2
public void setFlag() {
flag = true;
}
根據(jù)狀態(tài)標(biāo)記,終止線程。
2.單例模式中的double check
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
為什么要使用volatile 修飾instance?
主要在于instance = new Singleton()這句,這并非是一個(gè)原子操作,事實(shí)上在 JVM 中這句話大概做了下面 3 件事情:
1.給 instance 分配內(nèi)存
2.調(diào)用 Singleton 的構(gòu)造函數(shù)來初始化成員變量
3.將instance對象指向分配的內(nèi)存空間(執(zhí)行完這步 instance 就為非 null 了)。
但是在 JVM 的即時(shí)編譯器中存在指令重排序的優(yōu)化。也就是說上面的第二步和第三步的順序是不能保證的,最終的執(zhí)行順序可能是 1-2-3 也可能是 1-3-2。如果是后者,則在 3 執(zhí)行完畢、2 未執(zhí)行之前,被線程二搶占了,這時(shí) instance 已經(jīng)是非 null 了(但卻沒有初始化),所以線程二會(huì)直接返回 instance,然后使用,然后順理成章地報(bào)錯(cuò)。
參考文章
本文作者: catalinaLi
本文鏈接: http://catalinali.top/2018/helloVolatile/