volatile簡介
volatile
是jvm提供的最輕量級的同步機制(相比於synchronized
,其要輕量很多)
當一個變量定義爲volatile
後,其具備兩種特性:
- 此變量對所有線程的可見性
- 可見性:當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。
- 禁止指令重排序優化
- 指令重排序:JVM爲了進行優化,會對變量賦值等操作進行一系列的優化,其只保證了所有依賴賦值結果的地方都能獲取到正確的結果,但不能保證該變量賦值操作的順序與程序代碼中的執行順序一致。
- 注意:重排序優化是機器級的優化操作,不是是Java源代碼層面進行的。
可見性
普遍變量
首先,爲什麼普通變量不能做到可見性呢?
這裏需要引入JMM(Java內存模型)。
-
什麼是JMM?
由於在不同平臺上內存模型的差異,可能同一個程序在一個平臺上併發情況下可以正常運行,而在另一個平臺上併發訪問就出錯,因此需要針對各種平臺來實現一個統一的規範。由此,JVM規範了自身的JAVA內存模型(即JMM)來屏蔽操作系統的內存訪問差異。
-
JMM簡介
JMM的知識點較多,這裏只做簡單介紹。
首先上圖
JMM規定了所有的變量都存儲在主內存,而每條線程有自己的工作內存;線程的工作內存保存了該線程所使用到的變量的拷貝(注意:線程的工作內存只拷貝了對象的引用、和正在訪問的對象中的某個字段,並不會完全拷貝此對象),線程都所有操作都是在自己的工作內存中進行的;
(注意:JMM與JVM中內存區域的堆棧等區域不是一個層次的內存劃分,讀者不要混淆)
okay,引入完畢,回到剛纔的問題,爲什麼普通變量不能做到可見性呢?
由上圖及介紹可以知道,普通變量的值傳遞需要通過主內存來完成,例如:線程A修改一個變量的值,需要先向主內存回寫後,另一個線程B等到A回寫完成後再讀取,才能夠讀取到變量的新值。
volatile修飾的變量
volatile
怎樣實現可見的呢?
有如下java代碼
public class Test16 {
private volatile int a=0;
public void update() {
a = 1;
}
public static void main(String[] args) {
}
}
通過hsdis+jitwatch工具查看其彙編碼(查看步驟見:here),如下:
......
0x000000000295156d: lock addl %rdi,(%rdx)
......
可以看到在volatile
修飾的變量處,執行了lock addl....
步驟,這個操作的lock作用把主內存的變量標示爲獨佔內存的變量,此時會使得本CPU的Cache寫入內存,同時令其他CPU或別的內核其cache失效,當其他CPU發現cache失效後,會從內存中重讀該變量數據,即可以獲取當前最新值。
通過以上的步驟,使用前面的volatile
變量的修改對其他CPU立即可見。
-
除了
volatile
之外,synchronized
和final
關鍵字也可以保證可見性synchroinzed
:變量執行unlock操作(將處於鎖定狀態的變量釋放出來,釋放後其他線程纔可以使用此變量)之前,必須先把此變量同步到主內存中。final
:保障構造函數中對象不溢出的情況下,其他線程拿到的是初始化後的final
對象。
volatile保證有序性就安全了嗎
有如下例子:
package com.hpsyche;
/**
* @author hpsyche
* Create on 2019/12/24
*/
public class Test17 {
public static volatile int race=0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads=new Thread[50];
for(int i=0;i<50;i++){
threads[i]=new Thread(new Runnable() {
@Override
public void run() {
for(int i1 = 0; i1 <1000; i1++){
race++;
}
}
});
threads[i].start();
}
for(int i=0;i<50;i++){
threads[i].join();
}
System.out.println(race);
}
}
運行結果:發現race最終變量小於50000;
通過javap反編譯,查看字節碼:
0: iconst_0
1: istore_1
2: iload_1
3: bipush 50
5: if_icmpge 31
8: new #2 // class java/lang/Thread
11: dup
12: new #3 // class com/hpsyche/Test17$1
15: dup
16: invokespecial #4 // Method com/hpsyche/Test17$1."<init>":()V
19: invokespecial #5 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
22: invokevirtual #6 // Method java/lang/Thread.start:()V
25: iinc 1, 1
28: goto 2
31: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
34: getstatic #8 // Field race:I
37: invokevirtual #9 // Method java/io/PrintStream.println:(I)V
40: return
可以看到,race++被不是一個原子操作,其有istore\iload\iinc等組成,執行這些指令是,其他線程有可能已經將race的值改變了,導致最終getstatic時同步到主內存的數據偏小。
此時我們可以將race++操作加上synchroinzed
,或者使用jdk提供的AtmoicInteger
類來確保race++的線程安全。
有序性
volatile怎麼實現有序性
上文已經提過:重排序優化是指過程不保證,結果保證的一系列優化過程。而volatile關鍵字禁止了指令優化,那麼其是怎樣實現的呢?
在上文舉例中提到彙編碼:lock addl...
,其中的lock還有一個作用,其相當於一個內存屏障(重排序時不能將後面的指令排序到內存屏障之前),內存屏障其通過一系列的屏障策略來實現有序。
關於內存屏障的更多細節可見:here
- 除了
volatile
外,synchroinzed
也可實現線程間操作的有序性,因爲加了synchroinzed
後,一個變量在同一個時刻只允許一個線程對其進行lock,也就保證了訪問的先後性。
volatile典型使用
DCL實現的單例模式(雙重檢查加鎖)
public class Singleton{
private volatile static Singleton instance=null;
private Singleton(){}
public static Singleton getInstance(){
//先檢查實例是否存在,如果不存在才進入下面的同步塊
//避免synchroinzed資源的消耗
if(instance==null){
//同步塊,線程安全的創建實例
synchronized(Singleton.class){
//檢查實例是否存在,如果不存在才真正的創建實例
if(instance==null){
instance=new Singleton();
}
}
}
return instance;
}
}
上面的例子已經似乎可以保證單例了,那爲什麼還需要加volatile
呢?
首先要理解new Singleton()
做了什麼。new一個對象有幾個步驟。
1.看class對象是否加載,如果沒有就先加載class對象;
2.分配內存空間,初始化實例;
3.調用構造函數;
4.返回地址給引用。
而cpu爲了優化程序,可能會進行指令重排序,打亂這3,4這幾個步驟,導致實例內存還沒分配,就被使用了,當在併發的情況下,就可能出現線程B引用了線程A中還沒有被完全初始化的變量。
而加了volatile
之後,就保證new
不會被指令重排序。
總結
關於volatile
關鍵字還有很多深入的細節,由於才疏學淺,這裏我也只是簡單的聊了下其特性,如果不足之處歡迎評論指出。
參考文獻
《深入理解Java虛擬機》(第二版)——周志明