《Java併發編程實戰》線程安全性和對象共享

引言

  1. 多進程和多線程的優點?
  2. 多線程的優勢與風險?
  3. 競態條件是什麼?

早期計算機中還不存在操作系統,一臺機器從頭到尾只能執行一個程序,並且這個程序能訪問所有的計算機資源。

操作系統的引入是的計算機“同時”能運行多個程序,不同程序都在單獨的進程中運行:操作系統爲各個獨立的進程分配各種資源,包括內存、文件句柄以及安全證書等。不同進程之間可以通過一些粗粒度的通信機制來交換數據,包括:套接字、信號處理器、共享內存、信號量以及文件等。

促成操作系統協調多進程同時運作的主要原因有:

  • 資源利用率:對於某些需要等待IO或磁盤操作的程序,不應該要求CPU等待,在這種情況下,多進程操作系統的CPU可以在等待時運行其他程序;
  • 公平性:通過時間分片使得不同程序對計算機的資源有同等的使用權;
  • 便利性:一個系統的多個任務應該被拆分成多個進程進行獨立開發和維護,進程之間通過通信進行協調和共享數據;

而同樣的原因也促使線程的出現。線程也被稱爲輕量級進程,在大多數現代操作系統中,都是以線程爲基本的調度單位,而不是進程

線程的出現,允許同一個進程中同時存在多個程序控制流,線程會共享進程範圍內的資源(正是因爲多線程共享同一個進程的資源,加大了多線程使用的負責性),例如內存句柄和文件句柄,每個線程都有各自的程序計數器、棧以及局部變量等

多線程的優勢:

  • 發揮多核處理器的強大能力;
  • 建模的簡單性;
  • 異步事件的簡化處理;
  • 響應更靈敏的用戶界面;

多線程帶來的風險:

  • 安全性問題:由於CPU對多個線程的調度存在隨機性,即在沒有充分運用同步機制(例如,volatile、synchronized、基於AQS的各種鎖)的情況下,多個線程的操作執行次序是不可預測的
// java源碼
private int i; // 類字段
public void incAssign(){
    i++;
}     

// javap.exe -verbose查看class指令碼
public void incAssign();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0           // 將第一引用類型本地變量推送至棧頂
         1: dup               // 複製棧頂數值並將複製值壓入棧頂
         2: getfield      #2  // Field i:I  // 獲取指定類的實例域,並將其值壓入棧頂
         5: iconst_1          // 將int型1推送至棧頂  
         6: iadd              // 將棧頂兩個int型值相加,並將結果壓入棧頂
         7: putfield      #2  // Field i:I  // 爲指定類的實例域賦值
        10: return            // 從當前方法返回void
      LineNumberTable:
        line 10: 0
        line 11: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   LTest;
}

分析上述源碼和編譯後的字節碼可知,value++看上去是單個操作,但事實上在JVM執行字節碼時,它包含了幾個獨立的指令操作,如本例中的2-7指令行:獲取實例域數值並壓入棧頂、將常量1推送至棧頂、棧頂的兩個值相加、給實例域重新賦值,即一個簡單的自加操作,在字節碼的處理過程中至少涉及到了四步驟的操作。

如果A線程執行到第6行,還尚未執行第7行的賦值,而此時B線程已經執行完第2行的讀取指令,那麼A線程的加1的效果還沒來得及被B線程讀取到,也就意味着兩個線程對同一個i的值進行自加操作,雖然加了2次,但兩者得到的結果是一樣的(B在A之後putfield,相當於把A的勞動成果覆蓋掉了)。這就是所謂的多線程併發執行時的不安全問題,而本例還尚未考慮指令重排序帶來的更多複雜性。上述示例說明的是一種常見的併發安全問題,稱爲競態條件,即程序執行結果的正確與否依賴於多線程(進程)對共享資源的操作次序。

由於不恰當的執行次序導致出行不正確的結果的情況成爲競態條件

競態條件(race condition),從多進程間通信的角度來講,是指兩個或多個進程對共享的數據進行讀或寫的操作時,最終的結果取決於這些進程的執行順序。

出現競態條件的場景除了上述i++操作外(讀取-修改-寫入),還有一個典型的場景是在單例模式創建單例對象時(先檢查後執行),如果不做同步操作,那麼也會線程不安全,例如:

// 存在競態條件的創建單例示例(線程不安全的示例)
public class Singleton {

    private static Singleton uniqueInstance= null;

    public static Singleton getInstance(){
        if (uniqueInstance == null){
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }
}

// 解決競態條件的同步機制優化方案
public class Singleton {

    private volatile static Singleton uniqueInstance= null;

    public static Singleton getInstance(){
        if (uniqueInstance == null){
            synchronized (Singleton.class){
                if (uniqueInstance == null){
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}
  • 活躍性問題:例如,如果A線程在等待B線程釋放其所持有的資源,而B線程永遠都不釋放該資源,那麼A就會永久地等待下去。類似的活躍性問題還有死鎖、飢餓、活鎖等,即併發錯誤發生與否依賴於不同線程時間發生的次序。
  • 性能問題:在多線程程序中,線程調度器需要臨時掛起活躍線程轉而運行另一個線程,會導致頻繁的上下文切換操作,這種操作會帶來極大的開銷:保存和恢復執行的上下文、丟失局部性,並且CPU時間將更多的花在線程調度而不是線程運行商。當線程共享數據時,必須使用同步機制,而這些機制往往會抑制某些編譯器優化(Java內存模型中,工作內存會緩存主內存的變量以提升程序性能),使內存緩存區中的數據無效,以及增加共享內存總線的同步流量(如,volatile會通過緩存無效、鎖總線等來保證變量操作的可見性)。

線程安全性具體是什麼?

  1. 線程安全的定義?
  2. 內置鎖
  3. 可重入性
  4. 鎖保護狀態的根本原因(所有位置同步,所有同步設定同一把鎖)

線程安全的定義:

當多個線程訪問某個類時,不管運行時環境採用何種調度方式或者這些線程將如何交替執行,並且在主調代碼中不需要任何額外的同步或協同,這個類都能表現出正確的行爲,那麼就稱這個類時線程安全的。


在線程安全類中封裝了必要的同步機制,因此客戶端無需進一步採用同步措施。(這是“絕對線程安全”期待的效果,但是現實情況下,即便是在類中封裝了足夠的同步機制,仍不能保證客戶端任意操作下一定線程安全(舉例,Vector...???),即所謂的線程安全類,都是相對的線程安全)

synchronized + 內置鎖

Java中關鍵字synchronized是一種同步代碼塊實現機制,其包括兩部分內容:1. 鎖的對象引用,2. 這個鎖保護的代碼塊

  • 每個Java對象都可以作爲實現同步的鎖,這些鎖成爲內置鎖或者監視器鎖。代碼在進入同步塊之前會自動獲得鎖,並且在退出同步塊是自動釋放鎖,而不論是通過正常的控制路徑退出,還是通過從代碼塊中拋出異常退出;
  • 獲得內置鎖的唯一途徑就是進入由這個鎖保護的同步代碼塊或方法;
  • 內置鎖是可重入的,即某個線程試圖獲取一個已經由它自己持有的鎖,那麼這個請求不會阻塞,而是會成功。“可重入”意味着獲取鎖的操作的粒度是“線程”而不是“調用”;
  • 重入在JVM中的實現方法是,爲每個鎖關聯一個獲取計數值和一個所有者線程。當計數值爲0時,這個鎖就被認爲是沒有被任何線程持有,當線程請求一個未被持有的鎖時,JVM將記下鎖的持有者,並且將獲取的技術值置爲1,如果同一個線程再次獲取這個鎖,計數值將遞增,而當線程退出同步塊時,計數器會相應地遞減。當計數值爲0時,這個鎖將被釋放;
  • 可重入有利於面向對象開發,考慮一個場景,如果子類重寫了父類的synchronized方法,並且在方法體中調用了父類的方法,如果synchronized同步機制沒有可重入的鎖,那麼由於子類方法先獲取了鎖,而在調用父類方法時,父類方法陷入阻塞,導致死鎖情況的發生。例如:
class ReEntrantTest{

    static class Father{
        public synchronized void doSomething(){
            System.out.println("father method is doing");
        }
    }

    static class Son extends Father{
        @Override
        public synchronized void doSomething() {
            super.doSomething();
            System.out.println("Son is continue doing");
        }
    }

    public static void main(String[] args){
        new Son().doSomething();
    }

}

// 運行結果:
father method is doing
Son is continue doing

// 結果分析:
synchronized機制的內置鎖是可重入的,該情況下才不會阻塞當前線程

// 關於父類synchronized方法被子類繼承的相關問題補充
1. 如果子類方法不重寫父類synchronized方法,那麼子類實例調用該方法時,仍具有同步效果;
2. 如過子類重寫了父類的方法,即使子類不使用synchronized修飾該方法,也構成重寫效果。具體來說,
子類可以選擇是否使用synchronized關鍵字修飾重寫的父類方法,如過是使用了關鍵字,那麼該方法就具備同步效果,如果沒使用,就不具備

用鎖來保護狀態

僅僅將複合操作封裝到一個同步代碼塊中是不夠的,如果用同步來協調對某個對象的訪問,那麼在訪問這個變量的所有位置上都需要使用同步。而且,當使用鎖來協調對某個變量的訪問時,在訪問變量的所有位置上都要使用同一個鎖

即爲保證對一個共享可變變量訪問的安全性,需要在所有訪問該變量的位置都要加鎖實現同步,並且都必須用同一個鎖來保護。

對象的內置鎖與其狀態之間沒有內在的關聯。當獲取與對象關聯的鎖時,並不能阻止其他線程訪問該對象,某個線程在獲得對象的鎖之後,只能阻止其他線程獲得同一個鎖。之所以每個對象都有一個內置鎖,只是爲了免去顯式創建鎖對象。

一個常見的加鎖約定是:將所有的可變狀態都封裝在對象內部,並通過對象的內置鎖對所有訪問可變狀態的代碼路徑進行同步,使得在該對象上不會發生併發訪問。

// vector是線程安全類Vector的實例對象
if (!vector.contains(element)){
    vector.add(element);
}

雖然vector的containers方法和add方法都是線程安全的方法(可理解爲原子方法),但上述多個操作合併爲一個複合操作仍然存在競態條件,即還需要額外的加鎖機制。


如何共享和發佈對象?

  1. 什麼叫內存可見性?
  2. synchronized和volatile各自如何實現內存可見性?
  3. 理解什麼是引用逸出?(this逸出、引用對象逸出等)
  4. 線程封閉有什麼用?有哪些實現方式?

一方面關鍵字synchronized可以用於實現原子性,而另一方面也可以實現:內存可見性。

我們不僅需要保證多個線程在對共享變量訪問時的安全性,而且希望確保當一個線程修改了對象的狀態後,其他線程能夠看到發生的狀態變化,即保證對象被安全的發佈。

加鎖:互斥性和可見性

加鎖的涵義不僅僅侷限於互斥行爲,還包括內存可見性。爲了確保所有線程都能看到共享變量的最新值,所有執行讀操作或者寫操作的線程都必須在同一個鎖上同步。

注意兩點

  • 所有涉及訪問變量的位置都需要同步;
  • 所有的同步都要加持同一把鎖;

保證這兩點才能保證任何時候對於該變量的操作都不會被其他線程忽視掉,換句話說任何線程對共享可見變量的操作,都會被其他線程看到,即可見性。

舉個例子,假如有四個線程各存在一處對共享可見變量有讀寫操作,在都進行同步機制和設定同樣的內置鎖前提下,那麼在某一時刻,至多隻會有一個線程獲取鎖,當持鎖線程對變量修改,其他線程只能等待鎖釋放,而當鎖被釋放後,其他線程看到的變量值,都是最新的值,不存在失效值的情況。

而同樣的例子,如果存在第五個線程沒有進行同步或者同步的鎖不是同一把鎖,那麼該線程無需等待上述的任何持鎖線程釋放鎖,就可以直接讀取到共享變量,這種情況讀取的值可能是失效的或其他未知錯誤。

通過這個例子也在說明,其實同步機制、內置鎖都跟同步代碼塊裏的共享變量沒有什麼必然的關係,只要其他地方沒有設置同步或同步要求的鎖不是同一把鎖,那麼就無需阻塞等待鎖釋放,就可以直接訪問目標共享變量;而之所以所謂的同步和內置鎖能保證多線程環境下共享可變變量的安全性和可見性,最核心的保障措施在於所有涉及訪問共享變量的地方都必須對同一把鎖進行加鎖競爭,這種策略完全保證了永遠只會有一個線程能操作變量,不存在競爭條件,而持鎖線程釋放鎖後,其他線程再去訪問變量,都能看到最新修改的變量值,即保證可見性。

 

volatile與可見性

關鍵字volatile用來修飾變量,當把變量聲明爲volatile類型後,編譯器與運行時都會注意到這個變量是共享的,這意味着:

  • 抑制重排序:不會將該變量上的操作與其他內存操作一起重排序;
  • 抑制共享變量緩存:不會將該變量緩存在寄存器或者對其他CPU核不可見的地方;不緩存變量一方面表明當進行寫操作時,直接寫到是所有線程共享可見的主內存,而不是當前線程自己的工作內存中,另一方面也表明當進行讀操作時,讀到的不是緩存在本地線程內存中的值,而是直接讀所有線程可見的共享內存中的值,即讀取volatile類型的變量總會返回最新寫入的值。(當然volatile只用來修飾成員變量,不能用了修飾局部變量,因爲局部變量是存在於線程私有的虛擬機棧的棧幀中,不會存在多線程訪問的情況,而成員變量在類創建實例時是存在於線程共享的Java堆中,因此volatile修飾的成員變量直接在堆上操作,能實時對所有線程可見)

由於重排序和共享變量緩存都是對提升性能做出的優化,因此使用volatile關鍵字會抑制這兩種優化,雖能保證共享變量的可見性,但是會以犧牲部分性能爲代價。

volatile變量通常用作某個操作完成、發生中斷或者狀態的標誌。例如一個典型的用途:檢查某個狀態標記以判斷是否退出循環:

public class Test {

    static volatile boolean flag = true;

    public static void main(String[] args){

        while (!flag){
            doSomething();
        }

    }

    public static void doSomething(){}
}

一個常見誤區:volatile的語義不足以確保遞增操作(count++)的原子性:

// volatile修飾的變量自增操作源碼
public class Test {

    volatile int count = 0;

    public static void main(String[] args){
        
    }

    public void inc(){
        count++;
    }

}


// javap.exe -verbose查看字節碼
public void inc();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field count:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field count:I
        10: return
      LineNumberTable:
        line 10: 0
        line 11: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   LTest;

由上述字節碼指令可以看出,即便是volatile修飾的共享變量,在自增操作時,仍會在線程私有的棧幀中對應2-7一共四條指令,也就意味着,這個四條指令的複合過程不是原子操作,存在多線程下的競態條件。

發佈和逸出

私有數組逸出:當發佈一個對象時,在該對象的非私有域中引用的所有的對象同樣會被髮布。

public class Test {

    private String[] privateArr = {"hello", "world"};

    public static void main(String[] args){

        Test test = new Test();
        String[] arr = test.getArray();
        for (String str: arr){
            System.out.println(str);
        }

        for (int i=0; i<arr.length; i++){
            arr[i] = "modified";
        }

        test.print();
    }

    public String[] getArray(){
        return privateArr;
    }

    public void print(){
        System.out.println("--- print Test class's private String array ---");
        for (String str: this.privateArr){
            System.out.println(str);
        }
    }
}

// 輸出結果:
hello
world
--- print Test class's private String array ---
modified
modified

隱式this引用逸出,未完待續。。。。

線程封閉

僅在單線程內訪問數據,則無需同步,也不存在競態條件,實現這樣的單線程訪問變量的技術被稱爲線程封閉

從定義上可以看出,線程封閉有很多好處:

  • 由於不需要同步,因此程序性能得以提升;
  • 由於不存在競態條件,只在單線程內操作變量,因此降低了併發程序的出錯風險;

方式一:Ad-hoc線程封閉

Ad hoc是拉丁文常用短語中的一個短語。這個短語的意思是'特設的、特定目的的(地)、即席的、臨時的、將就的、專案的'。這個短語通常用來形容一些特殊的、不能用於其它方面的的,爲一個特定的問題、任務而專門設定的解決方案。

Ad-hoc線程封閉是指,維護線程封閉性的職責完全由程序實現來承擔。Ad-hoc線程封閉是非常脆弱的(程序中儘量少用,多用下述兩種棧封閉技術),因爲沒有藉由任何一種語言特性(藉助語言特性的有下面的:藉助局部變量/棧封閉、藉助ThreadLocal)來輔助實現單線程訪問,不借助語言特性,只從程序邏輯上實現單線程訪問。

方式二:棧封閉

棧封閉要求,只能通過局部變量才能訪問對象。由於局部變量的固有屬性之一就是封閉在執行線程中,其他線程無法訪問到該線程的JVM棧。

對於基本類型的局部變量,由於Java中無法通過任何機制獲取對基本類型的引用,所以基本類型的局部變量不存在逃逸的可能性(逃逸多指可變對象的引用地址被其他線程獲取到,從而出現意料之外的併發修改等),因此對其棧封閉是完全安全的。而維護引用局部變量的棧封閉性時,程序中需要多做一些工作以確保被引用的對象不會逃逸出當前線程之外。

方式三:ThreadLocal類

ThreadLocal類能使線程中的某個值與保存值的對象關聯起來。

理解:ThreadLocal是一個泛型類,其創建的實例對象可以通過該對象的set實例方法或者protected修飾的initialValue方法進行賦值,從而實現實例對象與某個類型的值之間的一一綁定關係。

另一篇關於ThreadLocal的詳細講解:ThreadLocal的源碼分析

 

 

 

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章