線程安全與鎖優化

面向過程的編程思想: 程序編寫都是以算法爲核心的,程序員會把數據和過程分別做爲獨立的部分來考慮,數據表明問題空間中的客體,程序代碼則用於處理這些數據,這種思惟方式直接站在計算機的角度去抽象問題和解決問題
java

面向對象的編程思想: 站在現實世界的角度去抽象和解決問題,把數據和行爲都看作是對象的一部分,這樣可讓程序員以符合現實世界的思惟方式來編寫和組織程序。程序員

現實世界和計算機世界之間不可避免的會有所差別:人們很難想象現實中的對象在一項工做進行期間,會被不斷的中斷和切換,對象的屬性可能會在中斷期間被修改和變「髒」,而這些事情在計算機世界中是很正常的。有時候,良好的設計原則不得不向顯示作出一些讓步,咱們必須讓程序在計算機中正確無誤的運行,而後再考慮如何將代碼組織的更好,運行的更快。算法

1.線程安全編程

「線程安全」怎麼解釋呢?數組

當多個線程訪問一個對象時,若是不用考慮這些線程在運行時環境下的調度和交替執行,也不須要進行額外的同步,或者在調用方進行任何其餘的協調操做,調用這個對象的行爲均可以得到正確的結果,那這個對象時線程安全的!安全

這個定義比較嚴謹,它要求線程安全的代碼都必須具有一個特徵:代碼自己封裝了全部必要的正確性保障手段,令調用者無需關心多線程的問題,更無需本身採用任何措施來保證多線程的正確調用。服務器

①java語言中的線程安全數據結構

 在java語言中,線程安全是如何體現的?有哪些操做時線程安全的?多線程

不把線程安全當作一個非真既假的二元排他選項來兌現,按照線程的「安全程度」由強至弱來排序,咱們能夠將java語言中各類操做共享的數據分爲如下五類:不可變、絕對線程安全、相對線程安全、線程兼容和線程獨立架構

 

1.不可變

 

        在 Java 語言中(特指 JDK 1.5 之後,即 Java 內存模型被修正以後的 Java 語言),不可變(Immutable)的對象必定是線程安全的,不管是對象的方法實現仍是方法的調用者,都不須要採起任何的線程安全保障措施,在前面咱們談到 final 關鍵字帶來的可見性時曾經提到過這一點,只要一個不可變的對象被正確地構建出來(沒有發生 this 引用逃逸的狀況),那其外部的可見狀態永遠也不會改變,永遠也不會看到它在多個線程之中處於不一致的狀態。「不可變」 帶來的安全性是最簡單和最純粹的。

 

        Java 語言中,若是共享數據是一個基本數據類型,那麼只要在定義時使用 final 關鍵字修飾它就能夠保證它是不可變的。若是共享數據是一個對象,那就須要保證對象的行爲不會對其狀態產生任何影響才行,若是讀者尚未想明白這句話,不妨想想 java.lang.String 類的對象,它是一個典型的不可變對象,咱們調用它的 substring()、replace() 和 concat() 這些方法都不會影響它原來的值,只會返回一個新的構造的字符串對象。

 

        保證對象行爲不影響本身狀態的途徑有不少種,其中最簡單的就是把對象中帶有狀態的變量都聲明爲 final,這樣在構造函數結束以後,它就是不可變的,例如代碼清單 13-1 中 java.lang.Integer 構造函數所示的,它經過將內部狀態變量 value 定義爲 final 來保障狀態不變。

 

代碼清單 13-1  JDK 中 Integer 類的構造函數

/** 
   * The value of the {@code Integer}. 
   * 
   * @serial 
   */  
  private final int value;  
  
  /** 
   * Constructs a newly allocated {@code Integer} object that 
   * represents the specified {@code int} value. 
   * 
   * @param   value   the value to be represented by the 
   *                  {@code Integer} object. 
   */  
  public Integer(int value) {  
      this.value = value;  
  }  

    在 Java API 中符合不可變要求的類型,除了上面提到的 String 以外,經常使用的還有枚舉類型,以及 java.lang.Number 的部分子類,如 Long 和 Double 等數值包裝類型,BigInteger 和 BigDecimal 等大數據類型;但同爲 Number 的子類型的原子類 AtomicInteger 和 AtomicLong 則並不是不可變的,讀者不妨看看這兩個原子類的源碼,想想爲何。

 

