首頁 > 軟體

Java多執行緒之執行緒安全問題詳解

2022-03-02 19:01:33

面試題:

  • 什麼是執行緒安全和執行緒不安全?
  • 自增運算是不是執行緒安全的?如何保證多執行緒下 i++ 結果正確?

1. 什麼是執行緒安全和執行緒不安全?

什麼是執行緒安全呢?當多個執行緒並行存取某個Java物件時,無論系統如何排程這些執行緒,也無論這些執行緒將如何交替操作,這個物件都能表現出一致的、正確的行為,那麼對這個物件的操作是執行緒安全的。

如果這個物件表現出不一致的、錯誤的行為,那麼對這個物件的操作不是執行緒安全的,發生了執行緒的安全問題。

2. 自增運算為什麼不是執行緒安全的?

執行緒安全實驗:兩個執行緒對初始值為 0 的靜態變數一個做自增,一個做自減,各做 5000 次,結果是 0 嗎?具體的程式碼如下

public class ThreadDemo {
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        // 執行緒1對變數i做5000次自增運算
         Thread t1 = new Thread(()->{
             for(int j=0;j<5000;j++){
                 i++;
             }
         });
         Thread t2 = new Thread(()->{
             for(int j=0;j<5000;j++){
                 i--;
             }
         });
         t1.start();
         t2.start();
         // 主執行緒等待t1執行緒和t2執行緒執行結束再繼續執行
         t1.join();
         t2.join();
        System.out.println(i);// 581 / -1830 / 0
    }
}

以上的結果可能是正數、負數、零。為什麼呢?因為 Java 中對靜態變數的自增,自減並不是原子操作,要徹底理解,必須從位元組碼來進行分析。

例如對於 i++ 而言,實際會產生如下的 JVM 位元組碼指令:

getstatic i  // 獲取靜態變數i的值
iconst_1     // 準備常數1
iadd         // 自增
putstatic i  // 將修改後的值存入靜態變數i

而對應 i-- 也是類似:

getstatic i  // 獲取靜態變數i的值
iconst_1     // 準備常數1
isub         // 自減
putstatic i  // 將修改後的值存入靜態變數

而 Java 的記憶體模型如下,完成靜態變數的自增,自減需要在主記憶體和工作記憶體中進行資料交換:

如果是單執行緒以上 8 行程式碼是順序執行(不會交錯)沒有問題:

但多執行緒下這 8 行程式碼可能交錯執行:

出現負數的情況:

出現正數的情況:

因此,一個自增運運算元是一個複合操作,至少包括三個JVM指令:“記憶體取值”“暫存器增加1”和“存值到記憶體”。這三個指令在JVM內部是獨立進行的,中間完全可能會出現多個執行緒並行進行。“記憶體取值”“暫存器增加1”和“存值到記憶體”這三個JVM指令本身是不可再分的,它們都具備原子性,是執行緒安全的,也叫原子操作。但是,兩個或者兩個以上的原子操作合在一起進行操作就不再具備原子性了。比如先讀後寫,就有可能在讀之後,其實這個變數被修改了,出現讀和寫資料不一致的情況。

3. 臨界區資源和競態條件

在多個執行緒操作相同資源(如變數、陣列或者物件)時就可能出現執行緒安全問題。一般來說,只在多個執行緒對這個資源進行寫操作的時候才會出現問題,如果是簡單的讀操作,不改變資源的話,顯然是不會出現問題的。

臨界區資源表示一種可以被多個執行緒使用的公共資源或共用資料,但是每一次只能有一個執行緒使用它。一旦臨界區資源被佔用,想使用該資源的其他執行緒則必須等待。在並行情況下,臨界區資源是受保護的物件。

臨界區程式碼段是每個執行緒中存取臨界資源的那段程式碼,多個執行緒必須互斥地對臨界區資源進行存取。執行緒進入臨界區程式碼段之前,必須在進入區申請資源,申請成功之後執行臨界區程式碼段,執行完成之後釋放資源。臨界區程式碼段的進入和退出如圖所示:

競態條件可能是由於在存取臨界區程式碼段時沒有互斥地存取而導致的特殊情況。如果多個執行緒在臨界區程式碼段的並行執行結果可能因為程式碼的執行順序不同而不同,我們就說這時在臨界區出現了競態條件問題。

比如下面程式碼中的臨界區資源和臨界區程式碼段:

public class SafeDemo {
    // 臨界區資源
    private static int i = 0;
    // 臨界區程式碼段
    public void selfIncrement(){
        for(int j=0;j<5000;j++){
            i++;
        }
    }
    // 臨界區程式碼段
    public void selfDecrement(){
        for(int j=0;j<5000;j++){
            i--;
        }
    }
	// 這個不是臨界區程式碼,因為雖然使用了共用資源,但是這個方法並沒有被多個執行緒同時存取
    public int getI(){
        return i;
    }
}
public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        SafeDemo safeDemo = new SafeDemo();
        Thread t1 = new Thread(()->{
            safeDemo.selfIncrement();
        });
        Thread t2 = new Thread(()->{
            safeDemo.selfDecrement();
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(safeDemo.getI());
    }
}

當多個執行緒存取臨界區的selfIncrement()方法時,就會出現競態條件的問題。更標準地說,當兩個或多個執行緒競爭同一個資源時,對資源的存取順序就變得非常關鍵。為了避免競態條件的問題,我們必須保證臨界區程式碼段操作具備排他性。這就意味著當一個執行緒進入臨界區程式碼段執行時,其他執行緒不能進入臨界區程式碼段執行。

總結:

(1) 一個程式執行多個執行緒本身是沒有問題的,問題出在多個執行緒存取共用資源,多個執行緒讀共用資源其實也沒有問題,而在多個執行緒對共用資源讀寫操作時發生指令交錯,就會出現問題 ;

(2) 一段程式碼塊內如果存在對共用資源的多執行緒讀寫操作,稱這段程式碼塊為臨界區程式碼塊;

(3) 多個執行緒在臨界區內執行,由於程式碼的執行序列不同而導致結果無法預測,稱之為發生了競態條件;

在Java中,可以使用synchronized關鍵字,使用Lock顯式鎖範例,或者使用原子變數(AtomicVariables)對臨界區程式碼段進行排他性保護。

本篇文章就到這裡了,希望能夠給你帶來幫助,也希望您能夠多多關注it145.com的更多內容!      


IT145.com E-mail:sddin#qq.com