Volatile的那些事

CodeBear發表於2018-12-27

上一篇中,我們瞭解了Synchronized關鍵字,知道了它的基本使用方法,它的同步特性,知道了它與Java記憶體模型的關係,也明白了Synchronized可以保證“原子性”,“可見性”,“有序性”。今天我們來看看另外一個關鍵字Volatile,這也是極其重要的關鍵字之一。毫不誇張的說,面試的時候談到Synchronized,必定會談到Volatile。

一個小栗子

public class Main {
    private static boolean isStop = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                if (isStop) {
                    System.out.println("結束");
                    return;
                }
            }
        }).start();

        try {
            TimeUnit.SECONDS.sleep(3);
            isStop = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

首先定義了一個全域性變數:isStop=false。然後在main方法裡面開了一個執行緒,裡面是一個死迴圈,當isStop=true,列印出一句話,結束迴圈。主執行緒睡了三秒鐘,把isStop改為true。

按道理來說,3秒鐘後,會列印出一句話,並且結束迴圈。但是,出人意料的事情發生了,等了很久,這句話遲遲沒有出現,也沒有結束迴圈。

這是為什麼?這又和記憶體模型有關了,由此可見,記憶體模型是多麼重要,不光是Synchronized,還是這次的Volatile都和記憶體模型有關。

問題分析

我們再來看看記憶體模型:

image.png

執行緒的共享資料是存放在主記憶體的,每個執行緒都有自己的本地記憶體,本地記憶體是執行緒獨享的。當一個執行緒需要共享資料,是先去本地記憶體中查詢,如果找到的話,就不會再去主記憶體中找了,需要修改共享資料的話,是先把主記憶體的共享資料複製一份到本地記憶體,然後在本地記憶體中修改,再把資料複製到主記憶體。

如果把這個搞明白了,就很容易理解為什麼會產生上面的情況了:

isStop是共享資料,放在了主記憶體,子執行緒需要這個資料,就把資料複製到自己的本地記憶體,此時isStop=false,以後直接讀取本地記憶體就可以。主執行緒修改了isStop,子執行緒是無感知的,還是去本地記憶體中取資料,得到的isStop還是false,所以就造成了上面的情況。

Volatile與可見性

如何解決這個問題呢,只需要給isStop加一個Volatile關鍵字:

public class Main {
    private static volatile boolean isStop = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                if (isStop) {
                    System.out.println("結束");
                    return;
                }
            }
        }).start();

        try {
            TimeUnit.SECONDS.sleep(3);
            isStop = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

執行,問題完美解決。

Volatile的作用:

  1. 當一個變數加了volatile關鍵字後,執行緒修改這個變數後,強制立即重新整理回主記憶體。

  2. 如果其他執行緒的本地記憶體中有這個變數的副本,會強制把這個變數過期,下次就不能讀取這個副本了,那麼就只能去主記憶體取,拿到的資料就是最新的。

正是由於這兩個原因,所以Volatile可以保證“可見性”

Volatile與有序性

指令重排的基本概念就不再闡述了,上兩節內容已經介紹了指令重排的基本概念。

指令重排遵守的happens-before規則,其中有一條規則,就是Volatile規則:

被Volatile標記的不允許指令重排。

所以,Volatile可以保證“有序性”。

那內部是如何禁止指令重排的呢?在指令中插入記憶體屏障

記憶體屏障有四種型別,如下所示:

image.png

在生成指令序列的時候,會根據具體情況插入不同的記憶體屏障。

總結下,Volatile可以保證“可見性”,“有序性”

Volatile與單例模式

public class Main {
    private static Main main;

    private Main() {
    }

    public static Main getInstance() {
        if (main != null) {
            synchronized (Main.class) {
                if (main != null) {
                    main = new Main();
                }
            }
        }
        return main;
    }
}

這裡比較經典的單例模式,看上去沒什麼問題,執行緒安全,效能也不錯,又是懶載入,這個單例模式還有一個響噹噹的名字:DCL

但是實際上,還是有點問題的,問題就出在

  main = new Main();

這又和記憶體模型有關係了。執行這個建立物件會有3個步驟:

  1. 分配記憶體
  2. 執行構造方法
  3. 指向地址

說明建立物件不是原子性操作,但是真正引起問題的是指令重排。先執行2,還是先執行3,在單執行緒中是無所謂的,但是在多執行緒中就不一樣了。如果執行緒A先執行3,還沒來得及執行2,此時,有一個執行緒B進來了,發現main不為空了,直接返回main,然後使用返回出來的main,但是此時main還不是完整的,因為執行緒A還沒有來得及執行構造方法。

所以單例模式得在定義變數的時候,加上Volatile,即:

public class Main {
    private volatile static Main main;

    private Main() {
    }

    public static Main getInstance() {
        if (main == null) {
            synchronized (Main.class) {
                if (main == null) {
                    main = new Main();
                }
            }
        }
        return main;
    }
}

這樣就可以避免上面所述的問題了。

好了,這篇文章到這裡主要內容就結束了,總結全文:Volatile可以保證“有序性”,“可見性”,但是無法保證“原子性”

題外話

嘿嘿,既然上面說的是主要內容結束了,就代表還有其他內容。

我們把文章開頭的例子再次拿出來:

public class Main {
    private static boolean isStop = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                if (isStop) {
                    System.out.println("結束");
                    return;
                }
            }
        }).start();

        try {
            TimeUnit.SECONDS.sleep(3);
            isStop = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

如果既想讓子執行緒結束,又不想加Volatile關鍵字怎麼辦?這真的可以做到嗎?當然可以。

public class Main {
    private static boolean isStop = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if (isStop) {
                    System.out.println("結束");
                    return;
                }
            }
        }).start();

        try {
            TimeUnit.SECONDS.sleep(3);
            isStop = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在這裡,我讓子執行緒也睡了一秒,執行程式,發現子執行緒停止了。

public class Main {
    private static boolean isStop = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                System.out.println("Hello");
                if (isStop) {
                    System.out.println("結束");
                    return;
                }
            }
        }).start();

        try {
            TimeUnit.SECONDS.sleep(3);
            isStop = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

我把上面的讓子執行緒睡一秒鐘的程式碼替換成 System.out.println,竟然也成功讓子執行緒停止了。

public class Main {
    private static boolean isStop = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                Random random=new Random();
                random.nextInt(150);
                if (isStop) {
                    System.out.println("結束");
                    return;
                }
            }
        }).start();

        try {
            TimeUnit.SECONDS.sleep(3);
            isStop = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

這樣也可以。

為什麼呢?

因為JVM會盡力保證記憶體的可見性,即使這個變數沒有加入Volatile關鍵字,主要CPU有時間,都會盡力保證拿到最新的資料。但是第一個例子中,CPU不停的在做著死迴圈,死迴圈內部就是判斷isStop,沒有時間去做其他的事情,但是隻要給它一點機會,就像上面的 睡一秒鐘,列印出一句話,生成一個隨機數,這些操作都是比較耗時的,CPU就可能可以去拿到最新的資料了。不過和Volatile不同的是 Volatile是強制記憶體“可見性”,而這裡是可能可以。