Android 多線程之線程安全問題

什么是線程安全問題

線程安全問題不是說線程不安全,而是多個線程之間交錯操作有可能導致數據異常。就比如兩個線程同時對一個數據進行操作,不能保證最后得到是數據是正確的,這就出現了線程安全問題。

什么是Java內存模型

在這之前,先了解下Java內存模型是什么,這能幫助我們更好的理解線程的安全性問題。


Java內存模型

其實線程每次對數據操作,這些數據都是當前線程工作內存中的共享變量副本,并不是直接在主內存操作。每條線程都有自己的工作內存。

如果線程對變量的操作沒有刷回主內存的話,僅僅改變了自己的工作內存的變量副本,那么對其他線程來說是不可見的,不知道這個變量發生變化。而如果一個變量沒有讀取內存中新值,而是使用舊的值去做后續操作的話,會得到一個錯誤的結果,這里體現出線程安全問題 -- 可見性

什么是Java線程調度

在任意時刻,CPU 只能執行一條機器指令,每個線程只有獲取到 CPU 的使用權后,才可以執行指令。也就是在任意時刻,只有一個線程占用 CPU,處于運行的狀態。

多線程并發運行實際上是指多個線程輪流獲取 CPU 使用權,分別執行各自的任務。線程的調度由 JVM 負責,線程的調度是按照特定的機制為多個線程分配 CPU 的使用權。線程調度模型分為兩類:分時調度模型和搶占式調度模型。

1、分時調度模型:讓所有線程輪流獲取 CPU 使用權,并且平均分配每個線程占用 CPU 的時間片。
2、搶占式調度模型:JVM 采用的是搶占式調度模型,也就是先讓優先級高的線程占用 CPU,如果線程的優先級都一樣,那就隨機選擇一個線程,并讓該線程占用 CPU。也就是如果我們同時啟動多個線程,并不能保證它們能輪流獲取到均等的時間片。如果我們的程序想干預線程的調度過程,最簡單的辦法就是給每個線程設定一個優先級。

什么是數據依賴性

如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴。數據依賴分下列三種類型:
1、寫后讀(a = 1;b = a;),寫一個變量之后,再讀這個位置。
2、寫后寫 (a = 1;a = 2;),寫一個變量之后,再寫這個變量。
3、讀后寫(a = b;b = 1;),讀一個變量之后,再寫這個變量。

上面三種情況,只要重排序兩個操作的執行順序,程序的執行結果將會被改變。所以,編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關系的兩個操作的執行順序。也就是說:在單線程環境下,指令執行的最終效果應當與其在順序執行下的效果一致,否則這種優化便會失去意義。

如何保證線程安全

1、原子性
2、可見性
3、有序性
要實現線程安全就要保證上面說到的原子性、可見性和有序性。

原子性

在講原子性之前先來看看這個例子:

a++,對于共享變量a的操作,實際上會執行三個步驟,
1.讀取變量a的值
2.a的值+1
3.將值賦予變量a

這三個操作中任何一個操作過程中,a的值被人篡改,那么都會出現我們不希望出現的結果。在多線程中,a的值可能被其他線程修改,導致線程不安全。為了保證線程安全,必須把這三個步驟當成不可分割的一個整體操作,在其他線程看來,該操作只有未開始和結束的兩種狀態,不知道中間發生什么。這就體現了原子性。

在單線程環境下我們可以認為整個步驟都是原子性操作,但是在多線程環境下則不同,Java只保證了基本數據類型的變量和賦值操作才是原子性的。

注:基本數據類型的變量和賦值操作類似 i = 0 的操作

可見性

剛才講到Java內存模型時,有提及到可見性。可見性是指一個線程對共享變量的更新,對于其他讀取該變量的線程是否可見。怎么讓修改完的數據可見呢?舉個例子

① 將工作內存1中的共享變量的改變更新到主內存中
② 將主內存中最新的共享變量的變化更新到工作內存2中

Java提供了volatile關鍵字來保證可見性。通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖然后執行同步代碼,并且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。

有序性

在Java內存模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程并發執行的正確性。舉個例子

class ReorderExample {
    int a = 0;
    boolean flag = false;
 
    public void writer() {
        a = 1;          // 1
        flag = true;    // 2
    }
 
    public void reader() {
        if (flag) {          // 3
            int i = a * a; // 4
        }
    }
}

思考一下,flag變量是個標記,用來標識變量a是否已被寫入。這里假設有兩個線程A和B,A首先執行writer()方法,隨后B線程接著執行reader()方法。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入?

答案是:不一定能看到。

由于操作1和操作2沒有數據依賴關系,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有數據依賴關系,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會產生什么效果?

執行順序是:2 -> 3 -> 4 -> 1 (這是完全存在并且合理的一種順序,如果你不能理解,請先了解CPU是如何對多個線程進行時間分配的)

操作3和操作4重排序后,因為操作3和操作4存在控制依賴關系。當代碼中存在控制依賴性時,會影響指令序列執行的并行度。為此,編譯器和處理器會采用猜測(Speculation)執行來克服控制相關性對并行度的影響。以處理器的猜測執行為例,執行線程B的處理器可以提前讀取并計算a*a,然后把計算結果臨時保存到一個名為重排序緩沖(reorder buffer ROB)的硬件緩存中。當接下來操作3的條件判斷為真時,就把該計算結果寫入變量i中。我們可以看出,猜測執行實質上對操作3和4做了重排序。重排序在這里破壞了多線程程序的語義。

在Java里面,可以通過volatile關鍵字來保證一定的“有序性”。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當于是讓線程順序執行同步代碼,自然就保證了有序性。另外,Java內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。

happens-before原則(先行發生原則)

程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生于書寫在后面的操作。

鎖定規則:一個unLock操作先行發生于后面對同一個鎖額lock操作。

volatile變量規則:對一個變量的寫操作先行發生于后面對這個變量的讀操作。

傳遞規則:如果操作A先行發生于操作B,而操作B又先行發生于操作C,則可以得出操作A先行發生于操作C。

線程啟動規則:Thread對象的start()方法先行發生于此線程的每個一個動作。

線程中斷規則:對線程interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生。

線程終結規則:線程中所有的操作都先行發生于線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行。

對象終結規則:一個對象的初始化完成先行發生于他的finalize()方法的開始。

總結

要實現線程安全就要保證上面說到的原子性、可見性和有序性。

注:提及到的volatile、synchronized和Lock后續寫一篇關于鎖的文章Android多線程之鎖

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容