volatile關鍵字在Android中到底有什麼用?

郭霖發表於2020-10-12
本文同步發表於我的微信公眾號,掃一掃文章底部的二維碼或在微信搜尋 郭霖 即可關注,每個工作日都有文章更新。

上週六在公眾號分享了一篇關於Java volatile關鍵字的文章,釋出之後有朋友在留言裡指出,說這個關鍵字沒啥用啊,Android開發又不像伺服器那樣有那麼高的併發,老分享這種知識幹啥?

讓我意識到有些朋友對於volatile這個關鍵字的理解還是有誤區的。

另外也有朋友留言說,雖然知道volatile關鍵字的作用,但是想不出在Android開發中具體有什麼用途。

所以我準備寫篇文章來剖析一下這個關鍵字,順便回答一下這些朋友的疑問。

由於這篇文章是我用週日一天時間趕出來的,所以可能不會像平時的文章那樣充實,但是對於上述問題我相信還是可以解釋清楚的。

對volatile關鍵字的作用有疑問的同學,可能都不太瞭解CPU快取記憶體這個概念,所以我們先從這個概念講起。

CPU快取記憶體和可見性問題

當一個程式執行的時候,資料是儲存在記憶體當中的,但是執行程式這個工作卻是由CPU完成的。那麼當CPU正在執行著任務呢,突然需要用到某個資料,它就會從記憶體中去讀取這個資料,得到了資料之後再繼續向下執行任務。

這是理論上理想的工作方式,但是卻存在著一個問題。我們知道,CPU的發展是遵循摩爾定律的,每18個月左右積體電路上電晶體的數量就可以翻一倍,因此CPU的速度只會變得越來越快。

但是光CPU快沒有用呀,因為CPU再快還是要從記憶體去讀取資料,而這個過程是非常緩慢的,所以就大大限制了CPU的發展。

為了解決這個問題,CPU廠商引入了快取記憶體功能。記憶體裡儲存的資料,CPU快取記憶體裡也可以存一份,這樣當頻繁需要去訪問某個資料時就不需要重複從記憶體中去獲取了,CPU快取記憶體裡有,那麼直接拿快取中的資料即可,這樣就可以大大提升CPU的工作效率。

而當程式要對某個資料進行修改時,也可以先修改快取記憶體中的資料,因為這樣會非常快,等運算結束之後,再將快取中的資料寫回到記憶體當中即可。

這種工作方式在單執行緒的場景下是沒問題的,準確來講,在單核多執行緒的場景下也是沒問題的。但如果到了多核多執行緒的場景下,可能就會出現問題。

我們都知道,現在不管是手機還是電腦,動不動就聲稱是多核的,多核就是CPU中有多個運算單元的意思。因為一個運算單元在同一時間其實只能處理一個任務,即使我們開了多個執行緒,對於單核CPU而言,它只能先處理這個執行緒中的一些任務,然後暫停下來轉去處理另外一個執行緒中的任務,以此交替。而多核CPU的話,則可以允許在同一時間處理多個任務,這樣效率當然就更高了。

但是多核CPU又帶來了一個新的挑戰,那就是在多執行緒的場景下,CPU快取記憶體中的資料可能不準確了。原因也很簡單,我們通過下面這張圖來理解一下。

可以看到,這裡有兩個執行緒,分別通過兩個CPU的運算單元來執行程式,但它們是共享同一個記憶體的。現在CPU1從記憶體中讀取資料A,並寫入快取記憶體,CPU2也從記憶體中讀取資料A,並寫入快取記憶體。

到目前為止還是沒有問題的,但是如果執行緒2修改了資料A的值,首先CPU2會更新快取記憶體中A的值,然後再將它寫回到記憶體當中。這個時候,執行緒1再訪問資料A,CPU1發現快取記憶體當中有A的值啊,那麼直接返回快取中的值不就行了。此時你會發現,執行緒1和執行緒2訪問同一個資料A,得到的值卻不一樣了。

這就是多核多執行緒場景下遇到的可見性問題,因為當一個執行緒去修改某個變數的值時,該變數對於另外一個執行緒並不是立即可見的。

為了讓以上理論知識更具有說服力,這裡我編寫了一個小Demo來驗證上述說法,程式碼如下所示:

public class Main {

    static boolean flag;

