volatile關鍵字的作用、原理


在只有雙重檢查鎖,沒有volatile的懶加載單例模式中,由於指令重排序的問題,我確實不會拿到兩個不同的單例了,但我會拿到“半個”單例

而發揮神奇作用的volatile,可以當之無愧的被稱為Java並發編程中“出現頻率最高的關鍵字”,常用於保持內存可見性和防止指令重排序。

保持內存可見性

內存可見性(Memory Visibility):所有線程都能看到共享內存的最新狀態。

失效數據

以下是一個簡單的可變整數類:

public class MutableInteger {
	private int value;
	public int get(){
		return value;
	}
	public void set(int value){
		this.value = value;
	}
}

MutableInteger不是線程安全的,因為getset方法都是在沒有同步的情況下進行的。如果線程1調用了set方法,那么正在調用的get的線程2可能會看到更新后的value值,也可能看不到

解決方法很簡單,將value 聲明為volatile變量:

private volatile int value;

神奇的volatile關鍵字

神奇的volatile關鍵字解決了神奇的失效數據問題。

Java變量的讀寫

Java通過幾種原子操作完成工作內存主內存的交互:

  1. lock:作用於主內存,把變量標識為線程獨占狀態。
  2. unlock:作用於主內存,解除獨占狀態。
  3. read:作用主內存,把一個變量的值從主內存傳輸到線程的工作內存。
  4. load:作用於工作內存,把read操作傳過來的變量值放入工作內存的變量副本中。
  5. use:作用工作內存,把工作內存當中的一個變量值傳給執行引擎。
  6. assign:作用工作內存,把一個從執行引擎接收到的值賦值給工作內存的變量。
  7. store:作用於工作內存的變量,把工作內存的一個變量的值傳送到主內存中。
  8. write:作用於主內存的變量,把store操作傳來的變量的值放入主內存的變量中。

volatile如何保持內存可見性

volatile的特殊規則就是:

  • read、load、use動作必須連續出現
  • assign、store、write動作必須連續出現

所以,使用volatile變量能夠保證:

  • 每次讀取前必須先從主內存刷新最新的值。
  • 每次寫入后必須立即同步回主內存當中。

也就是說,volatile關鍵字修飾的變量看到的隨時是自己的最新值。線程1中對變量v的最新修改,對線程2是可見的。

防止指令重排

在基於偏序關系Happens-Before內存模型中,指令重排技術大大提高了程序執行效率,但同時也引入了一些問題。

一個指令重排的問題——被部分初始化的對象

懶加載單例模式和競態條件

一個懶加載單例模式實現如下:

class Singleton {
	private static Singleton instance;
	private Singleton(){}
	public static Singleton getInstance() {
		if ( instance == null ) { //這里存在競態條件
			instance = new Singleton();
		}
		return instance;
	}
}

競態條件會導致instance引用被多次賦值,使用戶得到兩個不同的單例。

DCL和被部分初始化的對象

為了解決這個問題,可以使用synchronized關鍵字將getInstance方法改為同步方法;但這樣串行化的單例是不能忍的。所以我猿族前輩設計了DCL(Double Check Lock,雙重檢查鎖)機制,使得大部分請求都不會進入阻塞代碼塊:

