《2022問》1:爲什麼多線程會有可能不安全

        似乎,只要談到多線程,都會跟安全或不安全聯繫起來。但是爲什麼多線程就會有可能出現不安全的問題呢?爲什麼 Java 語言的設計就不能讓多線程的時候必然安全呢?這樣我們不就省了很多事嗎?事實是,Java 並不會無感保證多線程安全,這就使得程序員不得不去了解如何才能保證多線程安全的相關編碼內容了。

        首先,什麼叫做多線程不安全?這裏面說的“不安全”指的是數據的不安全,是由於多線程的情況下,某些數據有可能出現髒讀、髒寫等不安全的問題。那麼 Java 會在何種情況下出現髒讀、髒寫之類的問題呢?通常是對象內的成員變量(被 final 修飾的變量除外,這個很好理解,它要求在使用前就初始化了,後續不可變,自然不會不安全)被多個線程所訪問的時候。

public class MainTest {
    public static void main(String[] args) throws InterruptedException {
        Obj obj = new Obj();

        Thread[] tArr = new Thread[10];
        for (int i=0; i<tArr.length; i++){
            tArr[i] = new Thread(new MyInit(obj));
            tArr[i].start();
        }
    }
}

class MyInit implements Runnable{
    private Obj obj;

    public MyInit(Obj obj){
        this.obj = obj;
    }

    @Override
    public void run() {
        for (int i=0; i<1000; i++){
            obj.add();
        }
        System.out.print(obj.get() + "\t");
    }
}

class Obj {
    private int i = 0;

    public int get() {
        return i;
    }

    public void add() {
        this.i++;
    }
}

        如上的代碼模擬了10個線程併發進行的情況,每個線程都會對 obj 對象的成員變量 i 自增1000次的操作後輸出 i 的當前值,輸出的結果如下所示:

2114    2856    2024    4152    4676    5542    6542    7919    8383    9383

        每次輸出的值都會不一樣——這很好理解,由於併發的原因,同一時刻會有多個線程都會對 i 做自增操作,但是,爲什麼這裏面輸出的最大值不是10000呢?

        雖然存在着多個線程同時對單個變量進行操作,但是並不涉及其他的計算,這僅僅是讓 obj 對象的 i 進行 i++ 的操作而已,這裏面輸出的最大值肯定是最後執行完的那個線程輸出的吧?最終不應該是自增了10000次嗎?爲什麼最大值不是10000呢?——這似乎會令不少人感到疑惑,如果 obj 對象的 i 在內存中只會被存儲一份的話,那麼結果確實會是10000,而不是9000多的某個數字。

        令人遺憾的是:i 並不是每個線程都能夠直接操作到它的,在i與線程之間,還隔着一層“工作內存”——相當於緩存,既然存在着緩存,那麼必然存在着緩存一致性的問題,需要正確理解並處理才能獲得預想中的結果。

        這是 Java 的內存模型所決定的。簡述一下原因:

        現代計算機的CPU速度通常比內存讀寫速度高几個量級,如果CPU每個動作直接作用在內存,那麼必然導致非常多的IO等待——因爲某個計算過程在CPU中可能瞬間就完成了,要把這個結果寫入到內存中,這個寫入過程對於CPU來說實在太慢了。於是現代計算機基本都會在CPU和內存之間引入一層或多層高速緩存,這個緩存的速度相比內存而言,更接近CPU。於是CPU第一次讀取是從內存讀取的,然後把讀取結果放在緩存中,計算的結果也寫到緩存中,緩存最終會寫入內存中——這中間的緩存一致性問題會由緩存一致性協議解決。

        而 JVM 爲了提高運行速度,也會利用到計算機的高速緩存。JVM 的內存模型分爲了工作內存和主內存,其中的工作內存就是使用了計算機的高速緩存,所以它的速度會比主內存高很多,可類比計算機,JVM 執行時,第一次讀取是從主內存讀取的,隨後的操作都在工作內存中進行,最終的結果會寫回到主內存中——工作內存與主內存自然就存在着一致性問題了。

        

        如圖所示,每個線程都會有單獨的工作內存,互相不會互通,最終通過 Save、Load 操作作用到同一個主內存。

        上述的 Save、Load 操作並不會保證工作內存與主內存之間的一致性,或者說用“一致性”來描述過於模糊,準確點說是:在這個內存模型下,存在三個待解決的問題,分別是原子性、可見性和有序性。

