一篇文章徹底搞懂volatile關鍵字

volatile關鍵字synchronized關鍵字一樣,在Java多線程開發中,是一道必須要跨越的檻。之前有篇文章已經分析過synchronized關鍵字的原理,synchronized關鍵字的原理,這一次,我們來一步一步分析下volatile關鍵字的工作原理。

本文篇幅稍微有點長,希望您能耐心看下去,並有所收穫。

volatile關鍵字的使用

首先,我們從一個簡單的程序來入手。

public class VolatileFoo {
	//init_value的最大值
	final static int MAX = 5;
	//init_value的初始值
	static int init_value = 0;

	public static void main(String[] args) {
		//啓動一個Reader線程,當發現local_value和init_value不同時,
		//則輸出init_value被修改的信息
		new Thread(() -> {
			int localValue = init_value;
			while(localValue < MAX) {
				if(init_value != localValue) {
					System.out.println("this init_value is updated to " + init_value);
					//對local_value重新賦值
					localValue = init_value;
				}
			}
		},"Readder").start();
	
		new Thread(() -> {
			int localValue = init_value;
			while(localValue < MAX) {
				System.out.println("this init_value will be changed to " + ++localValue);
				//對local_value重新賦值
				init_value = localValue;
				try {
					Thread.sleep(2000);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		},"Updater").start();
	}
}

上面的程序分別啓動了兩個線程,一個線程負責對變量進行修改,一個線程負責對變量進行輸出。

運行程序,輸出結果如下:

this init_value will be changed to 1
this init_value is updated to 1
this init_value will be changed to 2
this init_value will be changed to 3
this init_value will be changed to 4
this init_value will be changed to 5

從輸出信息我們發現,Reader線程沒有感知到init_value的變化,我們期望的是在Updater進程更新init_value的值之後,Reader進程能夠打印出變化的init_value的值,但結果並不是我們期望的那樣。

我們嘗試在init_value前面加上volatile

static volatile int init_value = 0;

接着我們再運行下這個程序,輸出結果如下:

this init_value will be changed to 1
this init_value is updated to 1
this init_value will be changed to 2
this init_value is updated to 2
this init_value will be changed to 3
this init_value is updated to 3
this init_value will be changed to 4
this init_value is updated to 4
this init_value will be changed to 5
this init_value is updated to 5

這個時候Reader線程就能夠感受到init_value的值的變化了,並且在條件不滿足時程序就退出了運行。

那麼爲什麼加了個volatile就正常了呢, volatile關鍵字的作用到底是什麼呢?

想要徹底搞清楚volatile關鍵字,還需要具備Java內存模型、CPU緩存模型、彙編指令等相關知識的,接下來,我們接下來一步一步來拆解問題。

CPU緩存模型

要想對volatile有比較深刻的理解,首先我們需要對CPU的緩存模型有一定的認識。

在計算機中,所有的運算操作都是由CPU的寄存器來完成的,CPU指令的執行過程需要涉及數據的讀取和寫入操作,CPU所能訪問的所有數據只能是計算機的主存(通常是指RAM),雖然CPU的發展頻率不斷得到提升,但受制於製造工藝以及成本的限制,計算機的內存反倒在訪問速度上沒有多大的突破,因此CPU的處理速度和內存的訪問速度之間的差距越拉越大,通常這種差距可以達到上千倍,極端情況下甚至會在上萬倍以上。

由於兩邊速度嚴重的不對等,通過傳統FSB直連內存的訪問方式會導致CPU資源受到極大的限制,降低CPU整體的吞吐量,於是就有了CPU和主內存直接增加緩存的設計,現在緩存數量都可以增加到3級了,最靠近CPU的緩存爲L1,ranhou依次是L2,L3和主內存,CPU緩存模型圖如下所示:CPU緩存模型圖
Cache的出現是爲了解決CPU直接訪問內存效率低下的問題,程序在運行的過程中,會將運算所需要的數據從主內存複製一份到CPU Cache中,這樣CPU計算時就可以直接對CPU Cache中的數據進行讀取和寫入,當運算結束之後,再將CPU Cache中最新的數據刷新到主內存當中,CPU通過直接訪問Cache的方式提到直接訪問主內存的方式極大地提高了CPU的吞吐能力,有個CPU Cache之後,整體的CPU和主內存之間的交互的架構大致如下圖所示:
在這裏插入圖片描述

Java內存模型

由於緩存的出現,極大地提高了CPU的吞吐能力,但是同時也引入了緩存不一致的問題。在多處理器系統中,每個處理器都有自己的的高速緩存,而它們又共享同一主內存,當多個處理器的運算任務都設計到同一塊內存區域時,將可能導致各自的緩存數據不一致,這個時候就需要通過緩存一致性協議來保證數據的正確性,不同的操作系統使用緩存一致性協議都各不相同。

因爲各種硬件和操作系統的內存訪問是有差異的,Java爲了程序能在各種平臺下運行達到一致的內存訪問效果,於是定義了Java內存模型(Java Memory Mode,JMM)來對特定內存或高速緩存的讀寫訪問過程進行抽象。

Java內存模型定義了線程和主內存之間的抽象關係,具體如下。

  • 共享變量存儲於主內存之中,每個線程都可以訪問。
  • 每個線程都有私有的工作內存和本地內存。
  • 工作內存值存儲該線程對共享變量的副本。
  • 線程不能直接操作主內存,只有先操作了工作內存之後才能寫入主內存。
  • 工作內存和Java內存模型一樣也是一個抽象的概念,它其實並不存在,它涵蓋了緩存、寄存器、編譯優化以及硬件等。
    image
    Java內存模型定義了一套主內存和工作內存的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步到主內存之類的實現細節。具體有8種操作來完成,分別爲lock、unlock、read、load、use、assign、store和write。除此之外,Java內存模型還規定在執行這8種操作的時候必須滿足8種規則,由於篇幅問題,這裏就不一一列舉了,具體可參看深入理解Java虛擬機第12章的Java內存模型與線程。

Java內存模型是一個抽象的概念,其與計算機硬件的結構並不完全一樣,比如計算機物理內存不會存在棧內存和堆內存的劃分,無論是堆內存還是虛擬機棧內存都會對應到物理的主內存,當然也有一部分堆棧內存數據可能會存入CPU Cache寄存器中。具體可參考下圖:
image

對於volatile變量的特殊規則

介紹了CPU緩存模型以及Java內存模型之後,我們再來說volatile關鍵字,這樣更能加深我們對於volatile關鍵字的理解。
volatile關鍵字是Java虛擬機提供的最輕量級的同步機制,很多人由於對它理解不夠,往往更願意使用synchronized來做同步。

Java內存模型volatile關鍵字定義了一些特殊的訪問規則,當一個變量被volatile修飾後,它將具備兩種特性,或者說volatile具有下列兩層語義:

  • 第一、保證了不同線程對這個變量進行讀取時的可見性, 即一個線程修改了某個變量的值, 這新值對其他線程來說是立即可見的。 (volatile 解決了線程間共享變量的可見性問題)。
  • 第二、禁止進行指令重排序, 阻止編譯器對代碼的優化。

針對第一點,volatile保證了不同線程對這個變量進行讀取時的可見性,具體表現爲:

  • 第一: 使用 volatile 關鍵字會強制將在某個線程中修改的共享變量的值立即寫入主內存。
  • 第二: 使用 volatile 關鍵字的話, 當線程 2 進行修改時, 會導致線程 1 的工作內存中變量的緩存行無效(反映到硬件層的話, 就是 CPU 的 L1或者 L2 緩存中對應的緩存行無效);
  • 第三: 由於線程 1 的工作內存中變量的緩存行無效, 所以線程 1再次讀取變量的值時會去主存讀取。

基於這一點,所以我們經常會看到文章中或者書本中會說volatile 能夠保證可見性。

volatile 能夠保證可見性,但是volatile不能保證程序的原子性。

public class VolatileTest {
	public static volatile int race = 0;
	
	public static void increase() {
		race ++;
	}
	private static final int THREAD_COUNT = 20;

	public static void main(String[] args) {
		Thread[] threads = new Thread[THREAD_COUNT];
		for(int i =0 ;i<THREAD_COUNT;i++) {
			threads[i] = new Thread(() ->{
				for(int j =0;j< 10000;j++) {
					increase();
				}
			});
			threads[i].start();
		}
		
		while(Thread.activeCount() > 1)
			Thread.yield();
		
		System.out.println(race);
	}
}

這段代碼發起了20個線程,每個線程對race變量進行10000次自增操作,如果這段代碼能夠正確併發的話,最後輸出的結果應該是200000。我們運行完這段代碼之後,並沒有獲得期望的結果,而且發現每次運行程序。輸出的結果都不一樣,都是一個小於200000的數字。

問題就出在自增運算”race++“之中,我們用javap反編譯這段代碼後發現只有一行代碼的increase()方法在Class文件中是由4條字節碼指令構成的。

  public static void increase();
    Code:
       0: getstatic     #13                 // Field race:I
       3: iconst_1
       4: iadd
       5: putstatic     #13                 // Field race:I
       8: return

從字節碼層面上很容易分析出原因了:當getstatic指令把race的值取到操作棧時,volatile關鍵字保證了race的值此時是正確的,但是在執行iconst_1、iAdd這些指令的時候,其他線程可能已經把race的值加大了,而在操作棧訂的值就變成了過期的數據,所以putstati指令執行後就可能把較小的值同步回主內存中去了。

其實這裏我們通過字節碼來分析這個問題是不嚴謹的,因爲即使編譯出來的只有一條字節指令,也並不意味執行這條指令就是一個原子操作。一條字節碼指令在解釋執行時,解釋器將要運行許多行代碼才能實現它的語義,如果是編譯執行,一條字節碼指令也可能轉化成若干條本地機器碼指令。關於解釋執行和編譯執行,我們還會再講到。

由於volatile變量只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(synchronized或java.util.concurrent中的原子類)來保證原子性。

  • 運輸結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。
  • 變量不需要與其他狀態變量共同參與不變約束。

類似下面的場景就時候採用volatile來控制併發。

volatile boolean shutdownRequested;
public void shutdown() {
	shutdownRequested = true;
}
public void doWork() {
	while(!shutdownRequested) {
		//do stuff
	}
}

如果我們想讓上面的那個自增操作保持原子性,我們可以使用AtomicInteger,具體程序如下,這裏就不多做介紹了。

import java.util.concurrent.atomic.AtomicInteger;

public class VolatileTest {
//	public static volatile int race = 0;
	public static AtomicInteger race =new  AtomicInteger(0);
	
	public static void increase() {
//		race ++;
		race.incrementAndGet();
	}
	private static final int THREAD_COUNT = 20;

	public static void main(String[] args) {
		Thread[] threads = new Thread[THREAD_COUNT];
		for(int i =0 ;i<THREAD_COUNT;i++) {
			threads[i] = new Thread(() ->{
				for(int j =0;j< 10000;j++) {
					increase();
				}
			});
			threads[i].start();
		}
		while(Thread.activeCount() > 1)
			Thread.yield();
		
		System.out.println(race.get());
	}
}

回到volatile關鍵字的第二層語義:禁止指令重排。
普通的變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。

我們用一段僞代碼來幫助下理解:

Map configOptions;
char[] configText;
//此變量必須定義爲volatile
volatile boolean initialized = false;

//假設一下代碼在線程A中執行
//模擬讀取配置信息,當讀取完成後將initialized設置爲true以通知其他線程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processCongigOptions(configText,configOptions);
initialized = true

//假設一下代碼在線程B中執行
//等待initialized爲true,代表線程A已經吧配置信息初始化完成
while(!initialized) {
	sleep();
}
//使用線程A中初始化好的配置信息
doSomethingWithConfig();

上面這段代碼如果定義的initialized沒有使用volatile來修飾,就可能會由於指令重排序的優化,導致位於線程A中最後一句代碼initialized = true被提前執行(這裏雖然使用Java作爲僞代碼,但所指的重排序優化是機器級的優化操作,提前執行時值這句話對於的彙編代碼被提前執行),這樣在線程B中使用配置信息的代碼就可能出現錯誤,而volatile能避免此類情況的發生。

volatile關鍵字深入解析

上面講到volatile關鍵字的兩層語義,那麼volatile保證可見性以及有序性到底是如何做到的呢?它的底層邏輯是什麼呢?

這裏我們嘗試獲得Java程序的彙編代碼,通過比較變量加入volatile修飾和未加入volatile修飾的區別。

這裏主要使用的是HSDIS插件,HSDIS是一個Sun官方推薦的HotSpot虛擬機JIT編譯代碼的反彙編插件,網上有關於這個插件的下載,不過有的鏈接已經失效,我這裏是從這裏獲取的,hsdis,再把這個clone下來之後,編譯成功之後,使用下面這個命令拷貝到jre的server目錄,具體可以查看這個repo中README文件,裏面寫的很詳細。

sudo cp build/macosx-amd64/hsdis-amd64.dylib /Library/Java/JavaVirtualMachines/jdk1.8.0_131.jdk/Contents/Home/jre/lib/server/

接下來就可以嘗試反彙編了。

public class Singleton {
	private static Singleton instance;
	
	public static Singleton getInstance() {
		if(instance ==null) {
			synchronized(Singleton.class) {
				if(instance == null) {
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
	
	public static void main(String[] args) {
		Singleton.getInstance();
	}
}

上面這個是我們嘗試反彙編的程序代碼,如果是命令行,我們可以使用下面這個指令。

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Singleton

如果是eclipse,在下圖的VM arguments中添加XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly,然後運行程序,這樣在控制檯就會輸出彙編代碼。
image
程序運行後,在控制檯會輸出很多內容,由於輸出太大,所以截取了前面一段輸出。

Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output
Loaded disassembler from /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/server/hsdis-amd64.dylib
Decoding compiled method 0x0000000112e9ad50:
Code:
[Disassembling for mach='i386:x86-64']
[Entry Point]
[Constants]
  # {method} {0x000000010ce1f000} 'hashCode' '()I' in 'java/lang/String'
  #           [sp+0x40]  (sp of caller)
  0x0000000112e9aec0: mov    0x8(%rsi),%r10d
  0x0000000112e9aec4: shl    $0x3,%r10
  0x0000000112e9aec8: cmp    %rax,%r10
  0x0000000112e9aecb: jne    0x0000000112de0e60  ;   {runtime_call}
  0x0000000112e9aed1: data16 data16 nopw 0x0(%rax,%rax,1)
  0x0000000112e9aedc: data16 data16 xchg %ax,%ax
[Verified Entry Point]
  0x0000000112e9aee0: mov    %eax,-0x14000(%rsp)
  .....

得到這個輸出之後,我使用Singleton全局搜索了下,發現還無結果。
image
反編譯的卻沒有得到相應的內容,這是什麼問題呢?
帶着這個問題Google了好久,終於搞明白原因了。

於是我們又要來補充些虛擬機編譯的知識了。
image
我們在使用java -version查看JDK版本的時候,可以看到最後有個mixed mode,這裏其實表明的是Java 虛擬機的編譯方式,在HotSpot虛擬機中,提供了兩種編譯模式:解釋執行 和 即時編譯(JIT,Just-In-Time),即時編譯也可以稱爲編譯執行,解釋執行即逐條翻譯字節碼爲可運行的機器碼,而即時編譯則以方法爲單位將字節碼翻譯成機器碼。

我們在反編譯Singleton這個類的時候,因爲虛擬機使用的是解釋執行,這樣我們是得不到彙編代碼的。在深入理解Java虛擬機一書中介紹可以加上-Xcomp來觸發JIT編譯,但是我用的是JDK1.8,這個 -Xcomp`已經被移除了,具體哪個版本被移除了,目前我也沒仔細研究過了。

那要怎樣才能觸發JIT編譯呢?答案是循環。通過足夠多次數的循環來觸發JIT編譯。我們需要確保寫的Java方法被調用的次數足夠多,以觸發C1(客戶端)編譯,並大約10000次觸發C2(服務器)編譯器並打開高級優化。換句話說,要想查看彙編代碼,我們所寫的Java源代碼文件不能太過於簡單,要足夠複雜。

注意:C1,C2都是HotSpot虛擬機內置的即時編譯器。C1:即Client編譯器,面向對啓動性能有要求的客戶端GUI程序,採用的優化手段比較簡單,因此編譯的時間較短。C2:即Server編譯器,面向對性能峯值有要求的服務端程序,採用的優化手段複雜,因此編譯時間長,但是在運行過程中性能更好。

public class Singleton {
	private static Singleton instance;
	
	public static Singleton getInstance() {
		if(instance ==null) {
			synchronized(Singleton.class) {
				if(instance == null) {
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
	
	public static void main(String[] args) {
		for(int i=0;i<100;i++) {
			print();
		}
	}
	private static void print() {
		for(int i =0;i<=1000;i++) {
			Singleton.getInstance();
		}
	}
}

於是我在代碼里加上了兩層循環,然後在嘗試獲取一些彙編代碼。

這次發現終於能得到Singleton相關的彙編代碼了。

於是我們分別編譯了兩次,第一個是沒有使用volatile關鍵字修飾instance,第二個是使用volatile關鍵字,然後我們分別取出Singleton::getInstance這一段來進行比較。

 // 未使用volatile修飾
  0x000000010d29e931: movabs $0x7955f12a8,%rsi  ;   {oop(a 'java/lang/Class' = 'main/Singleton')}
  0x000000010d29e93b: mov    %rax,%r10
  0x000000010d29e93e: shr    $0x3,%r10
  0x000000010d29e942: mov    %r10d,0x68(%rsi)
  0x000000010d29e946: shr    $0x9,%rsi
  0x000000010d29e94a: movabs $0xfe403000,%rax
  0x000000010d29e954: movb   $0x0,(%rsi,%rax,1)  ;*putstatic instance
                                                ; - main.Singleton::getInstance@24 (line 10)
// 使用volatile修飾
 0x000000011435394f: movabs $0x7955f12a8,%rsi  ;   {oop(a 'java/lang/Class' = 'main/Singleton')}
  0x0000000114353959: mov    %rax,%r10
  0x000000011435395c: shr    $0x3,%r10
  0x0000000114353960: mov    %r10d,0x68(%rsi)
  0x0000000114353964: shr    $0x9,%rsi
  0x0000000114353968: movabs $0x10db6e000,%rax
  0x0000000114353972: movb   $0x0,(%rsi,%rax,1)
  0x0000000114353976: lock addl $0x0,(%rsp)     ;*putstatic instance
                                                ; - main.Singleton::getInstance@24 (line 10)

雖然對於彙編指令瞭解不多,但還是能從兩個對比中看出差異所在。
很明顯,在movb $0x0,(%rsi,%rax,1) 之後,加了volatile修飾的彙編代碼後面多了一條彙編指令lock addl $0x0,(%rsp),這個操作相當於一個內存屏障,指令重排時不能把後面的指令重排序到內存屏障之前的位置,當只有一個CPU訪問內存時,並不需要內存屏障,當如果有兩個或多個CPU訪問同一塊內存,且其中有一個在觀測另一個,就需要內存屏障來保證一致性了。lock addl $0x0,(%rsp) 表示把rsp的寄存器的值加0,這顯然是一個空操作,關鍵在於lock前綴。
image
查詢IA32手冊,lock前綴會強制執行原子操作,它的作用是是的本CPU的Cache寫入了內存,該寫入動作會引起別的CPU無效化其Cache。所有通過這樣一個空操作,可讓前面volatile變量的便是對其他CPU可見。

那爲什麼說它能禁止指令重排呢?從硬件架構上講,指令重排序是指CPU採用了運行將多條指令不按程序規定的順序分開發送給各相應的點了單元處理,但並不是指令任意重排,CPU需要能正確處理指令依賴情況以保障程序能得出正確的執行結果。lock addl $0x0,(%rsp) 指令把修改同步到內存時,意味着所有值錢的操作都已經執行完成,這樣便形成了" 指令重排序無法越過內存屏障"的效果。

總結來說,內存屏障有兩個作用:
先於這個內存屏障的指令必須先執行, 後於這個內存屏障的指令必須後執行。
如果你的字段是volatile,在讀指令前插入讀屏障,可以讓高速緩存中的數據失效,重新從主內存加載數據。在寫指令之後插入寫屏障,能讓寫入緩存的最新數據寫回到主內存。

關於volatile關鍵字的介紹就到這裏了,感謝,如果覺得還可以請幫忙點個贊,有問題歡迎留言討論。

原文鏈接

參考

[深入理解Java虛擬機]
[Java高併發編程詳解]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章