Java下如何保證多執行緒安全

秦淮煙下發表於2021-07-30

前言

        可能有人會覺得,只要我寫程式碼的時候不去開啟其他執行緒,那麼就不會有多執行緒的問題了。
        然而事實並非如此,如果僅僅是一些簡單的測試程式碼,確實程式碼都會順序執行而不是併發執行,但是Java應用最廣泛的web專案中,絕大部分(如果不是所有的話)web容器都是多執行緒的——以tomcat為例, 每一個進來的請求都需要一個執行緒,直到該請求結束。 這樣一來,即使本身不打算多執行緒執行的程式碼,實際上幾乎都會以多執行緒的方式執行。
        在 Spring 註冊的 bean(預設都是單例),在設為單例的 bean 中出現的成員變數或靜態變數,都必須注意是否存在多執行緒競爭導致的多執行緒不安全的問題。
        ——可見,有些時候確實都是人在江湖,身不由己。積累多執行緒的知識是必不可少的。

1.為什麼會有多執行緒不安全的問題

1.1.寫不安全

        上面講到 web 容器會多執行緒訪問 JVM,這裡還有一個問題,為什麼多執行緒時就會存在多執行緒不安全呢?這是因為在 JVM 中的記憶體管理,並不是所有記憶體都是執行緒私有的,Heap(Java堆)中的記憶體是執行緒共享的。
        而 Heap 中主要是存放物件的,這樣多個執行緒訪問同一個物件時,就會使用到同一塊記憶體了,在這塊記憶體中存著的成員變數就會受到多個執行緒的操作。
如下圖所示:
  Java下如何保證多執行緒安全
因為是增加2和3,結果應該是15才對,但是因為多執行緒的原因,導致結果是12或13。

1.2.讀不安全

        上面的寫操作不安全是一方面,事實上 Java 中還存在更加糟糕的問題,就是讀到的資料也不一致。
        因為多個執行緒雖然訪問物件時是使用的同一塊記憶體(這塊記憶體可稱為主記憶體),但是為了提高效率,每個執行緒有時會都會將讀取到的值快取在本執行緒內(具體因不同 JVM 的實現邏輯而有不同,所以快取不是必然的),這些快取的資料可稱為副本資料。
        這樣,就會出現,某個值已經被某個執行緒更改了,但是其他執行緒卻不知道,也不去主記憶體更新資料的情況。
如下圖所示:
 Java下如何保證多執行緒安全
上圖的情況,其實執行緒的併發度相對要低一點,但即使是其他執行緒更改的資料,有的執行緒也不知道,因為讀不安全導致了資料不一致。

2.如何讓多執行緒安全

        既然已經知道了會發生不安全的問題,那麼要怎麼解決這些問題呢?

2.1.讀一致性

        Java 中針對上述“讀不安全”的問題提供了關鍵字 volatile 來解決問題,被 volatile 修飾的成員變數,在內容發生更改的時候,會通知所有執行緒去主記憶體更新最新的值,這樣就解決了讀不安全的問題,實現了讀一致性。
        但是,讀一致性是無法解決寫一致性的,雖然能夠使得每個執行緒都能及時獲取到最新的值,但是1.1中的寫一致性問題還是會存在。
        既然如此,Java 為啥還要提供 volatile 關鍵字呢?這並非多餘的存在,在某些場景下只需要讀一致性的話,這個關鍵字就能夠滿足需求而且效能相對還不錯,因為其他的能夠保證“讀寫”都一直的辦法,多多少少存在一些犧牲。

2.2.寫一致性

        Java 提供了三種方式來保證讀寫一致性,分別是互斥鎖、自旋鎖、執行緒隔離。