2.  絕對線程安全

        絕對的線程安全徹底知足 Brian Goetz 給出的線程安全的定義,這個定義實際上是很嚴格的,一個類要達到 「無論運行是環境如何,調用者都不須要任何額外的同步措施」 一般須要付出很大的,甚至有時候是不切實際的代價。在 java API 中標註本身是線程安全的類,大多數都不是絕對的線程安全。咱們能夠經過 Java API 中一個不是 「絕對線程安全」 的線程安全類來看看這裏的 「絕對」 是什麼意思。

        若是說 java.util.Vector 是一個線程安全的容器,相信全部的 Java 程序員對此都不會有異議,由於它的 add()、get() 和 size() 這類方法都是被 synchronized 修飾的,儘管這樣效率很低,但確實是安全的。可是,即時它全部的方法都被修飾成同步,也不意味着調用它的時候永遠都不須要同步手段了,請看一下代碼清單 13-2 中的測試代碼。

代碼清單 13-2  對 Vector 線程安全的測試

private static Vector<Integer> vector = new Vector<Integer>();  
  
public static void main(String[] args) {  
    while (true) {  
        for (int i = 0; i < 10; i++) {  
            vector.add(i);  
        }  
          
        Thread removeThread = new Thread(new Runnable() {  
            @Override  
            public void run() {  
                for (int i = 0; i < vector.size(); i++) {  
                    vector.remove(i);  
                }  
            }  
        });  
          
        Thread printThread = new Thread(new Runnable() {  
            @Override  
            public void run() {  
                for (int i = 0; i < vector.size(); i++) {  
                    System.out.println(vector.get(i));  
                }  
            }  
        });  
          
        removeThread.start();  
        printThread.start();  
          
        // 不要同時產生過多的線程,不然會致使操做系統假死  
        while (Thread.activeCount() > 20);  
    }  
}  

  運行結果以下:

Exception in thread "Thread-10865" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 11  
    at java.util.Vector.get(Vector.java:744)  
    at cn.mk.day0810.MyTest$2.run(MyTest.java:30)  
    at java.lang.Thread.run(Thread.java:745)  

        很明顯,儘管這裏使用到的 Vector 的 get()、remove() 和 size() 方法都是同步的,可是在多線程的環境中,若是不在方法調用端作額外的同步措施的話,使用這段代碼仍然是不安全的,由於若是另外一個線程剛好在錯誤的時間裏刪除了一個元素,致使序號 i 已經再也不可用的話,再用 i 訪問數組就會拋出一個 ArrayIndexOutOfBoundsException。若是要保證這段代碼能正確執行下去,咱們不得不把 removeThread 和 printThread 的定義改爲如代碼清單 13-3 所示的樣子。

 

代碼清單 13-3  必須加入同步以保證 Vector 訪問的線程安全性

Thread removeThread = new Thread(new Runnable() {  
    @Override  
    public void run() {  
        synchronized(vector) {  
            for (int i = 0; i < vector.size(); i++) {  
                vector.remove(i);  
            }  
        }  
    }  
});  
  
Thread printThread = new Thread(new Runnable() {  
    @Override  
    public void run() {  
        synchronized(vector) {  
            for (int i = 0; i < vector.size(); i++) {  
                System.out.println(vector.get(i));  
            }  
        }  
    }  
});  

3. 相對線程安全

        相對的線程安全就是咱們一般意義上所講的線程安全,它須要保證對這個對象單獨的操做是線程安全,咱們在調用的時候不須要作額外的保障措施,可是對於一些特定順序的連續調用,就可能須要在調用端使用額外的同步手段來保證調用的正確性。上面代碼清單 13-2 和代碼清單 13-3 就是相對線程安全的明顯的案例。

        在 Java 語言中,大部分的線程安全類都屬於這種類型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包裝的集合等。

4. 線程兼容

        線程兼容是指對象自己並非線程安全的,可是能夠經過在調用端正確地使用同步手段來保證對象在併發環境中能夠安全地使用,咱們日常說一個類不是線程安全的,絕大多數時候指的是這一種狀況。Java API 中大部分的類都是屬於線程兼容的,如與前面的 Vector 和 HashTable 相對應的集合類 ArrayList 和 HashMap 等。

5. 線程對立

        線程對立是指不管調用端是否採起了同步措施,都沒法在多線程環境中併發使用的代碼。因爲 Java 語言天生就具有多線程特性,線程對立這種排斥多線程的代碼是不多出現的,並且一般都是有害的,應當儘可能避免。

        一個線程對立的例子是 Thread 類的 suspend() 和 resume() 方法,若是有兩個線程同時持有一個線程對象,一個嘗試去中斷線程,另外一個嘗試去恢復線程,若是併發進行的話,不管調用時是否進行了同步,目標線程都是存在死鎖風險的,若是 suspend() 中斷的線程就是即將要執行 resume() 的那個線程,那就確定要產生死鎖了。也正是因爲這個緣由,suspend() 和 resume() 方法已經被 JDK 聲明廢棄(@Deprecated)了。常見的線程對立的操做還有 System.setIn()、System.setOut() 和 System.runFinalizerosOnExit() 等。

線程安全的實現方法

        瞭解了什麼是線程安全以後,緊接着的一個問題就是咱們應該如何實現線程安全,這聽起來彷佛是一件由代碼如何編寫來決定的事情,確實,如何實現線程安全與代碼編寫有很大的關係,但虛擬機提供的同步和鎖機制也起到了很是重要的做用。本節中,代碼編寫如何實現線程安全和虛擬機如何實現同步與鎖這二者都會有所涉及,相對而言更偏重後者一些,只要讀者瞭解了虛擬機線程安全手段的運做過程,本身去思考代碼如何編寫並非一件困難的事情。

1. 互斥同步

        互斥同步(Mutual Exclusion & Synchronization)是常見的一種併發正確性保障手段。同步是指在多個線程併發訪問共享數據時,保證共享數據在同一個時刻只被一個(或者是一些,使用信號量的時候)線程使用。而互斥是實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)和信號量(Semaphore)都是主要的互斥實現方式。所以,在這 4 個字裏面,互斥是因,同步是果;互斥是方法,同步是目的。

        在 Java 中,最基本的互斥同步手段就是 synchronized 關鍵字,synchronized 關鍵字通過編譯以後,會在同步塊的先後分別造成 monitorenter 和 monitorexit 這兩個字節碼指令,這兩個字節碼都須要一個 reference 類型的參數來指明要鎖定和解鎖的對象。若是 Java 程序中的 synchronized 明確指定了對象參數,那就是這個對象的 reference;若是沒有明確指定,那就根據 synchronized 修飾的是實例方法仍是類方法,去取對應的對象實例或 Class 對象來做爲鎖對象。

        根據虛擬機規範的要求,在執行 monitorenter 指令時,首先要嘗試獲取對象的鎖。若是這個對象沒被鎖定,或者當前線程已經擁有了那個對象的鎖,把鎖的計數器加 1,相應的,在執行 monitorexit 指令時將鎖計數器減 1,當計數器爲 0 時,鎖就被釋放。若是獲取對象鎖失敗,那當前線程就要阻塞等待,知道對象鎖被另一個線程釋放爲止。

        在虛擬機規範對 monitorenter 和 monitorexit 的行爲描述中,有兩點是須要特別注意的。首先,synchronized 同步塊對同一條線程來講是可重入的,不會出現本身把本身鎖死的問題。其次,同步塊在已進入的線程執行完以前,會阻塞後面其餘線程的進入。在前面講過,Java 的線程是映射到操做系統的原生線程之上的,若是要阻塞或喚醒一個線程,都須要操做系統來幫忙完成,這就須要從用戶態轉換到核心態中,所以狀態轉換須要耗費不少的處理器時間。對於代碼簡單的同步塊(如被 synchronized 修飾的 getter() 或 setter() 方法),狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長。因此 synchronized 是 Java 語言中一個重要級(Heavyweight)的操做,有經驗的程序員都會在確實必要的狀況下才使用這種操做。而虛擬機自己也會進行一些優化,譬如在通知操做系統阻塞線程以前加入一段自旋等待過程,避免頻繁地切入到核心態之中。

        除了 synchronized 以外,咱們還可使用 java.util.concurrent(下文成 J.U.C)包中的重入鎖(ReentrantLock)來實現同步,在基本用法上,ReentrantLock 與 synchronized 很類似,他們都具有同樣的線程重入特性,只是代碼寫法上有點區別,一個表現爲 API 層面的互斥鎖(lock() 和 unlock() 方法配合 try/finally 語句塊來完成),另外一個表現爲原生語法層面的互斥鎖。不過,相比 synchronized,ReentrantLock 增長了一些高級功能,主要有如下 3 項:等待可中斷、可實現公平鎖,以及鎖能夠綁定多個條件。

  • 等待可中斷是指當持有鎖的線程長期不釋放鎖的時候,正在等待的線程能夠選擇放棄等待,改成處理其餘事情,可中斷特性對處理執行時間很是長的同步塊頗有幫助。
  • 公平鎖是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次得到鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的線程都有機會得到鎖。synchronized 中的鎖是非公平的,ReentrantLock 默認狀況下也是非公平的,但能夠經過帶布爾值的構造函數要求使用公平鎖。
  • 鎖綁定多個條件是指一個 ReentrantLock 對象能夠同時綁定多個 Condition 對象,而在 synchronized 中,鎖對象的 wait() 和 notify() 或 notifyAll() 方法能夠實現一個隱含的條件,若是要和多於一個的條件關聯的時候,就不得不額外添加一個鎖,而 ReentrantLock 則無須這樣作,只須要屢次調用 newCondition() 方法便可。

        若是須要使用上述功能,選用 ReentrantLock 是一個很好的選擇,那若是是基於性能考慮呢?關於 synchronized 和 ReentrantLock 的性能問題,Brian Goetz 對這兩種鎖在 JDK 1.5 與單核處理器,以及 JDK 1.5 與雙 Xeon 處理器環境下作了一組吞吐量對比的實現,實現結果如圖 13-1 和圖 13-2 所示。

圖 13-1  JDK 1.五、單核處理器下兩種鎖的吞吐量對比

圖 13-2  JDK 1.五、雙 Xeon 處理器下的兩種鎖的吞吐量對比

        從圖 13-1 和圖 13-2 能夠看出,多線程環境下 synchronized 的吞吐量降低得很是嚴重,而 ReentrantLock 則能基本保持在同一個比較穩定的水平上。與其說 ReentrantLock 性能好,還不如說 synchronized 還有很是大的優化餘地。後續的技術發展也證實了這一地單,JDK 1.6 中加入了不少針對鎖的優化措施,JDK 1.6 發佈以後,人們就發現 synchronized 與 ReentrantLock 的性能基本上是徹底持平了。所以,若是讀者的程序是使用 JDK 1.6 或以上部署的話,性能因素就再也不是選擇 ReentrantLock 的理由了,虛擬機在將來的性能改進中確定也會更加偏向於原生的 synchronized,因此仍是提倡在 synchronized 能實現需求的狀況下,優先考慮使用 synchronized 來進行同步。

4. 非阻塞同步

        互斥同步最主要的問題就是進行線程阻塞和喚醒所帶來的性能問題,所以這種同步也稱爲阻塞同步(Blocking Synchronization)。從處理問題的方式上來講,互斥同步屬於一種悲觀的併發策略,老是認爲只要不去作正確的同步措施(例如加鎖),那就確定會出現問題,不管共享數據是否真的會出現競爭,它都要進行加鎖(這裏討論的是概念模型,實際上虛擬機會優化掉很大一部分沒必要要的加鎖)、用戶 態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程須要喚醒等操做。隨着硬件指令集的發展,咱們有了另一個選擇:基於衝突檢測的樂觀併發策略,通俗地說,就是先進行操做,若是沒有其餘線程爭用共享數據,那操做就成功了;若是共享數據有爭用,產生了衝突,那就再採起其餘的補償措施(最多見的補償措施就是不斷地重試,知道成功爲止),這種樂觀的併發策略的許多實現都不須要把線程掛起,所以這種同步操做稱爲非阻塞同步(Non-Blocking Synchronization)。

        爲何筆者說使用樂觀併發策略須要 「硬件指令集的發展」 才能進行呢?由於咱們須要操做和衝突檢測這兩個步驟具有原子性,靠什麼來保證呢?若是這裏再使用互斥同步來保證就失去意義了,因此咱們只能靠硬件來完成這件事情,硬件保證一個從語法上看起來須要屢次操做的行爲只經過一條處理器指令就能完成,這類指令經常使用的有:

  • 測試並設置(Test-and-Set)。
  • 獲取並增長(Fetch-and-Increment)。
  • 交換(Swap)。
  • 比較並交換(Compare-and Swap,下文成 CAS)。
  • 加載鏈接 / 條件存儲(Load-Linked / Store-Conditional,下文稱 LL/SC)。

        其中,前面的 3 條是 20 世紀就已經存在於大多數指令集之中的處理器指令,後面的兩條是現代處理器新增的,並且這兩條指令的目的和功能是相似的。在 IA6四、x86 指令集中有 cmpxchg 指令完成 CAS 功能,在 sparc-TSO 也有 casa 指令實現,而在 ARM 和 PowerPC 架構下,則須要使用一對 Idrex/strex 指令來完成 LL/SC 的功能。

        CAS 指令須要有 3 個操做數,分別是內存位置(在 Java 中能夠簡單理解爲變量的內存地址,用 V 表示)、舊的預期值(用 A 表示)和新值(用 B 表示)。CAS 指令執行時,當且僅當 V 符合舊值預期值 A 時,處理器用新值 B 更新 V 的值,不然它就不執行更新,可是不管是否更新了 V 的值,都會返回 V 的舊值,上述的處理過程是一個原子操做。

        在 JDK 1.5 以後,Java 程序中才可使用 CAS 操做,該操做由 sun.misc.Unsafe 類裏面的 compareAndSwapInt() 和 compareAndSwapLong() 等幾個方法包裝提供,虛擬機在內部對這些方法作了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器 CAS 指令,沒有方法調用的過程,或者能夠認爲是無條件內聯進去了。(注:這種被虛擬機特殊處理的方法稱爲固有函數(intrinsics),相似的固有函數還有 Math.sin() 等)

        因爲 Unsafe 類不是提供給用戶程序調用的類(Unsafe.getUnsafe() 的代碼中限制了只有啓動類加載器(Bootstrap ClassLoader)加載的 Class 才能訪問它),所以,若是不採用反射手段,咱們只能經過其餘的 Java API 來間接使用它,如 J.U.C 包裏面的整數原子類,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 類的 CAS 操做。

        咱們不妨拿一段在第 12 章中沒有解決的問題代碼來看看如何使用 CAS 操做來避免阻塞同步,代碼如代碼清單 12-1 所示。咱們曾經經過這段 20 個線程自增 10000 次的代碼來證實 volatile 變量不具有原子性,那麼如何才能讓它具有原子性呢?把 「race++」 操做或 increase() 方法用同步塊包裹起來固然是一個辦法,可是若是改爲代碼清單 13-4 所示的代碼,那效率將會提升許多(實測效率更低~~)。

代碼清單 13-4  Atomic 的原子自增運算

/** 
 * Atomic 變量自增運算測試 
 *  
 * @author mk 
 */  
public class AtomicTest {  
      
    public static AtomicInteger race = new AtomicInteger(0);  
      
    public static void increase() {  
        race.incrementAndGet();  
    }  
      
    private static final int THREADS_COUNT = 20;  
      
    public static void main(String[] args) {  
        Thread[] threads = new Thread[THREADS_COUNT];  
        for (int i = 0; i < THREADS_COUNT; i ++) {  
            threads[i] = new Thread(new Runnable() {  
                @Override  
                public void run() {  
                    for (int i = 0; i < 10000; i++) {  
                        increase();  
                    }  
                }  
            });  
            threads[i].start();  
        }  
          
        // 等待全部累加線程都結束  
        while (Thread.activeCount() > 1)   
            Thread.yield();  
          
        System.out.println(race);  
    }  
}  

 運行結果以下:

200000  

     使用 AtomicInteger 代替 int 後,程序輸出了正確的結果,一切都要歸功於 incrementAndGet() 方法的原子性。它的實現其實很是簡單,如代碼清單 13-5 所示。

 

代碼清單 13-5  incrementAndGet() 方法的 JDK 源碼

/** 
    * Atomically increments by one the current value. 
    * 
    * @return the updated value 
    */  
   public final int incrementAndGet() {  
       for (;;) {  
           int current = get();  
           int next = current + 1;  
           if (compareAndSet(current, next))  
               return next;  
       }  
   }  

   incrementAndGet() 方法在一個無限循環中,不斷嘗試將一個比當前值大 1 的新值賦給本身。若是失敗了,那說明在執行 「獲取-設置」 操做的時候值已經有了修改,因而再次循環進行下一次操做,直到設置成功爲止。

 

        儘管 CAS 看起來很美,但顯然這種操做沒法涵蓋互斥同步的全部使用場景,而且 CAS 從語義上來講並非完美的,存在這樣的一個邏輯漏洞:若是一個變量 V 初次讀取的時候是 A 值,而且在準備賦值的時候檢查到它仍然爲 A 值,那咱們就能說它沒有被其餘線程改變過了嗎?若是在這段期間它的值曾經被改爲了 B,後來又被改回 A,那 CAS 操做就會誤認爲它歷來沒有被改變過。這個漏洞稱爲 CAS 操做的 「ABA」 問題。J.U.C 包爲了解決這個問題,提供了一個帶有標記的原子引用類 「AtomicStampedReference」,它能夠經過控制變量值的版原本保證 CAS 的正確性。不過目前來講這個類比較 「雞肋」,大部分狀況下 ABA 問題不會影響程序併發的正確性,若是須要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。

3. 無同步方案

        要保證線程安全,並非必定就要進行同步,二者沒有因果關係。同步只是保證共享數據爭用時的正確性的手段,若是一個方法原本就不涉及共享數據,那它天然就無須任何同步措施去保證正確性,所以會有一些代碼天生就是線程安全的,筆者簡單地介紹其中的兩類。

        可重入代碼(Reentrant Code):這種代碼也叫作純代碼(Pure Code),能夠在代碼執行的任什麼時候刻中斷它,轉而去執行另一段代碼(包括遞歸調用它自己),而在控制權返回後,原來的程序不會出現任何錯誤。相對線程安全來講,可重入性是更基本的特性,它能夠保證線程安全,即全部的可重入的代碼都是線程安全的,可是並不是全部的線程安全的代碼都是可重入的。

        可重入代碼有一些共同的特徵,例如不依賴存儲在堆上的數據和公用的系統資源、用到的狀態量都由參數中傳入、不調用非可重入的方法等。咱們能夠經過一個簡單的原則來判斷代碼是否具有可重入性:若是一個方法,它的返回結果是能夠預測的,只要輸入了相同的數據,就都能返回相同的結果,那它就知足可重入性的要求,固然也就是線程安全的。

        線程本地存儲(Thread Local Storage):若是一段代碼中所須要的數據必須與其餘代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行?若是能保證,咱們就能夠把共享數據的可見範圍限制在同一個線程以內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。

        符合這種特色的應用並很多見,大部分使用消費隊列的架構模式(如 「生產者 - 消費者」 模式)都會將產品的消費過程儘可能在一個線程中消費完,其中最重要的一個應用實例就是經典 Web 交互模型中的 「一個請求對應一個服務器線程」(Thread-per-Request)的處理方式,這種處理方式的普遍應用使得不少 Web 服務端應用均可以使用線程本地存儲來解決線程安全問題。

        Java 語言中,若是一個變量要被多線程訪問,可使用 volatile 關鍵字聲明它爲 「易變的」;若是一個變量要被某個線程獨享,Java 中就沒有相似 C++ 中的 __declspec(thread) (注:在 Visual C++ 是 「__declspec(thread)」 關鍵字,而在 GCC 中是 「__thread」)這樣的關鍵字,不過仍是能夠經過 java.lang.ThreadLocal 類來實現線程本地存儲的功能。每個線程的 Thread 對象中都有一個 ThreadLocalMap 對象,這個對象存儲了一組以 ThreadLocal.threadLocalHashCode 爲鍵,以本地線程變量爲值的 K-V 值對,ThreadLocal 對象就是當前線程的 ThreadLocalMap 的訪問入口,每個 ThreadLocal 對象都包含了一個獨一無二的 threadLocalHashCode 值,使用這個值就能夠在線程 K-V 值對中找回對應的本地線程變量。

鎖優化

        高效併發是從 JDK 1.5 到 JDK 1.6 的一個重要改進,HotSpot 虛擬機開發團隊在這個版本上花費了大量的精力去實現各類鎖優化技術,如適應性自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖粗化(Lock Coarsening)、輕量級鎖(Lightweight Locking)和偏向鎖(Biased Locking)等。這些技術都是爲了在線程之間更高效地共享數據,以及解決競爭問題,從而提升程序的執行效率。

自旋鎖與自適應自旋

        前面咱們討論互斥同步的時候,提到了互斥同步對性能最大的營銷阻塞的實現,掛起線程和恢復線程的操做都須要轉入內核態完成,這些操做給系統的併發性能帶來了很大的壓力。同時,虛擬機的開發團隊也注意到在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間去掛起和恢復線程並不值得。若是物理機器有一個以上的處理器,能讓兩個或以上的線程同時並行執行,咱們就可讓後面請求鎖的那個線程 「稍等一下」,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,咱們只需讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖。

        自旋鎖在 JDK 1.4.2 中就已經引入,只不過默認是關閉的,可使用 -XX:+UseSpinning 參數來開啓,在 JDK 1.6 就已經改成默認開啓了。自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待自己雖然避免了線程切換的開銷,但它是要佔用處理器時間的,所以,若是鎖被佔用的時間很短,自旋等待的效果就會很是好,反之,若是鎖被佔用的時候很長,那麼自旋的線程只會白白消耗處理器資源,而不會作任何有用的工做,反而會帶來性能上的浪費。所以,自旋等待的時間必需要有必定的限度,若是自旋超過了限定的次數仍然沒有成功得到鎖,就應當使用傳統的方式去掛起線程了。自旋次數的默認值是 10 次,用戶可使用參數 -XX:PreBlockSpin 來更改。

        在 JDK 1.6 中引入了自適應的自旋鎖。自適應意味着自旋的時間再也不固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。若是在同一個鎖對象上,自旋等待剛剛成功得到過鎖,而且持有鎖的線程正在運行中,那麼虛擬機就會認爲此次自旋也頗有可能再次成功,進而它將容許自旋等待持續相對更長的時間,好比 100 個循環。另外,若是對於某個鎖,自旋不多成功得到過,那在之後要獲取這個鎖時將可能省略掉自旋過程,以免浪費處理器資源。有了自適應自旋,隨着程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的情況預測就會愈來愈準確,虛擬機就會變得愈來愈 「聰明」 了。

鎖消除

        鎖消除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,可是被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要斷定依據來源於逃逸分析的數據支持,若是斷定在一段代碼中,堆上的全部數據都不會逃逸出去從而被其餘線程訪問到,那就能夠把他們當作棧上數據對待,認爲它們是線程私有的,同步加鎖天然就無須進行。

        也許讀者會有疑問,變量是否逃逸,對於虛擬機來講須要使用數據流分析來肯定,可是程序本身應該是很清楚的,怎麼會在明知道不存在數據爭用的狀況下要求同步呢?答案是有許多同步措施並非程序員本身加入的。同步的代碼在 Java 程序中的廣泛程度也許超過了大部分讀者的想象。咱們來看看代碼清單 13-6 中的例子,這段很是簡單的代碼僅僅是輸出 3 個字符串相加的結果,不管是源碼字面上仍是程序語義上都沒有同步。

代碼清單 13-6  一段看起來沒有同步的代碼

public static String concatString(String s1, String s2, String s3) {  
    return s1 + s2 + s3;  
}  

  咱們也知道,因爲 String 是一個不可變的類,對字符串的鏈接操做老是經過生成新的 String 對象來進行的,所以 Javac 編譯器會對 String 鏈接作自動優化。在 JDK 1.5 以前,會轉化爲 StringBuffer 對象的連續 append() 操做,在 JDK 1.5 及之後的版本中,會轉化爲 StringBuilder 對象的連續 append() 操做,即代碼清單 13-6 中的代碼可能會編程代碼清單 13-7 的樣子(注:客觀地說,既然談到鎖消除與逃逸分析,那虛擬機就不多是 JDK 1.5 以前的版本,實際上會轉化爲非線程安全的 StringBuilder 來完成字符串拼接,並不會加鎖,但這也不影響筆者用這個例子證實 Java 對象中同步的廣泛性)。

 

代碼清單 13-7  Javac 轉化後的字符串鏈接操做

public static String concatString(String s1, String s2, String s3) {  
    StringBuffer sb = new StringBuffer();  
    sb.append(s1);  
    sb.append(s2);  
    sb.append(s3);  
    return sb.toString();  
}  


        如今你們還認爲這段代碼沒有涉及同步嗎?每一個 StringBuffer.append() 方法中都有一個同步塊,鎖就是 sb 對象。虛擬機觀察變量 sb,很快就會發現它的動態做用域被限制在 concatString() 方法內部。也就是說,sb 的全部引用永遠不會 「逃逸」 道 concatString() 方法以外,其餘線程沒法訪問到它,所以,雖然這裏有鎖,可是能夠被安全地消除掉,在即時編譯以後,這段代碼就會忽略掉全部的同步而直接執行了。

 

鎖粗化

        原則上,咱們在編寫代碼的時候,老是推薦將同步塊的做用範圍限制得儘可能小——只在共享數據的實際做用域中才進行同步,這樣是爲了使得須要同步的操做數量儘量變小,若是存在鎖競爭,那等待鎖的線程也能儘快拿到鎖。

        大部分狀況下,上面的原則都是正確的,可是若是一系列的連續操做都對同一個對象反覆加鎖和解鎖,甚至加鎖操做是出如今循環體中,那即便沒有線程競爭,頻繁地進行互斥同步操做也會致使沒必要要的性能損耗。

        代碼清單 13-7 中連續的 append() 方法就屬於這類狀況。若是虛擬機探測到由這樣的一串零碎的操做都對同一個對象加鎖,將會把加鎖同步的範圍擴展(粗化)到整個操做序列的外部,以代碼清單 13-7 爲例,就是擴展到第一個 append() 操做以前直至最後一個 append() 操做以後,這樣只須要加鎖一次就能夠了。

輕量級鎖

        輕量級鎖是 JDK 1.6 之中加入的新型鎖機制,它名字中的 「輕量級」 是相對於使用操做系統互斥量來實現的傳統鎖而言的,所以傳統的鎖機制就稱爲 「重量級」 鎖。首先須要強調一點的是,輕量級鎖並非用來代替重要級鎖的,它的本意是在沒有多線程競爭的前提下,減小傳統的重量級鎖使用操做系統互斥量產生的性能消耗。

        要理解輕量級鎖,以及後面會講到的偏向鎖的原理和運做過程,必須從 HotSpot 虛擬機的對象(對象頭部分)的內存佈局開始介紹。HotSpot 虛擬機的對象頭(Object Header)分爲兩部分信息,第一部分用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC 分代年齡(Generational GC Age)等,這部分數據是長度在 32 位和 64 位的虛擬機中分別爲 32 bit 和 64 bit,官方稱它爲 「Mark Word」,它是實現輕量級鎖和偏向鎖的關鍵。另一部分用於存儲指向方法區對象類型數據的指針,若是是數組對象的話,還會有一個額外的部分用於存儲數組長度。

        對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Work 被設計成一個非固定的數據結構以便在極小的空間內存儲儘可能多的信息,它會根據對象的狀態複用本身的存儲空間。例如,在 32 位的 HotSpot 虛擬機中對象未被鎖定的狀態下,Mark Word 的 32bit 空間中的 25bit 用於存儲對象哈希碼(HashCode),4bit 用於存儲對象分代年齡,2bit 用於存儲鎖標誌位,1bit 固定爲 0,在其餘狀態(輕量級鎖定、重量級鎖定、GC 標記、可偏向)下對象的存儲內容見表 13-1。

        簡單地介紹了對象的內存佈局後,咱們把話題返回到輕量級鎖的執行過程上。在代碼進入同步塊的時候,若是此同步對象沒有被鎖定(鎖標誌位爲 「01」 狀態)虛擬機首先將在當前線程的棧幀中創建一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的 Mark Word 的拷貝(官方把這份拷貝加上了一個 Displaced 前綴,即 Displaced Mark Word),這時候線程堆棧與對象頭的狀態如圖 13-3 所示。

        而後,虛擬機將使用 CAS 操做嘗試將對象的 Mark Word 更新爲指向 Lock Record 的指針。若是這個更新動做成功了,那麼這個線程就擁有了該對象的鎖,而且對象 Mark Word 的鎖標誌位 (Mark Word 的最後 2bit)將轉變爲 「00」,即表示此對象處於輕量級鎖定狀態,這時候線程堆棧與對象頭的狀態如圖 12-4 所示。

        若是這個更新操做失敗了,虛擬機首先會檢查對象的 Mark Word 是否指向當前線程的棧幀,若是隻說明當前線程已經擁有了這個對象的鎖,那就能夠直接進入同步塊繼續執行,不然說明這個鎖對象以及被其餘線程線程搶佔了。若是有兩條以上的線程爭用同一個鎖,那輕量級鎖就再也不有效,要膨脹爲重量級鎖,所標誌的狀態變爲 「10」,Mark Word 中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態。

        上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也是經過 CAS 操做來進行的,若是對象的 Mark Word 仍然指向着線程的鎖記錄,那就用 CAS 操做把對象當前的 Mark Word 和線程中複製的 Displaced Mark Word 替換回來,若是替換成功,整個同步過程就完成了。若是替換失敗,說明有其餘線程嘗試過獲取該鎖,那就要釋放鎖的同時,喚醒被掛起的線程。

        輕量級鎖能提高程序同步性能的依據是 「對於絕大部分的鎖,在整個同步週期內都是不存在競爭的」,這是一個經驗數據。若是沒有競爭,輕量級鎖使用 CAS 操做避免了使用互斥量的開銷,但若是存在鎖競爭,除了互斥量的開銷外,還額外發生了 CAS 操做,所以在有競爭的狀況下,輕量級鎖會比傳統的重量級鎖更慢。

偏向鎖

        偏向鎖也是 JDK 1.6 中引入的一項鎖優化,它的目的是消除數據在無競爭狀況下的同步原語,進一步提升程序的運行性能。若是說輕量級鎖是在無競爭的狀況下使用 CAS 操做去消除同步使用的互斥量,那偏向鎖就是在無競爭的狀況下把整個同步都消除掉,連 CAS 操做都不作了。

        偏向鎖的 「偏」,就是偏愛的 「偏」、偏袒的 「偏」,它的意思是這個鎖會偏向於第一個得到它的線程,若是在接下來的執行過程當中,該鎖沒有被其餘的線程獲取,則持有偏向鎖的線程將永遠不須要再進行同步。

        若是讀者讀懂了前面輕量級鎖中關於對象頭 Mark Word 與線程之間的操做過程,那偏向鎖的原理理解起來就會很簡單。假設當前虛擬機啓用了偏向鎖(啓用參數 -XX:+UseBiasedLocking,這是 JDK 1.6 的默認值),那麼,當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設爲 「01」,即偏向模式。同時使用 CAS 操做把獲取到這個鎖的線程 ID 記錄在對象的 Mark Word 之中,若是 CAS 操做成功,持有偏向鎖的線程之後每次進入這個鎖相關的同步塊時,虛擬機均可以再也不進行如何同步操做(例如 Locking、Unlocking 及對 Mark Word 的 Update 等)。

        當有另一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束。根據鎖對象目前是否處於被鎖定的狀態,撤銷偏向(Revoke Bias)後恢復到未鎖定(標誌位爲 「01」)或輕量級鎖定(標誌位爲 「00」)的狀態,後續的同步操做就如上面介紹的輕量級鎖那樣執行。偏向鎖、輕量級鎖的狀態轉換及對象 Mark Word 的關係如圖 13-5 所示。

        偏向鎖能夠提升帶有同步但無競爭的程序性能。它一樣是一個帶有效益權衡(Trade Off)性質的優化,也就是說,它並不必定老是對程序運行有利,若是程序中大多數的鎖老是被多個不一樣的線程訪問,那偏向模式就是多餘的。在具體問題具體分析的前提下,有時候使用參數 -XX:-UseBiasedLocking 來禁止偏向鎖優化反而能夠提高性能。

相關文章
相關標籤/搜索