從一個小例子引發的Java記憶體可見性的簡單思考和猜想以及DCL單例模式中的volatile的核心作用
環境
OS | Win10 |
CPU | 4核8執行緒 |
IDE | IntelliJ IDEA 2019.3 |
JDK | 1.8 -server模式 |
場景
最初的程式碼
一個執行緒A根據flag的值執行死迴圈,另一個執行緒B只執行一行程式碼,修改flag的值,讓A執行緒死迴圈終止。
Visbility.java
public class Visbility { private boolean flag; public void cyclic(){ while (!flag){ } } public void setter(){ flag = true; } }
Main.java
public class Main { public static void main(String[] args) { Visbility visbility = new Visbility(); Thread cyclic = new Thread(visbility::cyclic); Thread setter = new Thread(visbility::setter); cyclic.start(); setter.start(); } }
多次執行Main函式結果:程式很快就終止。
這是為什麼呢?我沒有讓flag值在多執行緒之間記憶體可見呀,怎麼執行緒setter修改flag後,cyclic執行緒獲得了修改後的flag終止死迴圈?先帶著疑問。
新增for迴圈耗時程式碼
接著,在setter方法裡,在修改該flag之前,新增一行耗時程式碼(用for迴圈,為什麼不用TimeUnit,後面會說到),此時Visbility.java如下:
public class Visbility { private boolean flag; public void cyclic(){ while (!flag){ } } public void setter(){ for (int i = 0; i < 999999; i++) ; flag = true; } }
多次執行Main函式結果:程式一直不結束。
這是為什麼呢?難道執行個迴圈99999次,CPU永遠執行不完導致flag的值無法被修改該嗎?還是說記憶體可見性的問題?
用volatile解決記憶體可見性
我們給flag加上volatile關鍵字進行修飾(後面有其他的方式如鎖,System.out.println -_- 解決變數記憶體及時可見性),Visibility.java程式碼如下:
public class Visbility { private volatile boolean flag; public void cyclic(){ while (!flag){ } } public void setter(){ for (int i = 0; i < 999999; i++) ; flag = true; } }
多次執行Main函式結果:程式幾百毫秒後終止。
看來確實存在記憶體可見性的問題,執行緒cyclic獲取到了setter執行緒修改後的flag並終止,解決記憶體可見性的方式特別多,後面再列幾種;
但是結果證明了,並不是CPU執行不完了999999次的迴圈,而且是很快的執行完,那為什麼和最初什麼都沒加的程式碼相比,加上了這99999次迴圈的耗時,就必須要加上volatile才能讓setter執行緒中的flag的值被cyclic執行緒感知。
去掉volatile,減少for迴圈次數,減少耗時
繼續修改程式碼,去掉volatile,並把for迴圈的次數999999減少至99999(大家不同的機器不同的環境可能需要設定不同數值),Visbility.java程式碼如下:
public class Visbility { private boolean flag; public void cyclic(){ while (!flag){ } } public void setter(){ for (int i = 0; i < 99999; i++) ; flag = true; } }
多次執行Main函式結果:程式幾百毫秒內結束。
這裡我去掉了volatile關鍵字,僅僅減少了setter執行緒修改flag之前模擬的for迴圈耗時,結果似乎又flag記憶體可見了(cyclic死迴圈執行緒終止)。
總結上面的幾中情況
當setter執行緒修改flag之前無任務和耗時相對較短的任務時,不需要volatile修飾flag變數,cyclic執行緒能獲得被setter修改該後的flag值;
當setter執行緒修改該flag之前有耗時相對較長的任務時,需要volatile修改flag變數,cyclic執行緒才能獲得被setter修改該後的flag值。
幾種猜想(暫未證明)
1. 在皮秒級內(這也是為什麼我這裡模擬耗時用for迴圈,而不用TimeUnit,因為TimeUnit最小的單位是納秒,開始我使用最小的單位時間TimeUnit.NANOSECONDS.sleep(1),多次執行程式,每次結果都是一直都不結束,所以我需要更小的耗時時間),JVM已經感知到"flag"被修改,所以兩個執行緒都獲取的主存的值,第一個執行緒的迴圈終止
2. 由於setter執行緒的任務實在是太小(聯想到了程序排程演算法),所以setter在極短時間內被CPU執行完後,執行緒cyclic也立刻被同一個CPU執行,即取的是同一塊本地記憶體(CPU快取記憶體)
3. 由於setter執行緒的任務實在是太小(聯想到了程序排程演算法),所以setter在極短時間內被CPU執行完後,值已經被重新整理到主存,cyclic獲得的是主存中最新的值
本來想驗證下第二種猜想,查了下,暫時無法簡單的通過Java類庫程式碼來獲取當前執行緒是被哪個CPU執行(JNA+本地安裝對應的Library:https://github.com/OpenHFT/Java-Thread-Affinity);
耗時任務的意義
有了這個耗時任務,如果上面的cyclic已經啟動了,JVM感知到(在耗時任務執行過程中,CPU早已做了多次運算了),除了cyclic這個執行緒以外,沒有其他執行緒在操作"flag", JVM會假設"flag"的值一直都沒有被改變,所以cyclic執行緒一直從自身執行緒本地記憶體中獲取值(在未使用synchronized, volatile等實現"flag"的記憶體可見性時) ,所以就算setter執行緒修改"flag"的值,cyclic還是從自己的執行緒的本地記憶體中讀取。
如何保證變數在記憶體中及時可見?
主要有兩種,一種是用volatile,一種是鎖;
還有Atomic Class?底層value也是用的volatile,以及sun.misc.Unsafe:https://www.cnblogs.com/theRhyme/p/12129120.html;
當然AQS也是volatile+sun.misc.Unsase。
Volatile保證變數在記憶體中及時可見
至於volatile例子上面已經寫了,JAVA記憶體模型中VOLATILE關鍵字的作用:https://www.cnblogs.com/theRhyme/p/9396834.html
用鎖來保證記憶體的可見性
鎖有很多很多種,所以實現的方式也有很多,這裡列幾種有趣的實現,比如System.out.println也能保證能保證記憶體可見性?
System.out.println的形式
首先我們把setter修改flag之前新增耗時任務(僅66納秒)TimeUnit.NANOSECONDS.sleep(66),即確保不觸發剛才的猜想:
import java.util.concurrent.TimeUnit; public class Visbility { private boolean flag; public void cyclic(){ while (!flag){ } } public void setter(){ try { TimeUnit.NANOSECONDS.sleep(66); } catch (InterruptedException e) { e.printStackTrace(); } flag = true; } }
執行結果和之前一樣:多次執行Main函式,每次都不結束。
然後我們在cyclic死迴圈裡新增一行輸出語句:System.out.println,不加volatile關鍵字修飾flag,此時Visibility.java如下:
import java.util.concurrent.TimeUnit; public class Visbility { private boolean flag; public void cyclic(){ while (!flag){ System.out.println(flag); } } public void setter(){ try { TimeUnit.NANOSECONDS.sleep(66); } catch (InterruptedException e) { e.printStackTrace(); } flag = true; } }
多次執行Main函式的結果:都是輸出了幾十個false後程序終止。
什麼情況,這裡沒有用volatile修飾flag啊,也沒用鎖啊;
真的沒用鎖嗎?println原始碼如下:
public void println(boolean x) { synchronized (this) { print(x); newLine(); } }
原來是鎖住了this物件,即out屬性的例項,所以我們在這個場景裡用鎖的形式保證變數記憶體及時可見甚至可以是下面這樣:
import java.util.concurrent.TimeUnit; public class Visbility { private boolean flag; public void cyclic(){ while (!flag){ System.out.println(); } } public void setter(){ try { TimeUnit.NANOSECONDS.sleep(66); } catch (InterruptedException e) { e.printStackTrace(); } flag = true; } }
甚至還可以這樣:
public class Visbility { private boolean flag; public void cyclic(){ while (!flag){ synchronized ("123"){ } } } public void setter(){ try { TimeUnit.NANOSECONDS.sleep(66); } catch (InterruptedException e) { e.printStackTrace(); } flag = true; } }
但是不能這樣:
public class Visbility { private boolean flag; public void cyclic(){ synchronized ("123"){ } while (!flag){ } } public void setter(){ try { TimeUnit.NANOSECONDS.sleep(66); } catch (InterruptedException e) { e.printStackTrace(); } flag = true; } }
正常用鎖的方式
還是寫點正常點的程式碼吧。。。也是最基礎的例子
public class Visbility { private boolean flag; public void cyclic(){ while (!isFlag()){ } } public void setter(){ try { TimeUnit.NANOSECONDS.sleep(66); } catch (InterruptedException e) { e.printStackTrace(); } setFlag(true); } public synchronized boolean isFlag() { return flag; } public synchronized void setFlag(boolean flag) { this.flag = flag; } }
在這個場景中,用鎖的方式大同小異,不管是用wait-notifyAll,還是lock*,await-signallAll,亦或是,countdown,await,take,put等方法 ,都是在用鎖而已。
對DCL單例模式的思考
在DCL單例中,既然鎖synchronized能保證原子性和可見性,那volatile的作用是什麼呢?volatile起的作用是禁止指令重排序和可見性。
public class DoubleCheckedLocking { private volatile static DoubleCheckedLocking dcl = null; private DoubleCheckedLocking() { } public static DoubleCheckedLocking getInstance() { if (dcl == null) { synchronized (DoubleCheckedLocking.class) { if (dcl == null) { dcl = new DoubleCheckedLocking(); } } } return dcl; } }
對於"dcl = new DoubleCheckedLocking();"這行程式碼,首先DoubleCheckedLocking.java被編譯成位元組碼,然後被類載入器載入,接著還有下面3步驟:
memory = allocate(); // 1.分配記憶體空間
init(memory); // 2.將物件初始化
dcl = memory;// 3.設定dcl指向剛分配的記憶體地址,此時dcl != null
step2和step3在單執行緒環境下允許指令重排,即先把未初始化的記憶體地址指向dcl(此時dcl!=null),然後才把記憶體空間初始化;
但是如果在多執行緒的環境下,JVM優化指令重排後執行順序如果是step1->step3->step2,A執行緒執行到step3此時還未執行step2物件還未初始化,但是此時dcl已經被賦值為memory,所以dcl!=null,同時另一個執行緒B執行最外層程式碼塊if(dcl==null結果為false),就直接return未被初始化的錯誤的dc