Java並發編程實戰 01並發編程的Bug源頭

摘要

編寫正確的並發程序對我來說是一件極其困難的事情,由於知識不足,只知道synchronized這個修飾符進行同步。
本文為學習極客時間:Java並發編程實戰 01的總結,文章取圖也是來自於該文章

並發Bug源頭

在計算機系統中,程序的執行速度為:** CPU > 內存 > I/O設備 ** ,為了平衡這三者的速度差異,計算機體系機構、操作系統、編譯程序都進行了優化:

1.CPU增加了緩存,以均衡和內存的速度差異
2.操作系統增加了進程、線程,已分時復用CPU,以均衡 CPU 與 I/O 設備的速度差異
3.編譯程序優化指令執行順序,使得緩存能夠更加合理的利用。

但是這三者導致的問題為:可見性、原子性、有序性

源頭之一:CPU緩存導致的可見性問題

一個線程對共享變量的修改,另外一個線程能夠立即看到,那麼就稱為可見性。
現在多核CPU時代中,每顆CPU都有自己的緩存,CPU之間並不會共享緩存;

如線程A從內存讀取變量V到CPU-1,操作完成後保存在CPU-1緩存中,還未寫到內存中。
此時線程B從內存讀取變量V到CPU-2中,而CPU-1緩存中的變量V對線程B是不可見的
當線程A把更新後的變量V寫到內存中時,線程B才可以從內存中讀取到最新變量V的值

上述過程就是線程A修改變量V後,對線程B不可見,那麼就稱為可見性問題。

file

源頭之二:線程切換帶來的原子性問題

現代的操作系統都是基於線程來調度的,現在提到的「任務切換」都是指「線程切換」
Java並發程序都是基於多線程的,自然也會涉及到任務切換,在高級語言中,一條語句可能就需要多條CPU指令完成,例如在代碼 count += 1 中,至少需要三條CPU指令。

指令1:把變量 count 從內存加載到CPU的寄存器中
指令2:在寄存器中把變量 count + 1
指令3:把變量 count 寫入到內存(緩存機制導致可能寫入的是CPU緩存而不是內存)

操作系統做任務切換,可以發生在任何一條CPU指令執行完,所以並不是高級語言中的一條語句,不要被 count += 1 這個操作蒙蔽了雙眼。假設count = 0,線程A執行完 指令1 後 ,做任務切換到線程B執行了 指令1、指令2、指令3後,再做任務切換回線程A。我們會發現雖然兩個線程都執行了 count += 1 操作。但是得到的結果並不是2,而是1。

file

如果 count += 1 是一個不可分割的整體,線程的切換可以發生在 count += 1 之前或之後,但是不會發生在中間,就像個原子一樣。我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱為原子性

源頭之三:編譯優化帶來的有序性問題

有序性指的是程序按照代碼的先後順序執行。編譯器為了優化性能,可能會改變程序中的語句執行先後順序。如:a = 1; b = 2;,編譯器可能會優化成:b = 2; a = 1。在這個例子中,編譯器優化了程序的執行先後順序,並不影響結果。但是有時候優化後會導致意想不到的Bug。
在單例模式的雙重檢查創建單例對象中。如下代碼:

public class Singleton {
    private static Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

問題出現在了new Singletion()這行代碼,我們以為的執行順序應該是這樣的:

指令1:分配一塊內存M
指令2:在內存M中實例化Singleton對象
指令3:instance變量指向內存地址M

但是實際優化後的執行路徑確實這樣的:

指令1:分配一塊內存M
指令2:instance變量指向內存地址M
指令3:在內存M中實例化Singleton對象

這樣的話看出來什麼問題了嗎?當線程A執行完了指令2後,切換到了線程B,
線程B判斷到 if (instance != null)。直接返回instance,但是此時的instance還是沒有被實例化的啊!所以這時候我們使用instance可能就會觸發空指針異常了。如圖:

file

總結

在寫並發程序的時候,需要時刻注意可見性、原子性、有序性的問題。在深刻理解這三個問題後,寫起並發程序也會少一點Bug啦~。記住了下面這段話:CPU緩存會帶來可見性問題、線程切換帶來的原子性問題、編譯優化帶來的有序性問題。

參考文章:極客時間:Java並發編程實戰 01 | 可見性、原子性和有序性問題:並發編程Bug的源頭

個人博客網址: //colablog.cn/

如果我的文章幫助到您,可以關注我的微信公眾號,第一時間分享文章給您
微信公眾號