2.2.1.互斥鎖

        互斥鎖只是一個鎖概念,在其他場景也叫做獨佔鎖、悲觀鎖等,其實就是一個意思。它是指執行緒之間是互斥的,某一個執行緒獲取了某個資源的鎖,那麼其他執行緒就只能睡眠等待。
        在 Java 中互斥鎖的實現一般叫做同步執行緒鎖,關鍵字 synchronized,它鎖住的範圍是它修飾的作用域,鎖住的物件是: 當前物件(物件鎖) 或 類的全部物件(類鎖) ——鎖釋放前,其他執行緒必將阻塞,保證鎖住範圍內的操作是原子性的,而且讀取的資料不存在一致性問題。
  • 物件鎖:當它修飾方法、程式碼塊時,將會鎖住當前物件
  • 類鎖:修飾類、靜態方法時,則是鎖住類的所有物件
注意: 鎖住的永遠是物件,鎖住的範圍永遠是 synchronized 關鍵字後面的花括號劃定的程式碼域。

2.2.2.自旋鎖

        自旋鎖也只是一個鎖概念,在其他場景也叫做樂觀鎖等。
        自旋鎖本質上是不加鎖,而是通過對比舊資料來決定是否更新:
 Java下如何保證多執行緒安全
        如上所示,不管執行緒1與執行緒2哪個先執行,哪個後執行,結果都會是15,由此實現了讀寫一致性。而因為步驟3的更新失敗而在步驟4中更新資料後再此嘗試更新的過程,就叫做自旋——自旋只是個概念:表示 操作失敗後,執行緒會迴圈進行上一步的操作,直到成功為止。
        這種方式避免了執行緒的上下文切換以及執行緒互斥等,相對於互斥鎖而言,它允許併發的存在(互斥鎖不存在併發,只能同步進行)。
        在 Java 的 java.util.concurrent.atomic 包 中提供了自旋的操作類,諸如 AtomicInteger、AtomicLong 等,都能夠達到此目的。
 

Java下如何保證多執行緒安全

  1. 上面程式碼中的18行的程式碼,直接對一個int變數++操作,這是多執行緒不安全的
  2. 其中註釋掉的19、20、21行程式碼則是加上了同步執行緒鎖的寫法,同步的操作使得多執行緒安全
  3. 下面的25行程式碼則是基於自旋鎖的操作,也是多執行緒安全的
        但是,如果併發度很高的話,就會導致某些執行緒一直都無法更新成功(因為一直有其他執行緒更改了值),會使得執行緒長時間佔用CPU和執行緒。所以自旋鎖是屬於低併發的解決方案。
        另外,直接使用這些自旋的操作類還是太過原始,所以Java還在這個基礎上封裝了一些類,能夠簡單直接地接近於 synchronized 那麼方便地對某段程式碼上鎖,即是 ReentrantLock 以及 ReentrantReadWriteLock,限於篇幅,這裡不詳細介紹他們的使用。

2.2.3.執行緒隔離

        既然自旋鎖只是低併發的解決方案,那麼遇到高併發要如何處理呢?答案是將成員變數設成執行緒隔離的,也就是說每個執行緒都各自使用自己的變數,互相自己是不相關的。這樣自然也做到了多執行緒安全。但是這種做法是讓所有執行緒都互相隔離的了,所以他們之間是不存在互相操作的。
        在 Java 中提供了 ThreadLocal 類來實現這種效果:
// 宣告執行緒隔離的變數,變數型別通過泛型決定
private static ThreadLocal<Integer> localInt = new ThreadLocal<>();

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

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

// 將泛型物件設到變數中
localInt.set(++integer);

 

總結

        本文主要講了為什麼會出現多執行緒不安全的原因,其中涉及讀不安全與寫不安全。Java 使用 volatile 關鍵字實現了讀一致性,使用同步執行緒鎖(synchronized)、自旋操作類(AtomicInteger等 )以及執行緒隔離類(ThreadLocal )來實現了寫一致性,這三種方法中,同步執行緒鎖效率最低,自旋操作類在非高併發的場景可大大提高效率,但是要想實現真正的高併發,還是需要用到執行緒隔離類來實現。

相關文章