原子性:某操作只需一行機器碼完成 或 鎖定某段代碼使其不會併發進行

可見性:使“工作內存與主內存同步延遲”的情況不會發生

有序性:使代碼不會發生指令重排,保證代碼順序即是執行順序

        上面的代碼之所以輸出的最大值不是預料中的10000,就是因爲每次i++的操作不具備原子性(雖然我們的代碼只有一步,但是實際的機器碼操作不止一步,這樣在步驟之間有可能實際的值已經被其他線程更改了,而本線程依然使用舊值進行增1計算)並且不具備可見性(本線程更改後的i值只是在本線程的工作內存中,還沒同步到主內存;或者其他線程的更改也沒同步到主內存,本線程不能獲取實際的最新的值),上述代碼暫時不涉及有序性。

        對於這些問題,Java 提供了兩個關鍵字以供開發者使用:volatile 和 synchronized。

volatile:保證可見性和有序性(volatile 的其中一個語義爲禁止指令重排,從而保證了有序性),不保證原子性

synchronized:同時保證原子性、可見性和有序性(synchronized 的做法是對要操作的變量加鎖,加鎖的變量在同一個時刻只允許一個線程進行操作——這樣,雖然它並沒有禁止指令重排,但是隻有一個線程進行操作的時候,是否存在指令重排其實並沒有任何不良影響,可以認爲有序性天然得到保證)

        討論到這裏可知,volatile 不能保證上述代碼得到預期的結果,因爲不保證原子性。volatile 的用途應該是不需要使用被修飾的變量進行任何計算的時候,應用範圍並不廣。synchronized 因爲保證了原子性,也就是鎖住的變量只能由一個線程操作,所以能夠保證輸出的最大值必然是10000,只需要更改成以下代碼即可:

//    public void add() {
    public synchronized void add() {
        this.i++;
    }

        或許你已經發現了,synchronized 雖然幾乎完美地保證了多線程下對變量的安全操作,但是它最突出的缺點也顯而易見,即是性能會很低。因爲它是系統級實現的線程串行化,第一個線程獲取到鎖後獨佔,其他線程則進入休眠,獨佔線程釋放鎖後通知休眠的線程喚醒,競爭到鎖再繼續執行——這個過程中,休眠和喚醒都涉及當前線程的上下文數據的現場保護、現場恢復,線程一多,這些上下文數據的維護會是一個非常大的負擔。

        那麼到底有沒有一種辦法,既解決了多線程的安全問題,又能夠不犧牲掉這麼多性能呢?還真的有,那就是 AQS(AbstractQueuedSynchronizer),從 JDK1.5 開始提供。

(不要問我“既然有那你前面還費字瞎比比那麼多幹啥?”,你這樣會顯得我很呆😳)

        AQS 的實現原理是使用 CPU 提供的 CAS 指令保證加鎖時只會有一個線程加鎖成功,加鎖的對象的頭部信息會加上標記,表示本對象被加了樂觀鎖(因爲 CAS 跟樂觀鎖原理類似,爲方便,後面稱爲樂觀鎖,同理,將 synchronized 加的鎖稱爲悲觀鎖)。

        CAS 全稱 Compare-and-Swap,即比較和交換,它允許兩個值:比較值和交換值,比較值與當前的值比較,如果相同,則跟交換值交換並返回當前值,否則直接返回當前值——整個比較和交換的操作是原子性的,由 CPU 保證。這跟樂觀鎖原理類似,比較值是本線程讀取到的值,如果比較時發現不相同則表示該值被其他線程更改了,否則表示未發生更改,可以使用交換值進行更新。

        而 AQS 在加鎖前會先判斷該對象是否存在樂觀鎖,不存在樂觀鎖時纔會在加樂觀鎖的步驟前使用 CAS 來保證只會有一個線程加鎖成功。

        ReentratLock 是 AQS 的一種實現,它的主要方法是 lock()、tryLock()、tryLock(long timeout, TimeUnit unit)、unlock();