    public static void main(String... args) {
        new Thread1().start();
        new Thread2().start();
    }

    static class Thread1 extends Thread {
        @Override
        public void run() {
            while (true) {
                if (flag) {
                    flag = false;
                    System.out.println("Thread1 set flag to false");
                }
            }
        }
    }

    static class Thread2 extends Thread {
        @Override
        public void run() {
            while (true) {
                if (!flag) {
                    flag = true;
                    System.out.println("Thread2 set flag to true");
                }
            }
        }
    }

}

這段程式碼真的非常簡單,我們開啟了兩個執行緒來對同一個變數flag進行修改。Thread1使用一個while(true)迴圈,發現flag是true時就把它改為false。Thread2也使用一個while(true)迴圈,發現flag是false時就把它改為true。

理論上來說,這兩個執行緒同時執行,那麼就應該一直交替列印,你改我的值,我再給你改回去。

實際上真的會是這樣嗎?我們來執行一下就知道了。

可以看到,列印過程只持續了一小會就停止列印了,但是程式卻沒有結束,依然顯示在執行中。

這怎麼可能呢?理論上來說,flag要麼為true,要麼為false。true的時候Thread1應該列印,false的時候Thread2應該列印,兩邊都不列印是為什麼呢?

我們用剛才所學的知識就可以解釋這個原本解釋不了的問題,因為Thread1和Thread2的CPU快取記憶體中各有一份flag值,其中Thread1中快取的flag值是false,Thread2中快取的flag值是true,所以兩邊就都不會列印了。

這樣我們就通過一個實際的例子演示了剛才所說的可見性問題。那麼該如何解決呢?

答案很明顯,volatile。

volatile這個關鍵字的其中一個重要作用就是解決可見性問題,即保證當一個執行緒修改了某個變數之後,該變數對於另外一個執行緒是立即可見的。

至於volatile的工作原理,太底層方面的內容我也說不上來,大概原理就是當一個變數被宣告成volatile之後,任何一個執行緒對它進行修改,都會讓所有其他CPU快取記憶體中的值過期,這樣其他執行緒就必須去記憶體中重新獲取最新的值,也就解決了可見性的問題。

我們可以將剛才的程式碼進行如下修改:

public class Main {

    volatile static boolean flag;
    ...

}

沒錯,就是這麼簡單,在flag變數的前面加上volatile關鍵字即可。然後重新執行程式,效果如下圖所示。

一切如我們所預期的那樣執行了。

指令重排問題

volatile關鍵字還有另外一個重要的作用,就是禁止指令重排,這又是一個非常有趣的問題。

我們先來看兩段程式碼:

// 第一段程式碼
int a = 10;
int b = 5;
a = 20;
System.out.println(a + b);

// 第二段程式碼
int a = 10;
a = 20;
int b = 5;
System.out.println(a + b);

第一段程式碼,我們宣告瞭一個a變數等於10,又宣告瞭一個b變數等於5,然後將a變數的值改成了20,最後列印a + b的值。

第二段程式碼,我們宣告瞭一個a變數等於10,然後將a變數的值改成了20,又宣告瞭一個b變數等於5,最後列印a + b的值。

這兩段程式碼有區別嗎?

不用瞎猜了,這兩段程式碼沒有任何區別,宣告變數b和修改變數a之間的順序是隨意的,它們之間誰也不礙著誰。

也正是因為這個原因,CPU在執行程式碼時,其實並不一定會嚴格按照我們編寫的順序去執行,而是可能會考慮一些效率方面的原因,對那些先後順序無關緊要的程式碼進行重新排序,這個操作就被稱為指令重排。

這麼看來,指令重排這個操作沒毛病啊。確實,但只限在單執行緒環境下。

很多問題一旦進入了多執行緒環境,就會變得更加複雜,我們來看如下程式碼:

public class Main {

    static boolean init;
    static String value;

    static class Thread1 extends Thread {
        @Override
        public void run() {
            value = "hello world";
            init = true;
        }
    }

    static class Thread2 extends Thread {
        @Override
        public void run() {
            while (!init) {
                // 等待初始化完成
            }
            value.toUpperCase();
        }
    }

}

這段程式碼的思路仍然很簡單,Thread1用於對value資料進行初始化,初始化完成之後會將init設定成true。Thread2則會先通過while迴圈等待初始化完成,完成之後再對value資料進行操作。

