一男子給對象轉賬5000元,居然又退還了!

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

在介紹原子性問題之前,先來說下執行緒安全:

執行緒安全

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

其中在《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 元的操作是不可分割和拆分的,正如我們上面說到的:要麼全部成功,要麼全部失敗。wupx 給 huxy 上交成功流程如下所示:

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

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

原子性問題

原子性問題的核心就是執行緒切換導致的,因為並發編程中,執行緒數設置的數目一般會大於 CPU 核心數。

關於執行緒數的設置可以閱讀:執行緒數,射多少更舒適?

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

线程切换

上圖就是執行緒切換的例子,有 Thread-0 和 Thread-1 兩個執行緒,其中粉色矩形表示該執行緒佔有 CPU 資源並執行任務,剛開始 Thread-1 執行一段時間,這段時間稱為時間片,在該時間片內,Thread-1 會佔有 CPU 資源並執行任務,當經過一個時間片後,Thread-1 會讓出 CPU 資源,虛線部分表示讓出 CPU,不佔用 CPU 資源,CPU 會重新選擇一個執行緒 Thread-0 來執行,CPU 會在 Thread-0 和 Thread-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-1 將 count=0 載入到 CPU 的暫存器後,發生了執行緒切換,此時記憶體中的 count 值為 0,Thread-0 將 count=0 載入到 CPU 暫存器,執行 count++ 操作,並將 count=1 寫到記憶體,此時,CPU 切換到 Thread-1,執行 Thread-1 中的 count++ 操作後,Thread-1 中的 count 值為 1,Thread-1 將 count=1 寫入記憶體,此時記憶體中的 count 值為 1。

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

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

總結

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

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

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

參考

《Java並發編程實戰》

Java並發編程實戰

Tags: