從一個小例子引發的Java內存可見性的簡單思考和猜想以及DCL單例模式中的volatile的核心作用


環境

OS Win10
CPU 4核8線程
IDE IntelliJ IDEA 2019.3
JDK 1.8 -server模式

 

JVM被設置成-server模式的意義

其中之一是為了線程的執行效率,從線程的私有內存中讀取變量,而不是從主存中獲取;

比如主存中有個變量A,第一次線程從主存中取得A變量的值后,會復制到自己的私有內存中,以后也會從自己的私有內存中取A變量的值,那么主存中的A被更改,則無法及時獲取,這時候就需要讓A變量在內存可見。

 

場景

最初的代碼

一個線程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) {// 第一個if不用獲取鎖就能判斷對象是否為null(效率),第二個if存在的原因是線程安全
       
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被初始化的錯誤的dcl


免責聲明!

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



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