lock() 方法:它是直接加鎖,如果加樂觀鎖失敗,則直接轉爲悲觀鎖 synchronized。沒有返回值

tryLock() 方法:它是自旋加鎖,即如果加樂觀鎖失敗,則繼續重試(多次重試的過程稱爲自旋),直到成功爲止。沒有返回值

tryLock(long timeout, TimeUnit unit) 方法:它是有嘗試時間限制的自旋加鎖,返回值爲 boolean 類型。如果在 timeout 時間之內沒有加上樂觀鎖,則返回false,成功則返回true

unlock() 方法:解鎖

        ReentratLock 是可重入的,但是必須保證加鎖的次數與解鎖的次數相同。

        ReentratLock 與 synchronized 相比,ReentratLock 使用方式豐富,性能更優,但相對而言繁瑣且需要手動除鎖;synchronized 則相對簡單便利,但存在 CPU 對多線程上下文的維護開銷(ReentratLock 則不涉及線程切換的問題)。

        但是,但是纔是重點,你可能從上面開始就開始覺得,既然 ReentratLock 這麼溜,幹嘛還非得講半天 synchronized,雖然是用起來麻煩了一點,但只要我學會了,不就替代掉 synchronized 了嗎?然鵝事情並沒有這麼簡單,因爲 ReentratLock 並不是設計來替代 synchronized 的

        舉個簡單的例子: ReentratLock 的 lock() 方法,如果加樂觀鎖失敗,則直接轉爲悲觀鎖 synchronized,所以這個方法加的鎖既有可能是樂觀鎖,也可能是悲觀鎖;當然你如果不喜歡它的不確定性,也可以使用 tryLock() 方法,但是,這也無法保證必然比 synchronized 高效,因爲如果存在大量線程競爭鎖的時候,tryLock() 一直自旋加鎖的過程會存在大量的失敗,即是某個線程一直都無法獲得鎖,因爲每次加鎖前的 CAS 操作都無法成功(被其他線程搶先了)——這是不公平鎖的情況,如果是公平鎖,也可能存在非常糟糕的情況:幾乎每個線程都需要等待大量的時間才能成功。又或者使用有嘗試時間限制的自旋加鎖方法tryLock(long timeout, TimeUnit unit),也有可能導致大量的超時。當然,如果出現大量的併發時,synchronized 也可能存在類似的問題。

        可見,ReentratLock 並沒有比 synchronized 高到哪裏去,當使用不當時甚至有可能比 synchronized 更加糟糕。只有當併發量低的時候 ReentratLock 才比 synchronized 有更好的表現,所以我認爲 ReentratLock 是低併發時的解決方案。

        說了半天,原來 ReentratLock 也不是萬能的,那麼到底有沒有萬能的解決方案呢?目前來看就只剩下 TreadLocal 了,它是每個線程都互相隔離的,自然就不存在競爭的問題了。顯然,這種做法需要付出更多的內存消耗來實現,是空間換時間的做法。以下是簡單的代碼例子:

// 聲明線程隔離的變量,變量類型通過泛型決定
private static ThreadLocal<Integer> localInt = new ThreadLocal<>();

// 獲取泛型類的對象
Integer integer = localInt.get();

if (integer==null){
    integer = 0;
}

// 將泛型對象設到變量中
localInt.set(++integer);

         以上便是可能會發生多線程不安全問題的原因所在以及三種解決方案,分別是系統級別的同步線程鎖(synchronized)、代碼級別的AQS樂觀鎖(ReentratLock )以及線程隔離類(ThreadLocal)。並且就 Java 這門語言而言,多線程下的安全問題並不是語言本身天然保證的,目的是爲了提高代碼的執行速度,於是開發人員必須學習並掌握其中的緣由以及保證多線程安全的能力。

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