Volatile的那些事

上一篇中,我們瞭解了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都和內存模型有關。

問題分析

我們再來看看內存模型:

線程的共享數據是存放在主內存的,每個線程都有自己的本地內存,本地內存是線程獨享的。當一個線程需要共享數據,是先去本地內存中查找,如果找到的話,就不會再去主內存中找了,需要修改共享數據的話,是先把主內存的共享數據複製一份到本地內存,然後在本地內存中修改,再把數據複製到主內存。

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

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可以保證“有序性”。

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

內存屏障有四種類型,如下所示:

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

總結下,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是強制內存“可見性”,而這裏是可能可以。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章