深入理解volatile關鍵字


一、初識volatile關鍵字

  自java 1.5版本起,volatile關鍵字所扮演的作用越來越重要。該關鍵字在併發包(JUC)中使用得非常廣泛,因此掌握volatile對進一步提升技術大有裨益。所有的原子數據類型都以此作爲修飾,相比synchronized關鍵字,volatile被稱爲"輕量級鎖",能實現部分synchronized關鍵字的語義。

  我們先看一個案例:兩個線程,一個是Reader線程,一個是Updater線程,都訪問共享變量init_value,看看會不會導致線程安全問題。

import java.util.concurrent.TimeUnit;

public class VolatileDemo {
	
	final static int MAX = 5;
	static int init_value = 0;
	//static volatile int init_value = 0;

	public static void main(String[] args) {

		new Thread(() -> {
			int localValue = init_value;
			while (localValue < MAX) {
				// 若 init_value的值發生變化,下面打印相應的信息
				if (init_value != localValue) {
					System.out.printf("The init_value is updated to [%d]\n", init_value);
					// 對localValue重新賦值
					localValue = init_value;
				}
			}
		}, "Reader").start();
		
		new Thread(() -> {
			int localValue = init_value;
			while (localValue < MAX) {
				System.out.printf("The init_value will be updated to [%d]\n", ++localValue);
				init_value = localValue;
				// 短暫休眠,讓Reader線程能夠來得及輸出變化後的內容
				try {
					TimeUnit.SECONDS.sleep(1);
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
		}, "Updater").start();
	}
}

執行上面的代碼,結果如下:
在這裏插入圖片描述
控制檯僅僅輸出Updater線程的內容,而Reader線程沒有輸出,說明Reader線程並沒有感知到init_value的值發生變化。這和預期的結果不符。

// 用volatile關鍵字修飾
static volatile int init_value = 0;

用volatile關鍵字修飾init_value字段後,從新執行上面的案例,結果如下:
在這裏插入圖片描述
用volatile關鍵字修飾共享變量後,得到預期的結果。經過這個案例,我們初步瞭解到volatile關鍵字的作用了。下面帶着疑問繼續往下讀吧。

注意點:volatile關鍵字只能修飾 類變量和實例變量,不能修飾方法參數、局部變量、實例常量和類常量。比如上面案例中的MAX就不能使用volatile修飾。

二、背景知識

要弄清楚volatile關鍵字的來龍去脈,需要具備java內存模型(JMM)和CPU緩存模型等知識。

1. CPU Cache模型

  計算機中的所有運算操作都是由CPU的寄存器來完成的,CPU指令的執行過程需要涉及數據的讀取和寫入,這些數據只能來自於計算機主存(通常指RAM)。

  CPU的處理速度和內存的訪問速度差距巨大,直連內存的訪問方式使得CPU資源沒有得到充分合理的利用,於是產生了在CPU與主存之間增加高速緩存CPU Cache的設計。現在的緩存數量一般都可以達到3級,最靠近CPU的緩存稱爲L1,然後依次是L2,L3和主內存,CPU Cache模型如下圖所示。

  由於指令和數據的行爲和熱點分佈差異很大,因此將L1按照用途劃分爲L1i(instruction)和L1d(data)。在多核CPU的結構中,L1和L2是CPU私有的,L3則是所有CPU共享的。

在這裏插入圖片描述

  Cache的出現解決了CPU直接訪問內存效率低下的問題。但同時也引入了緩存一致性(Cache Coherence) 的問題。比如i++操作的處理過程:

  1. 讀取主內存的i到CPU Cache;
  2. 從CPU Cache中讀取i;
  3. 在CPU寄存器中對i進行加1操作;
  4. 將結果i寫回到CPU Cache;
  5. 將數據i刷新到主存;

  在多線程情況下,每個線程都有自己的工作內存(本地內存),共享變量i會在多個線程的本地內存中存儲一個副本。如果兩個線程同時執行i++操作,假設i的初始值是1,每一個線程都從主內存中獲取i的值存入CPU Cache中,執行加1操作再寫入到主存中,都自增1可能就會導致兩次自增之後的結果是2,這就是典型的CPU 緩存不一致性問題。主流解決方案有如下兩種:

  • 總線加鎖
    常見於早期CPU,CPU和其他組件的通信都是通過總線(數據總線、控制總線、地址總線)來進行的,總線加鎖會阻塞其他CPU的操作,只有搶到總線鎖的CPU能夠操作,是一種悲劇的實現方式,效率較爲低下。
  • 緩存一致性協議
    爲了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。最出名的是Intel的MESI協議(協議將Cache Line的狀態分成四種),它保證了每一個緩存中使用的共享變量副本都是一致的。當CPU操作CPU Cache中的數據時,如果發現該變量是一個共享變量,也就是說在其他的CPU Cache中也存在一個副本,那麼會執行如下操作:
  1. 讀取操作。不做任何處理,僅將Cache中的數據讀取到寄存器。
  2. 寫入操作。發出信號通知其他CPU將該變量的Cache Line置爲無效狀態,其他CPU在進行該變量的讀取操作時需要從主存中再次獲取。
    在這裏插入圖片描述

2. java內存模型

  java內存模型(Java Memory Mode,JMM),指定了Java虛擬機如何與計算機的主存(RAM)進行工作。

  Java內存模型決定了一個線程對共享變量的寫入何時對其他線程可見,Java內存模型定義了一個線程和主內存之間的抽象關係,具體如下:

  • 共享變量存儲於主內存,每個線程都可以訪問;
  • 每個線程都有私有的工作內存或稱爲本地內存,每一個線程都不能訪問其他線程的工作內存或本地內存;
  • 工作內存只存儲該線程對共享變量的副本;
  • 線程對變量的所有操作都必須在自己的工作內存中進行,線程不能直接操作主內存,只有先操作了工作內存之後才能寫入主存;
  • 工作內存和Java內存模型一樣是一個抽象概念,它其實並不存在,它涵蓋了緩存、寄存器、編譯優化以及硬件等。
    在這裏插入圖片描述
    瞭解JMM後,一個共享變量勢必會在多個線程的本地內存中出現不一致的情況,java語言中又是如何保證不同線程對某個共享變量的可見性呢?請繼續閱讀。

三、併發編程三個特性

  併發編程有三個至關重要的特性,分別是:原子性、有序性、可見性,下面逐個介紹。

1.原子性

  所謂原子性是指在一次的操作或者多次操作中,要麼所有的操作都得到了執行並且不會受到任何因素的干擾或中斷,要麼所有的操作都不執行。(All Or Nothing)

兩個原子性的操作結合在一起未必還是原子性的,比如:i++; volatile關鍵字不能保證原子性,synchronized關鍵字可以保證,自JDK1.5版本,java提供了原子類型變量可以保證原子性。

2.可見性

  可見性是指:當一個線程對共享變量進行了修改,那麼另外的線程可以立即看到修改後的最新值。這一點,可以回顧一下上面的案例。Reader線程將init_value緩存到CPU Cache中,Updater線程對init_value的修改對Reader線程是不可見的。

volatile關鍵字能保證可見性。

3.有序性

  由於java編譯器以及運行期間的優化,導致代碼執行的順序未必就是開發者編寫代碼時的順序。比如:

int x=9;	// 語句1
int y=8;	// 語句2
y=10;	// 語句3
x++;	// 語句4

  從編寫代碼的角度看上面的代碼肯定是順序執行的,但是在JVM真正執行這段代碼的時候未必是這樣的順序,可能語句2在語句1的前面得到了執行,這種情況我們稱爲“指令重排序”(Instruction Reorder)。當然指令重排序要嚴格遵循指令之間的數據依賴關係,不能任意重排(如語句2和語句3不會發生指令重排序)。

  單線程情況下,無論怎麼樣的重排序最終都會保證程序的執行結果和代碼順序執行的結果是完全一致的。但在多線程情況下,指令重排序可能會造成非常大的問題,如下面的代碼片段:

private boolean initialized = false;
private Context context;
public Context load(){
	if(!initialized ){
		context=loadContext();    //語句1
		initialized=true;         //語句2
	}
	return context;
}

  如果語句2的執行被重排序到語句1的前面,那麼在高併發訪問load方法時將會產生災難性的後果。第一個線程判斷initialized 爲false執行了context的加載,但在執行loadContext()之前將initialized 置爲true,另外一個線程也執行load方法,發現此時initialized 已經是true,則直接返回了未被加載成功的context,那麼線程在後面的運行過程中勢必會出現錯誤。

volatile關鍵字能保證有序性。


四、JMM如何保證三大特性

1.JMM與原子性

  • java對基本類型變量的讀取、賦值操作是原子性的。
  • java對引用類型變量的讀取、賦值操作是原子性的。
  • x=10; 賦值操作是原子性的。
  • y=x;將一個變量賦值給另一個變量,非原子性操作。
  • y++;和y=y+1; 加一和自增操作非原子性操作。

  volatile關鍵字不具備保證原子性的語義。如果想使得某些代碼具備原子性,需要使用關鍵字synchronized,或者JUC中的lock。如果想使int等類型的自增具有原子性,可以使用JUC包下的原子封裝類java.util.concurrent.atomic.*.

2.JMM與可見性

java提供了三種方式來保證可見性。

  1. 使用關鍵字volatile關鍵字。 見上面的“緩存一致性協議”。
  2. 通過synchronized關鍵字能夠保證可見性。synchronized保證同一時刻只有一個線程獲得鎖,還會保證在釋放鎖之前將變量的修改刷新到內存中。
  3. JUC提供的顯示鎖Lock也能保證可見性。道理同synchronized。

3.JMM與有序性

  java內存模型允許編譯器和處理器對指令進行重排序。但在多線程環境下,重排序會影響程序的正確執行。java提供了三種方式來保證有序性。

  1. 使用關鍵字volatile關鍵字。(後面給出解釋)
  2. 通過synchronized關鍵字能夠保證有序性。(同步代碼的執行,最終結果是有序的)
  3. JUC提供的顯示鎖Lock也能保證有序性。(同步代碼的執行,最終結果是有序的)

  java內存模型具備一些天生的有序規則,不需要任何同步手段就能夠保證有序性,這些規則稱爲“Happens-before原則”。如果兩個操作的執行的順序無法從Happens-before原則推導出來,那麼他們無法保證有序性,虛擬機可以任意對他們進行重排序處理。具體的Happens-before原則請點擊鏈接。

volatile對順序性的保證比較霸道,直接禁止JVM和處理器對volatile關鍵字修飾的指令重排序,但對volatile前後無依賴關係的的指令可以隨便重排序。

int x=9;	//語句1
int y=0;	//語句2
volatile int z=8;	//語句3
x++;	//語句4
y--;	//語句5

上面的程序中,語句1和語句2的執行順序無關緊要,只要百分之百的保證在語句3之前執行即可;同理語句4和語句5必須在語句3之後。


五、volatile的原理和實現

volatile關鍵字的作用,是靠“內存屏障”的方式實現。內存屏障會爲指令的執行提供如下幾個保障:

  1. 確保指令重排序時不會將其後面的代碼排到內存屏障之前。
  2. 確保指令重排序時不會將其前面的代碼排到內存屏障之後。
  3. 確保在執行到內存屏障修飾的指令時前面的代碼全部執行完成。
  4. 強制將 線程工作內存中的修改刷新至主內存。
  5. 如果是寫操作,則會將其他線程工作內存中的緩存數據失效掉。

六、參考資料

  1. JMM和底層實現原理
  2. 《java高併發編程詳解》 汪文君
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章