那麼這段程式碼可以正常工作嗎?未必,因為根據剛才的指令重排理論,Thread1中value和init這兩個變數之間是沒有先後順序的。如果CPU將這兩條指令進行了重排,那麼就可能出現初始化已完成,但是value還沒有賦值的情況。這樣Thread2的while迴圈就會跳出,然後在操作value的時候出現空指標異常。

所以說,指令重排功能一旦進入了多執行緒環境,也是可能會出現問題的。

而至於解決方案嘛,當然還是volatile了。

對某個變數宣告瞭volatile關鍵字之後,同時也就意味著禁止對該變數進行指令重排。所以我們只需要這樣修改程式碼就能夠保證程式的安全性了。

public class Main {

    volatile static boolean init;
    ...

}

volatile在Android上的應用

現在我們已經瞭解了volatile關鍵字的主要作用,但是就像開篇時那位朋友提到的一樣,很多人想不出來這個關鍵字在Android上有什麼用途。

其實我覺得任何一個技術點都不應該去生搬硬套,你只要掌握了它,該用到時能想到它就可以了,而不是絞盡腦汁去想我到底要在哪裡使用它。

我在看一些Google庫的原始碼時,其實時不時就能看到這個關鍵字,只要是涉及多執行緒程式設計的時候,volatile的出場率還是不低的。

這裡我給大家舉一個常見的示例吧,在Android上我們應該都編寫過檔案下載這個功能。在執行下載任務時,我們需要開啟一個執行緒,然後從網路上讀取流資料,並寫入到本地,重複執行這個過程,直到所有資料都讀取完畢。

那麼這個過程我可以用如下簡易程式碼進行表示:

public class DownloadTask {

    public void download() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    byte[] bytes = readBytesFromNetwork(); // 從網路上讀取資料
                    if (bytes.length == 0) {
                        break; // 下載完畢,跳出迴圈
                    }
                    writeBytesToDisk(bytes); // 將資料寫入到本地
                }
            }
        }).start();
    }

}

到此為止沒什麼問題。

不過現在又來了一個新的需求,要求允許使用者取消下載。我們都知道,Java的執行緒是不可以中斷的,所以如果想要做取消下載的功能,一般都是通過標記位來實現的,程式碼如下所示:

public class DownloadTask {

    boolean isCanceled = false;

    public void download() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (!isCanceled) {
                    byte[] bytes = readBytesFromNetwork();
                    if (bytes.length == 0) {
                        break;
                    }
                    writeBytesToDisk(bytes);
                }
            }
        }).start();
    }

    public void cancel() {
        isCanceled = true;
    }

}

這裡我們增加了一個isCanceled變數和一個cancel()方法,呼叫cancel()方法時將isCanceled變數設定為true,表示下載已取消。

然後在download()方法當中,如果發現isCanceled變數為true,就跳出迴圈不再繼續執行下載任務,這樣也就實現了取消下載的功能。

這種寫法能夠正常工作嗎?根據我的實際測試,確實基本上都是可以正常工作的。

但是這種寫法真的安全嗎?不,因為你會發現download()方法和cancel()方法是執行在兩個執行緒當中的,因此cancel()方法對於isCanceled變數的修改,未必對download()方法就立即可見。

所以,存在著這樣一種可能,就是我們明明已經將isCanceled變數設定成了true,但是download()方法所使用的CPU快取記憶體中記錄的isCanceled變數還是false,從而導致下載無法被取消的情況出現。

因此,最安全的寫法就是對isCanceled變數宣告volatile關鍵字:

public class DownloadTask {

    volatile boolean isCanceled = false;
    ...

}

這樣就可以保證你的取消下載功能始終是安全的了。

好了,關於volatile關鍵字的作用,以及它在Android開發中具體有哪些用途,相信到這裡就解釋的差不多了。

本來是想用週日一天時間寫篇小短文的,寫著寫著好像最後又寫出了不少內容,不過只要對大家有幫助就好。


如果想要學習Kotlin和最新的Android知識,可以參考我的新書 《第一行程式碼 第3版》點選此處檢視詳情


關注我的技術公眾號,每個工作日都有優質技術文章推送。

微信掃一掃下方二維碼即可關注:

相關文章