簡(jiǎn)介
在 Java 并發(fā)編程中,volatile 是經(jīng)常用到的一個(gè)關(guān)鍵字,它可以用于保證不同的線程共享一個(gè)變量時(shí)每次都能獲取最新的值。volatile 具有鎖的部分功能并且性能比鎖更好,所以也被稱為輕量級(jí)鎖。下面具體分析 volatile 的用法及原理,涉及到內(nèi)存模型、可見性、重排序以及偽共享等方面。
內(nèi)存模型
在深入理解 volatile 之前,先了解一些計(jì)算機(jī)的內(nèi)存模型。當(dāng) CPU 執(zhí)行運(yùn)算的時(shí)候,需要從內(nèi)存中取數(shù)據(jù),由于 CPU 的運(yùn)算速度遠(yuǎn)遠(yuǎn)快于內(nèi)存的讀取速度,所以 CPU 需要等數(shù)據(jù),這個(gè)過程就浪費(fèi)了 CPU 的時(shí)間。為了提高效率, 在 CPU 和內(nèi)存之間會(huì)有緩存(一般有三級(jí)緩存),緩存的讀寫速度高于內(nèi)存,容量也會(huì)比內(nèi)存小得多。當(dāng) CPU 讀數(shù)據(jù)的時(shí)候會(huì)先從緩存中讀,如果緩存未命中則會(huì)去內(nèi)存讀,并把數(shù)據(jù)放到緩存中,寫數(shù)據(jù)的時(shí)候也會(huì)先寫緩存,在適當(dāng)?shù)臅r(shí)候再將緩存中的數(shù)據(jù)刷新到內(nèi)存中。
緩存的使用提高了 CPU 的運(yùn)行效率,但是對(duì)于多核處理器會(huì)有一些問題。如果某個(gè)內(nèi)存地址的數(shù)據(jù)同時(shí)被兩個(gè) CPU 緩存,其中一個(gè) CPU 修改了這個(gè)地址的值,無論這個(gè)值是寫入到了緩存中還是被刷新到了內(nèi)存中,只要另一個(gè) CPU 依然使用其緩存中的值,那還是舊值。因此對(duì)于多線程來說,需要一些手段來保證數(shù)據(jù)的一致性。
對(duì)于 Java 來說,程序運(yùn)行在 JVM 上,JVM 提供了類似的內(nèi)存抽象模型,如下圖所示。

每個(gè)線程有自己的工作內(nèi)存,相當(dāng)于緩存,所有的線程共享主內(nèi)存,相當(dāng)于系統(tǒng)中的內(nèi)存。線程之間往往會(huì)有共享變量,為了保證共享變量的可見性,需要采用 java 提供的并發(fā)技術(shù)。對(duì)于單個(gè)變量的可見性來說,volatile 是一種有效的機(jī)制。
內(nèi)存可見性
先看下面的一段代碼:
int a = 1;
boolean flag = false;
int b = 3;
// 線程1
a = 2;
flag = true;
// 線程2
if (flag) {
b = a;
}
上面的代碼如果線程 1 執(zhí)行后,線程 2 中的 flag 能立刻看到 flag 的新值嗎?根據(jù)上面介紹的 Java 內(nèi)存模型可以知道,答案是不一定。那么如何保證當(dāng)線程 1 更新 flag 之后,線程 2 能夠讀取到最新的值呢?其實(shí)很簡(jiǎn)單,只需要給 flag 添加 volatile 修飾符。
那么 volatile 是如何做到的呢? 我們想一想,根據(jù) Java 內(nèi)存模型,要實(shí)現(xiàn)這種功能該怎么做?應(yīng)該是兩步:1. 當(dāng)線程 1 寫 volatile 變量的時(shí)候,將這個(gè)值從緩存刷新到主內(nèi)存中 2. 當(dāng)線程 2 讀取 volatile 變量的時(shí)候,將本地的工作內(nèi)存置為無效,從主內(nèi)存讀取新值。
其實(shí) volatile 的實(shí)現(xiàn)正是以上的原理,對(duì)于一個(gè) volatile 變量的寫操作會(huì)有一行以 lock 作為前綴的匯編代碼。這個(gè)指令在多核處理器下會(huì)引發(fā)兩件事:
- 將當(dāng)前處理器緩存行的數(shù)據(jù)寫回到主內(nèi)存
- 這個(gè)寫回內(nèi)存的操作會(huì)使在其它 CPU 里緩存了該內(nèi)存地址的數(shù)據(jù)無效
lock 前綴的指令會(huì)鎖住系統(tǒng)總線或者是緩存,目的是保證在同一時(shí)間只有一個(gè) CPU 會(huì)修改數(shù)據(jù),使得修改具有原子性。根據(jù) 緩存一致性 協(xié)議, CPU 通過嗅探技術(shù)保證它的內(nèi)部緩存、內(nèi)存和其它處理器的緩存的數(shù)據(jù)的一致性。例如,一個(gè)處理器檢測(cè)其它處理器打算寫內(nèi)存地址,而這個(gè)地址當(dāng)前處于共享狀態(tài),那么正在嗅探的處理器將使它的緩存行無效,在下次訪問相同的內(nèi)存地址時(shí),強(qiáng)制執(zhí)行緩存行填充。
禁止重排序
volatile 除了保證內(nèi)存可見性,還可以禁止重排序。在了解重排序之前,先看一段代碼:
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
上面的代碼一看就是單例模式,并且使用了雙重加鎖提高效率。稍微有經(jīng)驗(yàn)的程序員還會(huì)發(fā)現(xiàn),上面的寫法是不正確的,應(yīng)該給 instance 添加 volatile 修飾。那么為什么需要 volatile 呢?
其實(shí)問題出在 instance = new Singleton(); 這一行,這里是創(chuàng)建 Singleton 對(duì)象的地方,其實(shí)這里可以看成三個(gè)步驟:
- memory = allocate(); //1: 分配對(duì)象的內(nèi)存空間
- ctorInstance(memory); //2: 初始化對(duì)象
- instance = memory; //3: 設(shè)置 instance 指向剛分配的內(nèi)存地址
上面的偽代碼可能會(huì)被重排序。什么是重排序?編譯器以及處理器有時(shí)候會(huì)為了執(zhí)行的效率改變代碼的執(zhí)行順序,這個(gè)被稱為重排序。上面的三個(gè)步驟可能會(huì)被重排序?yàn)橄旅娴牟襟E:
- memory = allocate(); //1: 分配對(duì)象的內(nèi)存空間
- instance = memory; //2: 設(shè)置 instance 指向剛分配的內(nèi)存地址
// 注意:此時(shí)對(duì)象還沒有被初始化 - ctorInstance(memory); //3: 初始化對(duì)象
在這種情況下,當(dāng)一個(gè)線程執(zhí)行到 instance = memory; 的時(shí)候,對(duì)象還沒有被初始化,另一個(gè)線程也調(diào)用了 getInstance 方法,發(fā)現(xiàn) instance 引用不為 null,就會(huì)認(rèn)為這個(gè)對(duì)象已經(jīng)創(chuàng)建好了,從而使用了未初始化的對(duì)象。
為什么 volatile 可以避免上面的問題?其實(shí)是因?yàn)?volatile 會(huì)禁止重排序,方法是插入了內(nèi)存屏障,具體原理較復(fù)雜,這里就不深入分析了。
偽共享
CPU 緩存是以緩存行為單位進(jìn)行存取的,一般一個(gè)緩存行是 64 字節(jié),如果兩個(gè) volatile 變量被緩存在同一個(gè)緩存行,并且有多個(gè) CPU 緩存了同一行數(shù)據(jù),那么會(huì)出現(xiàn) 偽共享 的問題,造成性能問題。
例如,CPU A 以及 CPU B 都在同一個(gè)緩存行緩存了共享變量 X 和 Y,如果 CPU A 修改了 X,那么 CPU B 中的緩存行也就失效了,如果 CPU 只是需要讀取 Y ,卻因?yàn)?X 使得整個(gè)緩存行都要重新讀取,這就不劃算了,這叫做偽共享。
解決偽共享主要是讓不同的 volatile 變量不要緩存到同一個(gè)緩存行,可以利用填充技術(shù)來解決,具體可以參考這篇文章:Java中的偽共享以及應(yīng)對(duì)方案
總結(jié)
volatile 作為一個(gè)輕量級(jí)的鎖可以實(shí)現(xiàn)內(nèi)存可見性以及禁止重排序,常用于修飾標(biāo)記變量以及雙重加鎖的場(chǎng)景等。需要注意的是,volatile 用于保證一個(gè)變量的可見性,但是對(duì)于 i++ 這種復(fù)合操作是無法保證原子性的。另外,注意偽共享問題可以進(jìn)一步提升性能。
參考
- 《Java 并發(fā)編程的藝術(shù)》