Java核心知識體系7:執行緒安全性討論

Brand發表於2023-11-28

Java核心知識體系1:泛型機制詳解
Java核心知識體系2:註解機制詳解
Java核心知識體系3:異常機制詳解
Java核心知識體系4:AOP原理和切面應用
Java核心知識體系5:反射機制詳解
Java核心知識體系6:集合框架詳解

1 為什麼需要多執行緒

我們都知道,CPU、記憶體、I/O 裝置的速度是有極大差異的,為了合理利用 CPU 的高效能,平衡這三者的速度差異,計算機體系結構、作業系統、編譯程式都做出了最佳化,主要體現為:

  • CPU增加了快取,均衡了與記憶體之間的速度差異,但會導致可見性問題
  • 作業系統增加了程式、執行緒,以分時複用 CPU,進而均衡 CPU 與 I/O 裝置的速度差異,但會導致原子性問題
  • 編譯程式最佳化指令執行次序,使得快取能夠得到更加合理地利用,但會導致有序性問題

從上面可以看到,雖然多執行緒平衡了CPU、記憶體、I/O 裝置之間的效率,但是同樣也帶來了一些問題。

2 執行緒不安全案例分析

如果有多個執行緒,對一個共享資料進行操作,但沒有采取同步的話,那操作結果可能超出預想,產生不一致。
下面舉個粒子,設定一個計數器count,我們透過1000個執行緒同時對它進行增量操作,看看操作之後的值,是不是符合預想中的1000。

public class UnsafeThreadTest {

    private int count = 0;

    public void add() {
        count += 1;
    }

    public int get() {
        return count;
    }
}
public static void main(String[] args) throws InterruptedException {
    final int threadNum = 1000;
    UnsafeThreadTest threadTest = new UnsafeThreadTest();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorSvc = Executors.newCachedThreadPool();
	// 執行併發計數
    for (int idx = 0; idx < threadNum; idx ++) {
        executorSvc.execute(() -> {
            threadTest.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
	// 關閉執行緒池
    executorSvc.shutdown();
    System.out.println("最終計數:" + threadTest.get());
}
最終計數:994  // 結果跟預期的 1000 不一樣

3 併發出現問題的原因

可以看到,上述程式碼輸出的結果跟預期的 1000 不一樣,我們需要理清楚發生了什麼問題?
★ 併發三要素:可見性、原子性、有序性

3.1 可見性:由CPU快取引起

CPU快取是一種快取記憶體,用於儲存CPU最近使用的資料。由於CPU快取比主儲存器更快,因此CPU會盡可能地使用快取,以提高程式的效能。但是,這也會導致可見性問題。
可見性問題是指當一個執行緒修改了一個共享變數的值時,另一個執行緒可能無法立即看到這個修改。

我們舉個簡單的例子,看下面這段程式碼:

// 主存中 index 的值預設為 10
System.out.println("主存中的值:" + index);

// Thread1 執行賦值
index = 100;
 
// Thread2 執行的
threadA = index;

因為Thread1修改後的值可能仍然儲存在CPU快取中,而沒有被寫回主儲存器。這種情況下,Thread2無法讀取到修改後的值,所以導致錯誤資訊。
具體來說,當多個執行緒同時執行在同一個處理器上時,它們共享該處理器的快取。如果一個執行緒修改了某個共享變數的值,該值可能被儲存在處理器快取中,並且未被立即寫回到主儲存器中。
因此,當另一個執行緒試圖讀取該變數的值時,它可能會從主儲存器中讀取舊的值 10,而不是從處理器快取中讀取已更新的值 100。

3.2 原子性: 由分時複用引起

原子性:原子性是指一個操作在執行過程中不可分割,即該操作要麼完全執行,要麼完全不執行。

我們舉個簡單的例子,看下面這段程式碼:


// 主存中 index 的值預設為 10
System.out.println("主存中的值:" + index);

// Thread1 執行增值
index += 1;
 
// Thread2 執行增值
index += 1

以上的資訊可以看出:

  • 主存的值為10
  • i += 1 這個操作實際執行三條 CPU 指令
    • 變數 i 從記憶體讀取到 CPU暫存器;
    • 在CPU暫存器中執行 i + 1 操作;
    • 將最後的結果i寫入記憶體,因為有快取機制,所以最終可能寫入的是 CPU 快取而不是記憶體。
  • 由於CPU分時複用(執行緒切換)的存在,Thread1執行了第一條指令後,就切換到Thread2執行,Thread2全部執行完成之後,再切換會Thread1執行後續兩條指令,將造成最後寫到記憶體中的index值是11而不是12。

3.3 有序性: 重排序引起

有序性:即程式執行的順序按照程式碼的先後順序執行。

重排序(Reordering)是指在計算機系統中,由於處理器最佳化或編譯器最佳化等原因,導致指令執行的順序與程式程式碼中的順序不一致。重排序可能會引起有序性錯誤,即在併發或多執行緒環境中,程式執行的順序與程式碼的先後順序不一致,導致程式結果不正確或出現意外的結果。

我們舉個簡單的例子,看下面這段程式碼:

int idx = 10;
boolean isCheck = true;
idx += 1;                // 執行語句1  
isCheck = false;          // 執行語句2

上面程式碼定義了一個int型變數,定義了一個boolean型別變數,然後分別對兩個變數進行操作。
從程式碼順序上看,執行語句1是在執行語句2前面的,那麼JVM在真正執行這段程式碼的時候會保證語句1一定會在語句2前面執行嗎? 不一定,為什麼呢? 這裡可能會發生指令重排序(Instruction Reorder)。

重排序(Reordering)是指在計算機系統中,由於處理器最佳化或編譯器最佳化等原因,導致指令執行的順序與程式程式碼中的順序不一致。重排序可能會引起有序性錯誤,即在併發或多執行緒環境中,程式執行的順序與程式碼的先後順序不一致,導致程式結果不正確或出現意外的結果。

重排序引起的有序性錯誤主要有以下幾種情況:

  1. 指令重排序:處理器為了最佳化程式的執行,可能會對指令進行重排序。這種重排序不會改變單執行緒程式的執行結果,但可能會影響多執行緒程式的行為。例如,一個執行緒修改了一個共享變數的值,但由於指令重排序,另一個執行緒在讀取該變數時可能讀取到過時的值。
  2. 記憶體訪問重排序:處理器為了提高程式的執行效率,可能會對記憶體訪問進行重排序。例如,一個執行緒先讀取一個共享變數的值,然後再寫入該值,但由於記憶體訪問重排序,處理器可能會先執行寫入操作,再執行讀取操作,從而導致其他執行緒無法正確地讀取到修改後的值。
  3. 同步操作重排序:在併發或多執行緒環境中,同步操作可能會被重排序。例如,一個執行緒先釋放了一個鎖,然後再執行另一個操作,但由於同步操作重排序,釋放鎖的操作可能會先於另一個操作執行,從而導致其他執行緒無法正確地獲取鎖。

image

為了避免重排序引起的有序性錯誤,可以採用一些同步機制來確保程式的執行順序,如記憶體屏障(Memory barrier,intel 稱為 memory fence)、指令fence等。這些同步機制可以確保指令的執行順序與程式碼中的順序一致,避免指令重排序和記憶體訪問重排序等問題。同時,也可以使用序列化(Serialization)或事務記憶體(Transactional memory)等技術來保證併發程式的有序性。

4 總結

  • CPU、記憶體、I/O 裝置的速度是有極大差異的,多執行緒 的實現是為了合理利用 CPU 的高效能,平衡這三者的速度差異
  • 多執行緒情況下,併發產生問題的三要素:可見性、原子性、有序性
    • 可見性:由CPU快取引起
    • 原子性: 由分時複用引起
    • 有序性: 重排序引起

相關文章