class Singleton {
	private static Singleton instance;
	private Singleton(){}
	public static Singleton getInstance() {
		if ( instance == null ) { //當instance不為null時,仍可能指向一個“被部分初始化的對象”
			synchronized (Singleton.class) {
				if ( instance == null ) {
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
}

“看起來”非常完美:既減少了阻塞,又避免了競態條件。不錯,但實際上仍然存在一個問題——當instance不為null時,仍可能指向一個"被部分初始化的對象"

問題出在這行簡單的賦值語句:

instance = new Singleton();

它並不是一個原子操作。事實上,它可以”抽象“為下面幾條JVM指令:

memory = allocate();	//1:分配對象的內存空間
initInstance(memory);	//2:初始化對象
instance = memory;		//3:設置instance指向剛分配的內存地址

上面操作2依賴於操作1,但是操作3並不依賴於操作2,所以JVM可以以“優化”為目的對它們進行重排序,經過重排序后如下:

memory = allocate();	//1:分配對象的內存空間
instance = memory;		//3:設置instance指向剛分配的內存地址(此時對象還未初始化)
ctorInstance(memory);	//2:初始化對象

可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向內存memory時,這段嶄新的內存還沒有初始化——即,引用instance指向了一個"被部分初始化的對象"。此時,如果另一個線程調用getInstance方法,由於instance已經指向了一塊內存空間,從而if條件判為false,方法返回instance引用,用戶得到了沒有完成初始化的“半個”單例。
解決這個該問題,只需要將instance聲明為volatile變量:

private static volatile Singleton instance;

也就是說,在只有DCL沒有volatile的懶加載單例模式中,仍然存在着並發陷阱。我確實不會拿到兩個不同的單例了,但我會拿到“半個”單例(未完成初始化)。
然而,許多面試書籍中,涉及懶加載的單例模式最多深入到DCL,卻只字不提volatile。這“看似聰明”的機制,曾經被我廣大初入Java世界的猿胞大加吹捧——我在大四實習面試跟誰學的時候,也得意洋洋的從飽漢、餓漢講到Double Check,現在看來真是傻逼。對於考查並發的面試官而言,單例模式的實現就是一個很好的切入點,看似考查設計模式,其實期望你從設計模式答到並發和內存模型。

volatile如何防止指令重排

volatile關鍵字通過“內存屏障”來防止指令被重排序。

為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。然而,對於編譯器來說,發現一個最優布置來最小化插入屏障的總數幾乎不可能,為此,Java內存模型采取保守策略。

下面是基於保守策略的JMM內存屏障插入策略:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。
  • 在每個volatile寫操作的后面插入一個StoreLoad屏障。
  • 在每個volatile讀操作的后面插入一個LoadLoad屏障。
  • 在每個volatile讀操作的后面插入一個LoadStore屏障。

進階

在一次回答上述問題時,忘記了解釋一個很容易引起疑惑的問題:

如果存在這種重排序問題,那么synchronized代碼塊內部不是也可能出現相同的問題嗎?

即這種情況:

class Singleton {
	...
		if ( instance == null ) { //可能發生不期望的指令重排
			synchronized (Singleton.class) {
				if ( instance == null ) {
					instance = new Singleton();
					System.out.println(instance.toString()); //程序順序規則發揮效力的地方
				}
			}
		}
	...
}

難道調用instance.toString()方法時,instance也可能未完成初始化嗎?

首先還請放寬心,synchronized代碼塊內部雖然會重排序,但不會在代碼塊的范圍內導致線程安全問題

Happens-Before內存模型和程序順序規則

程序順序規則:如果程序中操作A在操作B之前,那么線程中操作A將在操作B之前執行。

前面說過,只有在Happens-Before內存模型中才會出現這樣的指令重排序問題。Happens-Before內存模型維護了幾種Happens-Before規則,程序順序規則最基本的規則。程序順序規則的目標對象是一段程序代碼中的兩個操作A、B,其保證此處的指令重排不會破壞操作A、B在代碼中的先后順序,但與不同代碼甚至不同線程中的順序無關

因此,在synchronized代碼塊內部,instance = new Singleton()仍然會指令重排序,但重排序之后的所有指令,仍然能夠保證在instance.toString()之前執行。進一步的,單線程中,if ( instance == null )能保證在synchronized代碼塊之前執行;但多線程中,線程1中的if ( instance == null )卻與線程2中的synchronized代碼塊之間沒有偏序關系,因此線程2中synchronized代碼塊內部的指令重排對於線程1是不期望的,導致了此處的並發陷阱。

類似的Happens-Before規則還有volatile變量規則監視器鎖規則等。程序猿可以借助(Piggyback)現有的Happens-Before規則來保持內存可見性和防止指令重排。

注意點

上面簡單講解了volatile關鍵字的作用和原理,但對volatile的使用過程中很容易出現的一個問題是:

錯把volatile變量當做原子變量。

出現這種誤解的原因,主要是volatile關鍵字使變量的讀、寫具有了“原子性”。然而這種原子性僅限於變量(包括引用)的讀和寫,無法涵蓋變量上的任何操作,即:

  • 基本類型的自增(如count++)等操作不是原子的。
  • 對象的任何非原子成員調用(包括成員變量成員方法)不是原子的。

如果希望上述操作也具有原子性,那么只能采取鎖、原子變量更多的措施。

總結

綜上,其實volatile保持內存可見性和防止指令重排序的原理,本質上是同一個問題,也都依靠內存屏障得到解決。更多內容請參見JVM相關書籍。


參考:


本文鏈接:volatile關鍵字的作用、原理
作者:猴子007
出處:https://monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議發布,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及鏈接。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM