造成類在多執行緒時不安全的原因

付一鳴發表於2019-03-01

執行緒安全的類定義: 不存在競態條件(類中不存在被修改的成員變數),或存在時進行了同步控制。

多執行緒不安全的原因-競態條件/臨界區

同一個程式執行在多個執行緒中本身不會有執行緒安全問題,問題在於多個執行緒訪問共享資源時存在,如:類成員變數(普通或靜態變數),系統共享資源(檔案,資料庫)等。

同時只有多個執行緒同時對這些資源進行了寫的操作時才會發生執行緒安全問題,不對資源的進行修改時就不會存在問題。

// 執行緒不安全的計數器
public class Counter {
    // 多個執行緒時存在的共享成員變數,產生競態條件
    protected long count = 0;

    public void add(long value){
        //此處包含三個操作:1.獲取count當前值 2.加上value值 3.將結果值賦給count
        this.count = this.count + value;  
    }
}
複製程式碼

如:執行緒A和B同時執行同一個Counter例項的add()方法,我們不知道作業系統何時線上程之間切換,JVM並不是將該方法當作單條指令執行的,而是按下列順序執行的:

1. 從記憶體中獲取this.count的值放到暫存器中
2. 將暫存器值加value
3. 將暫存器中的結果值寫回記憶體
複製程式碼

但多個執行緒執行時,會共享同一個例項的count成員變數,被排程執行時,可能會按照下列順序執行:

A: 讀取記憶體中this.count的值0到暫存器,被掛起
B: 讀取記憶體中this.count的值0到暫存器
B: 將暫存器值加value=2
B: 將暫存器結果寫回記憶體,此時記憶體中this.count值為2,執行結束
A: 再此排程A繼續執行,將暫存器值加value=3
A: 將暫存器中結果寫回記憶體,覆蓋原來的結果值為3,執行結束
複製程式碼

兩個執行緒分別對count加2和3,兩個執行緒執行結束後,應該為5,實際為3。在兩個執行緒交叉執行時,讀到的初始值都為0,分別寫回2或3,後者覆蓋前者,如果不對這樣的多執行緒訪問進行同步控制,就會造成這種執行緒不安全的結果。

  • 競態條件&臨界區

當多個執行緒訪問同一個資源時,對先後順序敏感,就存在競態條件。導致競態條件發生的程式碼區稱為臨界區。

上例中add()方法是一個臨界區,它會產生競態條件。在臨界區中使用適當的同步就可以避免競態條件。

類中能造成執行緒不安全的共享資源

當多個執行緒訪問共享資源變數時,並且進行了寫操作,會引發競態條件。同時讀不會產生競態條件。

  • 方法中的區域性基本型別變數

多執行緒中同時執行類的方法時(包括靜態方法和成員方法),方法中區域性變數會在每個執行緒的堆疊空間中存在副本,對它的修改不會影響其他執行緒,所以不存線上程安全問題,而成員變數根據不同情況會產生執行緒安全問題。

public void someMethod(){
  long threadSafeInt = 0;
  threadSafeInt++;
}
複製程式碼
  • 方法中的區域性物件引用變數

物件引用存在每個執行緒的執行緒棧中,但new出來的物件例項在共享堆中,如果在某個方法中建立的區域性物件不逃逸出該方法,則該類就是執行緒安全的。哪怕將該物件作為引數傳遞給其他方法,只要其他執行緒獲取不到,就還是執行緒安全的。

逃逸:即該物件不會被其他方法獲得,也不會被非區域性變數引用。

public void someMethod(){
  LocalObject localObject = new LocalObject();
  localObject.callMethod();
  method2(localObject);
}

public void method2(LocalObject localObject){
  localObject.setValue("value");
}
複製程式碼

樣例中LocalObject物件沒有被方法返回,也沒有被傳遞給someMethod()方法外的物件。每個執行someMethod()的執行緒都會建立自己的LocalObject物件,並賦值給localObject引用。因此,這裡的LocalObject是執行緒安全的。事實上,整個someMethod()都是執行緒安全的。即使將LocalObject作為引數傳給同一個類的其它方法或其它類的方法時,它仍然是執行緒安全的。當然,如果LocalObject通過某些方法被傳給了別的執行緒,那它就不再是執行緒安全的了。

  • 物件成員變數

物件成員儲存在堆上。如果兩個執行緒同時更新同一個物件的同一個成員,那這個程式碼就不是執行緒安全的。

public class NotThreadSafe{
    StringBuilder builder = new StringBuilder();
    public add(String text){
        this.builder.append(text);
    }  
}
複製程式碼

如果兩個執行緒同時呼叫同一個NotThreadSafe例項上的add()方法,就會有競態條件問題。

注意兩個MyRunnable共享了同一個NotThreadSafe物件。
因此,當它們呼叫add()方法時會造成競態條件。

NotThreadSafe sharedInstance = new NotThreadSafe();
new Thread(new MyRunnable(sharedInstance)).start();
new Thread(new MyRunnable(sharedInstance)).start();

public class MyRunnable implements Runnable{
  NotThreadSafe instance = null;
  
  public MyRunnable(NotThreadSafe instance){
    this.instance = instance;
  }
  
  public void run(){
    this.instance.add("some text");
  }
}
複製程式碼
當然,如果這兩個執行緒在不同的NotThreadSafe例項上呼叫call()方法,
就不會導致競態條件。下面是稍微修改後的例子:

new Thread(new MyRunnable(new NotThreadSafe())).start();
new Thread(new MyRunnable(new NotThreadSafe())).start();
複製程式碼

現在兩個執行緒都有自己單獨的NotThreadSafe物件,呼叫add()方法時就會互不干擾,再也不會有競態條件問題了。所以非執行緒安全的物件仍可以通過某種方式來消除競態條件。

  • 執行緒控制逃逸規則

執行緒安全的類:不包含競態條件,即多執行緒時不存在共享資源變數或存在共享資源變數時進行了適當的同步控制

不可變物件保證執行緒安全

我們可以通過建立不可變的共享物件來保證物件線上程間共享時不會被修改,從而實現執行緒安全。如下示例:

請注意add()方法以加法操作的結果作為一個新的ImmutableValue類例項返回
而不是直接對它自己的value變數進行操作。

public class ImmutableValue{
    private int value = 0;
    
    public ImmutableValue(int value){
        this.value = value;
    }
    public int getValue(){
        return this.value;
    }
    
    public ImmutableValue add(int valueToAdd){
        return new ImmutableValue(this.value + valueToAdd);
    }
}
複製程式碼

請注意ImmutableValue類的成員變數value是通過建構函式賦值的,並且在類中沒有set方法。這意味著一旦ImmutableValue例項被建立,value變數就不能再被修改,這就是不可變性。但你可以通過getValue()方法讀取這個變數的值。

  • 即使一個物件是執行緒安全的不可變物件,但在另一個包含這個物件的一個引用的類中,該類可能不是執行緒安全的。

相關文章