在併發編程中,所有問題的根源就是可見性、原子性和有序性問題,這篇文章我們就來聊聊原子性問題。

在介紹原子性問題之前,先來說下線程安全:

線程安全

我理解的線程安全就是不管單線程還是多線程併發的時候,始終能保證運行的正確性,那麼這個類就是線程安全的。

其中在《Java併發編程實戰》一書中對線程安全的定義如下:

當多個線程訪問某個類時,不管運行是環境採用何種調度方式或者這些線程將如何交替執行,並且在主調代碼中不需要任何額外的同步或協同,這個類都能表現出正確的行爲,那麼就稱這個類是線程安全的。

爲了保證線程安全,可能會有很多的挑戰和問題,當我們瞭解了問題根源所在,問題也就迎刃而解了,接下來介紹線程安全三大特性之一的原子性。

原子性

原子,我想大家應該都有印象吧,在化學反應中不可再分的基本微粒就是原子,也就是不可分割。

同時事務的四大特性 ACID 中也有原子性,那麼原子性究竟是什麼呢?

原子性其實就是 所有操作要麼全部成功,要麼全部失敗,這些操作是不可拆分的 ,也可以簡單地理解爲不可分割性。

將整個操作視作一個整體是原子性的核心特徵,這些操作就是 原子性操作

接下來舉個原子性操作在生活中的例子:

比如, wupx 今天剛發了 5100 元的工資,全身家當爲 5100 元, huxy 目前餘額還有 1000 元,此時 wupx 上交 5000 元,如果轉賬成功,則 huxy 的餘額就變爲了 6000 元, wupx 的餘額爲 100 元。

若轉賬失敗,則轉出去的餘額會退回來, wupx 的餘額仍然是 5100 元, huxy 的餘額爲 1000 元。

不會出現 wupx 的錢轉出去了, huxy 的餘額沒有增加,或者 wupx 的工資沒轉出去,而 huxy 的餘額卻增加的情況。

wupx 上交工資給 huxy 的操作就是原子性操作, wupx 餘額減少 5000 元,而 huxy 的餘額增加 5000 元的操作是不可分割和拆分的,正如我們上面說到的:要麼全部成功,要麼全部失敗。 wupxhuxy 上交成功流程如下所示:

原子操作可以是一個步驟,也可以是多個操作步驟,但是其順序不可以被打亂,也不可以被切割而只執行其中的一部分。

到這裏,我相信大家對原子性有了基本的瞭解,下面來聊下原子性問題。

原子性問題

原子性問題的核心就是線程切換導致的,因爲併發編程中,線程數設置的數目一般會大於 CPU 核心數。

關於線程數的設置可以閱讀: 線程數,射多少更舒適?

每個 CPU 同一時刻只能被一個線程使用,而 CPU 資源分配採用的是時間片輪轉策略,也就是給每個線程分配一個時間片,線程在這個時間片內佔用 CPU 的資源來執行任務,當過了一個時間片後,操作系統會重新選擇一個線程來執行任務,這個過程一般稱爲任務切換,也叫做線程切換或者線程上下文切換。

上圖就是線程切換的例子,有 Thread-0Thread-1 兩個線程,其中粉色矩形表示該線程佔有 CPU 資源並執行任務,剛開始 Thread-1 執行一段時間,這段時間稱爲時間片,在該時間片內, Thread-1 會佔有 CPU 資源並執行任務,當經過一個時間片後, Thread-1 會讓出 CPU 資源,虛線部分表示讓出 CPU,不佔用 CPU 資源,CPU 會重新選擇一個線程 Thread-0 來執行,CPU 會在 Thread-0Thread-1 之間來回切換,反覆橫跳。

下面通過一個例子來看下原子性問題,具體代碼如下:

public class AtomicityDemo {

    private long count = 0;

    public void calc() {
        count++;
    }
}

calc() 方法中只有一個 count++ 操作,那麼就是原子性的嗎?

下面在 class 目錄下使用 javap -c AtomicityDemo 就可以得到如下結果:

public class com.`wupx`.thread.AtomicityDemo {
  public com.`wupx`.thread.AtomicityDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: lconst_0
       6: putfield      #2                  // Field count:J
       9: return

  public void calc();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field count:J
       5: lconst_1
       6: ladd
       7: putfield      #2                  // Field count:J
      10: return
}

重點來看下 calc 方法,這些 CPU 指令大概可以分爲如下三步:

  • 指令 1:將 count 從內存加載到 CPU 寄存器
  • 指令 2:在寄存器中執行 +1 操作
  • 指令 3:將結果寫入內存(也有可能是 CPU 緩存)

關於 CPU 緩存可以閱讀: 原來 CPU 爲程序性能優化做了這麼多

操作系統的線程切換並不是一定是發生一條語句執行完成後,而可能是發生在任何一條 CPU 執行完成後。比如 Thread-0 執行完指令 1 後,操作系統發生了線程切換,兩個線程都執行了 count++ 操作,但是最後的結果是 1 而不是 2,下面用圖來表示這個過程。

通過上圖,我們可以發現: Thread-1count=0 加載到 CPU 的寄存器後,發生了線程切換,此時內存中的 count 值爲 0, Thread-0count=0 加載到 CPU 寄存器,執行 count++ 操作,並將 count=1 寫到內存,此時,CPU 切換到 Thread-1 ,執行 Thread-1 中的 count++ 操作後, Thread-1 中的 count 值爲 1, Thread-1count=1 寫入內存,此時內存中的 count 值爲 1。

因此,在併發編程中,若在 CPU 中存在正在執行的線程,正好 CPU 發生了線程切換,則可能會導致原子性問題,這就是導致併發編程問題的根源之一。

針對原子性問題,我們可以通過爲操作加鎖或者使用原子變量來解決,原子變量在 java.util.concurrent.atomic 包中,是 JDK 1.5 引入的,它提供了一系列的原子操作。

總結

這篇文章簡要介紹了線程安全的概念,並詳細介紹了線程安全的特性之一原子性,並針對原子性問題進行了分析。

只有掌握了引發原子性問題的根源,才能便於我們編寫更加安全的併發程序。

歡迎大家留言討論,分享你的想法。

參考

《Java併發編程實戰》

Java併發編程實戰

相關文章