Java多線程之線程安全問題詳解

面試題:

  • 什麼是線程安全和線程不安全?
  • 自增運算是不是線程安全的?如何保證多線程下 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)對臨界區代碼段進行排他性保護。

本篇文章就到這裡瞭,希望能夠給你帶來幫助,也希望您能夠多多關註WalkonNet的更多內容!      

推薦閱讀: