因為我說:volatile 是輕量級的 synchronized,面試官讓我回去等通知!


因為我說:volatile 是輕量級的 synchronized,面試官讓我回去等通知!

volatile 是並發編程的重要組成部分,也是面試常被問到的問題之一。不要向小強那樣,因為一句:volatile 是輕量級的 synchronized,而與期望已久的大廠失之交臂。

volatile 有兩大特性:保證內存的可見性和禁止指令重排序。那什么是可見性和指令重排呢?接下來我們一起來看。

內存可見性

要了解內存可見性先要從 Java 內存模型(JMM)說起,在 Java 中所有的共享變量都在主內存中,每個線程都有自己的工作內存,為了提高線程的運行速度,每個線程的工作內存都會把主內存中的共享變量拷貝一份進行緩存,以此來提高運行效率,內存布局如下圖所示:
內存可見性.png

但這樣就會產生一個新的問題,如果某個線程修改了共享變量的值,其他線程不知道此值被修改了,就會發生兩個線程值不一致的情況,我們用代碼來演示一下這個問題。

public class VolatileExample {
    // 可見性參數
    private static boolean flag = false;

    public static void main(String[] args) {
		new Thread(() -> {
            try {
                // 暫停 0.5s 執行
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("flag 被修改成 true");
        }).start();
        
        // 一直循環檢測 flag=true
        while (true) {
            if (flag) {
                System.out.println("檢測到 flag 變為 true");
                break;
            }
        }
    }
}

以上程序的執行結果如下:

flag 被修改成 true

我們會發現永遠等不到 檢測到 flag 變為 true 的結果,這是因為非主線程更改了 flag=true,但主線程一直不知道此值發生了改變,這就是內存不可見的問題。

內存的可見性是指線程修改了變量的值之后,其他線程能立即知道此值發生了改變。

我們可以使用 volatile 來修飾 flag,就可以保證內存的可見性,代碼如下:

public class VolatileExample {
    // 可見性參數
    private static volatile boolean flag = false;

    public static void main(String[] args) {
		new Thread(() -> {
            try {
                // 暫停 0.5s 執行
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("flag 被修改成 true");
        }).start();
        
        // 一直循環檢測 flag=true
        while (true) {
            if (flag) {
                System.out.println("檢測到 flag 變為 true");
                break;
            }
        }
    }
}

以上程序的執行結果如下:

檢測到 flag 變為 true
flag 被修改成 true

指令重排

指令重排是指在執行程序時,編譯器和處理器常常會對指令進行重排序,已到達提高程序性能的目的。
比如小強要去圖書館還上次借的書,隨便再借一本新書,而此時室友小王也想讓小強幫他還一本書,未發生指令重排的做法是,小強先把自己的事情辦完,再去辦室友的事,這樣顯然比較浪費時間,還有一種做法是,他先把自己的書和小王的書一起還掉,再給自己借一本新書,這就是指令重排的意義。

但指令重排不能保證指令執行的順序,這就會造成新的問題,如下代碼所示:

public class VolatileExample {
    // 指令重排參數
    private static int a = 0, b = 0;
    private static int x = 0, y = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            Thread t1 = new Thread(() -> {
                // 有可能發生指令重排,先 x=b 再 a=1
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(() -> {
                // 有可能發生指令重排,先 y=a 再 b=1
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("第 " + i + "次,x=" + x + " | y=" + y);
            if (x == 0 && y == 0) {
                // 發生了指令重排
                break;
            }
            // 初始化變量
            a = 0;
            b = 0;
            x = 0;
            y = 0;
        }
    }
}

以上程序執行結果如下所示:
指令重排.png
可以看出執行到 48526 次時發生了指令重排,y 就變成了非正確值 0,顯然這不是我們想要的結果,這個時候就可以使用 volatile 來禁止指令重排。

以上我們通過代碼的方式演示了指令重排和內存可見性的問題,接下來我們用代碼來演示一下 volatile 同步方式的問題。

volatile 非同步方式

首先,我們使用 volatile 修飾一個整數變量,再啟動兩個線程分別執行同樣次數的 ++ 和 -- 操作,最后發現執行的結果竟然不是 0,代碼如下:

public class VolatileExample {
    public static volatile int count = 0; // 計數器
    public static final int size = 100000; // 循環測試次數

    public static void main(String[] args) {
        // ++ 方式
        Thread thread = new Thread(() -> {
            for (int i = 1; i <= size; i++) {
                count++;
            }
        });
        thread.start();
        // -- 方式
        for (int i = 1; i <= size; i++) {
            count--;
        }
        // 等所有線程執行完成
        while (thread.isAlive()) {}
        System.out.println(count); // 打印結果
    }
}

以上程序執行結果如下:

1065

可以看出,執行結果並不是我們期望的結果 0,我們把以上代碼使用 synchronized 改造一下:

public class VolatileExample {
    public static int count = 0; // 計數器
    public static final int size = 100000; // 循環測試次數

    public static void main(String[] args) {
        // ++ 方式
        Thread thread = new Thread(() -> {
            for (int i = 1; i <= size; i++) {
                synchronized (VolatileExample.class) {
                    count++;
                }
            }
        });
        thread.start();
        // -- 方式
        for (int i = 1; i <= size; i++) {
            synchronized (VolatileExample.class) {
                count--;
            }
        }
        // 等所有線程執行完成
        while (thread.isAlive()) {}
        System.out.println(count); // 打印結果
    }
}

這次執行的結果變成了我們期望的值 0。

這說明 volatile 只是輕量級的線程可見方式,並不是輕量級的同步方式,所以並不能說 volatile 是輕量級的 synchronized,終於知道為什么面試官讓我回去等通知了。

volatile 使用場景

既然 volatile 只能保證線程操作的可見方式,那它有什么用呢?
volatile 在多讀多寫的情況下雖然一定會有問題,但如果是一寫多讀的話使用 volatile 就不會有任何問題。volatile 一寫多讀的經典使用示例就是 CopyOnWriteArrayList,CopyOnWriteArrayList 在操作的時候會把全部數據復制出來對寫操作加鎖,修改完之后再使用 setArray 方法把此數組賦值為更新后的值,使用 volatile 可以使讀線程很快的告知到數組被修改,不會進行指令重排,操作完成后就可以對其他線程可見了,核心源碼如下:

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    
    private transient volatile Object[] array;
    
	final void setArray(Object[] a) {
        array = a;
    }	
    //...... 忽略其他代碼
}

總結

本文我們通過代碼的方式演示了 volatile 的兩大特性,內存可見性和禁止指令重排,使用 ++ 和 -- 的方式演示了 volatile 並非輕量級的同步方式,以及 volatile 一寫多讀的經典使用案例 CopyOnWriteArrayList。

更多 Java 原創文章,請關注我微信公眾號 「Java中文社群」


免責聲明!

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



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