jvm(13)-線程安全與鎖優化

【0】READMEjava

0.1)本文部分文字轉自「深刻理解jvm」, 旨在學習 線程安全與鎖優化 的基礎知識;程序員

0.2)本文知識對於理解 java併發編程很是有用,我的以爲,因此我總結的很詳細;編程


【1】概述數組

【2】線程安全安全

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

【2.1】java 語言中的線程安全(乾貨——java中各類操做共享的數據分爲如下5類)
0)java中各類操做共享的數據分爲如下5類:不可變, 絕對線程安全, 相對線程安全,線程兼容,線程對立;
1)不可變對象:該對象必定是線程安全的,不管是對象的方法實現仍是方法的調用者,都不須要採起任何的線程安全保障措施;
1.1)若是共享數據是一個基本數據類型,那麼只要在定義時使用 final 關鍵字修飾它就能夠保證它是不可變的;
1.2)不妨想一想java.lang.String類的對象:它是一個典型的不可變對象,調用它的substring(), replace(), concat() 這些方法都不會影響它原來的值,只會返回一個新構造的字符串對象;
1.3)看個荔枝:如java.lang.Integer 構造函數所示的,將value定義爲final 來保障狀態不變;

 

2)絕對線程安全多線程

2.1)在java API中標註本身是線程安全的類,大多數都不是絕對的線程安全併發

2.2)java.util.Vector 是一個線程安全的容器,由於它的add()方法,get()方法,size() 方法 這些方法都是被 synchronized修飾的,儘管效率低下,但確實是安全的;對Vector的測試以下: app

// 對線程安全的容器 Vector的測試
public class VectorTest { private static Vector<Integer> vector = new Vector<>(); public static void main(String[] args) { while(true) { for (int i = 0; i < 100; 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(); // 不要同時產生過多的線程,不然會致使os 假死
            while(Thread.activeCount() > 20); } } }

 對以上代碼的分析(Analysis):jvm

A1)運行結果: 做者說會拋出異常(但個人運行結果卻沒有拋出異常),按理說應該是會拋出異常的;

A2)拋出異常的緣由:由於若是另外一個線程剛好在錯誤的時間裏刪除了一個元素,致使序號i 已經再也不可用的話,再用i 訪問數組就會拋出一個 ArrayIndexOutOfBoundsException。

A3)若是要保證這段代碼可以正確執行下去,修改後的代碼爲:

    • // 對線程安全的容器 Vector的測試(修改後的代碼)
      public class ModifiedVectorTest {
          private static Vector<Integer> vector = new Vector<>(); 
          
          public static void main(String[] args) {
              while(true) {
                  for (int i = 0; i < 100; i++) {
                      vector.add(i);
                  }
                  
                  Thread removeThread = new Thread(new Runnable() {
                      @Override
                      public void run() {
                          synchronized (vector) { // 添加同步塊,this line
                              for (int i = 0; i < vector.size(); i++) {
                                  vector.remove(i);
                              }
                          }
                      }
                  });
                  
                  Thread printThread = new Thread(new Runnable() {
                      @Override
                      public void run() {
                          synchronized (vector) { // 添加同步塊,this line
                              for (int i = 0; i < vector.size(); i++) {
                                  System.out.println(vector.get(i));
                              }
                          }
                      }
                  });
                  
                  removeThread.start();
                  printThread.start();
                  
                  // 不要同時產生過多的線程,不然會致使os 假死
                  while(Thread.activeCount() > 20);
              }
          }
      }
3)相對線程安全

3.1)上述 VectorTest.java 和 ModifiedVectorTest.java 就是相對線程安全的案例;

4)線程兼容

4.1)線程兼容定義:線程兼容是指對象自己並非線程安全的,可是能夠經過在調用端正確地使用同步手段來保證對象在併發環境中能夠安全地使用;

5)線程對立
5.1)線程對立定義:指不管調用端是否採起了同步措施,都沒法在多線程環境中併發使用的代碼;
5.2)因爲java語言天生就具有多線程特性,線程對立這種排斥多線程的代碼是不多出現的,並且一般是有害的,應當儘可能避免;
5.3)線程對立的荔枝:Thread類的suspend() 和 resume() 方法;若是有兩個線程同時持有一個線程對象,一個嘗試去中斷線程,另外一個嘗試去恢復線程,若是併發進行的話,不管調用時是否進行了同步,目標線程都是存在死鎖風險的。正因爲這個緣由,suspend和result方法已經被JDK廢棄了了(@Deprecated)
【2.2】線程安全的實現方法
1)互斥同步
1.1)互斥同步:是常見的併發正確性保障手段;
1.2)同步:是指在多個線程併發訪問共享數據時,保證共享數據在同一個時刻被一個線程使用。
1.3)互斥:互斥是實現同步的一種手段;臨界區,互斥量和信號量都是主要的互斥實現方式。所以,在這4個字裏面,互斥是因,同步是果;互斥是方法,同步是目的;
1.4)最基本的互斥同步手段就是 synchronized關鍵字:synchronized關鍵字通過 編譯以後,會在同步塊的先後分別造成 monitorenter 和 monitorexit 這個兩個字節碼指令,這兩個字節碼都須要一個 reference類型的參數來指明要鎖定和解鎖的對象;若是java程序中的synchronized明確指定了對象參數,那就是這個對象的reference;若是沒有明確指定,那就根據 synchronized修飾的實例方法仍是類方法,去取對應的對象實例或Class 對象來做爲鎖對象;(乾貨——最基本的互斥同步手段就是 synchronized關鍵字)
1.5)根據虛擬機規範的要求:在執行monitorenter指令時,若是這個對象沒有鎖定或當前線程已經擁有了那個對象的鎖,鎖的計數器加1,相應的,在執行 monitorexit 指令時會將鎖計數器減1;當計數器爲0時,鎖就被釋放了;(乾貨——執行monitorenter和monitorexit 指令)
Attention)對於monitorenter 和 monitorexit 的行爲描述中,有兩點須要注意:
A1)synchronized同步塊對同一條線程來講是可重入的, 不會出現本身把本身鎖死的問題;
A2)同步塊在已進入的線程執行完以前,會阻塞後面其餘線程 的進入;
1.6)除了synchronized以外,還可使用 java.util.concurrent 包中的重入鎖(ReentrantLock)來實現同步;(乾貨——引入重入鎖進行同步)
1.6.1)synchronized 和 ReentrantLock 的區別: 一個表現爲 API 層面的互斥鎖(lock() 和 unlock() 方法配合 try/finally 語句塊來完成),另外一個表現爲 原生語法層面的互斥鎖;
1.6.2)ReentrantLock增長了一些高級功能:主要有3項:等待可中斷,可實現公平鎖, 以及鎖能夠綁定多個條件;(乾貨——ReentrantLock 增長了3項高級功能)
case1)等待可中斷: 指當持有鎖的線程長期不釋放鎖的時候,正在等待的線程能夠選擇放棄等待,改成處理其餘事情,可中斷特性對處理執行時間很是長的同步塊頗有幫助;
case2)公平鎖:指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次得到鎖;
case3)鎖綁定多個條件:指一個 ReentrantLock對象能夠同時綁定多個 Condition對象,而在 synchronized中,鎖對象的wait() 和 notify() 或 notifyAll() 方法能夠實現一個隱含的條件,若是要和多於一個的條件關聯的時候,就不得不額外地添加一個鎖,而ReentrantLock 則無需這樣作,只須要屢次調用 newCondition() 方法便可; (乾貨——可重入鎖ReentrantLock 和 synchronized 綁定多個條件的實現方式的區別)

1.6.3)關於synchronized 和 ReentrantLock 性能的分析:

對上圖的分析(Analysis):

A1)多線程環境下 synchronized的吞吐量降低得很是嚴重,而 ReentrantLock 則能基本保持在同一個比較穩定的水平上;與其說ReentrantLock性能好,還不如說 synchronized還有很是大的優化餘地;

A2)虛擬機在將來的性能改進中確定也會更加偏向於原生的 synchronized,因此仍是提倡在 synchronized能實現需求的狀況下,優先考慮使用 synchronized 來進行同步;(乾貨——同步方式推薦使用synchronized)

2)非阻塞同步
2.1)阻塞同步(互斥同步)的問題:就是進行線程阻塞和喚醒所帶來的性能問題,互斥同步屬於一種悲觀的併發策略,不管共享數據是否真的會出現競爭,它都要進行加鎖,用戶態核心態轉換,維護鎖計數器和檢查是否有被阻塞的線程須要喚醒等操做;(乾貨——阻塞同步(互斥同步)的問題)
2.2)非阻塞同步定義:基於衝突檢測的樂觀併發策略,通俗的說,就是先進行操做,若是沒有其餘線程爭用共享數據,那操做就成功了;若是共享數據有爭用,產生了衝突,那就再採用其餘的補償措施,這種樂觀的併發策略的許多實現都不須要把線程掛起,所以這種同步操做稱爲 非阻塞同步;(乾貨——非阻塞同步定義)
2.3)爲何做者要說使用樂觀併發策略須要「硬件指令集的發展」才能進行呢?由於 咱們須要操做和衝突檢測這兩個步驟具有原子性,靠什麼來保證呢?
2.3.1)硬件: 保證一個從語義上看起來須要屢次操做的行爲只經過一次處理器指令就能完成,這類指令經常使用的有:(instructions)
i1)測試並設置(Test-and-Set);
i2)獲取並增長(Fetch-and-Increment);
i3)交換(Swap);
i4)比較並交換(Compare-and-Swap,下文簡稱 CAS);
i5)加載連接/ 條件存儲(Load-Linked/Store-Conditional,下文簡稱 LL/SC);

2.4)如何使用CAS 操做來避免阻塞同步,看個荔枝:(測試incrementAndGet 方法的原子性)

// Atomic 變量自增運算測試(incrementAndGet 方法的原子性)
public class AtomicTest {
    public static AtomicInteger race = new AtomicInteger(0);
    
    public static void increase() {
        // 輸出正確結果,一切都要歸功於 incrementAndGet 方法的原子性
        race.incrementAndGet();  
    }
    
    public static final int THREADS_COUNT = 20;
    
    public static void main(String[] args) throws Exception {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        
        while(Thread.activeCount() > 1) {
            Thread.yield();
        }
        
        System.out.println(race);
    }
    
    /**
     * incrementAndGet() 方法的JDK 源碼
     * Atomically increment 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;
            }
        }
    }
}

2.5)CAS操做(比較並交換操做)的ABA問題:若是一個變量V初次讀取的時候是A值,而且在準備賦值的時候檢查到它仍然是A值,那咱們就說它的值沒有被其餘線程改變過了嗎? 若是在這段期間它的值曾經被改成了B,以後又改回了A,那CAS操做就會誤認爲它歷來沒有被改變過,這個漏洞稱爲 CAS操做的 ABA問題;

2.6)解決方法:J.U.C 包爲了解決這個問題,提供了一個帶有標記的原子引用類「AtomicStampedReference」,它能夠經過控制變量值的version 來保證CAS的正確性。不過目前來講這個類比較雞肋, 大部分cases 下 ABA問題 不會影響程序併發的正確性,若是須要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效;(乾貨——CAS操做(比較並交換操做)的ABA問題及其解決方法) 

3)無同步方案
3.0)intro: 若是一個方法原本就不涉及共享數據,那它天然就無須任何同步措施去保證正確性,所以會有一些代碼天生就是線程安全的;下面介紹兩類線程安全代碼:
3.1)第一類線程安全代碼——可重入代碼:也叫做純代碼,能夠在代碼執行的任什麼時候刻中斷它,轉而去執行另一段代碼,而在控制權返回後,原來的程序不會出現任何錯誤; (乾貨——可重入代碼定義)
3.1.1)全部的可重入代碼都是線程安全的;
3.1.2)如何判斷代碼是否具有可重入性:若是一個方法,它的返回結果是能夠預測的,只要輸入了相同的數據,就都能返回相同的結果,那它就知足可重入性的要求,固然也就是線程安全的;
3.2)第二類線程安全代碼——線程本地存儲:若是一段代碼中所須要的數據必須與其餘代碼共享,那就看看這些共享數據的代碼是否可以保證在同一線程中執行? 若是能保證,咱們就能夠把共享數據的可見範圍限制在同一個線程內,這樣,無需同步也能夠保證線程間不出現數據爭用問題;

【3】鎖優化
【3.1】 自旋鎖與自適應自旋(乾貨——引入自旋鎖與自適應自旋)
1)problem:前文中咱們提到,互斥同步對性能最大的影響是阻塞的實現,掛起線程和恢復線程的操做都須要轉入內核態中完成,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間去掛起和恢復線程很不值得;(乾貨——產生自旋鎖與自適應自旋的背景)
2)自旋鎖定義:爲了讓線程等待,咱們只需讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖;(solution)
2.1)jdk1.6中 自旋鎖是默認開啓的,可使用 -XX:+UseSpinning 參數來開啓;
2.2)自旋等待的時間必需要有必定的限度: 若是自旋超過了限定的次數仍然沒有成功得到鎖,就應當使用傳統的方式去掛起線程了。自旋次數的默認值是10,用戶能夠用參數 -XX:PreBlockSpin 來更改;
2.3)自適應自旋鎖:jdk1.6 中引入了自適應的自旋鎖。自適應意味着自旋的時間再也不固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定;
case1)若是在同一個鎖對象上,自旋等待剛剛成功得到過鎖,而且持有鎖的線程正在運行中,那麼虛擬機就會認爲此次自旋也頗有可能再次成功,進而它將容許自旋等待持續相對更長的時間,好比100個cycle;
case2)若是對於某個鎖,自旋不多成功得到過, 那在之後要獲取這個鎖時將可能省略掉自旋過程,以免浪費處理器資源;
 
【3.2】鎖消除
1)定義:鎖消除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,可是被檢查到不可能存在共享數據競爭的鎖進行消除;(乾貨——引入鎖消除的概念)
2)鎖消除的主要斷定依據:來源於逃逸分析的數據支持;若是斷定在一段代碼中,堆上的全部數據都不會逃逸出去從而被其餘線程訪問到,那就能夠把它們當作棧上數據對待,認爲它們是線程私有的,同步加鎖天然就無須進行了;
3)problem+solution
3.1)problem:程序員本身應該很清楚,怎麼會在明知道不存在數據爭用的case下還要求同步呢?
3.2)solution:許多同步措施並非程序員本身加入的,同步的代碼在java程序中的廣泛程度早就超過了大部分人的想象;(乾貨——許多同步措施並非程序員本身加入的)
3.3)看個荔枝:這段code 僅僅是輸出3個字符串相加的結果,不管是源碼字面上仍是程序語義上都沒有同步;(乾貨——鎖消除的荔枝)

public class LockEliminateTest {
    
    // raw code
    public String concatString(String s1, String s2, String s3) {
        return s1 + s2 + s3;
    }
    
    // javac 轉化後的字符串鏈接操做
    public String concatString(String s1, String s2, String s3) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        sb.append(s3);
        return sb.toString();
    }
}

 對以上代碼的分析(Analysis):

A1)對於 javac 轉化後的字符串鏈接操做代碼: 使用了同步,由於StringBuffer.append() 方法中都有一個同步塊,鎖就是sb對象。虛擬機觀察變量sb,很快就會發現他的動態做用域被限制在 concatString() 方法內部;也就是所 sb 的全部引用都不會逃逸到方法以外;

A2)因此,雖然這裏有鎖,可是能夠被安全地消除掉,在即時編譯以後,這段代碼就會忽略掉全部的同步而直接執行了;

【3.3】鎖粗化
1)problem:若是一系列的連續操做都對同一個對象反覆加鎖和解鎖,甚至加鎖操做是出如今循環體中的,那即便沒有線程競爭,頻繁地進行互斥同步操做也會致使沒必要要的性能損耗;
2)鎖粗化的定義:若是虛擬機探測到有這樣一串零碎的操做都對同一個對象加鎖,將會把加鎖同步的範圍擴展(粗化)到整個操做序列的外部;
3)看個荔枝:如下面的代碼爲例,就是擴展到第一個 append() 操做前直到最後一個 append()操做以後,這樣只須要加鎖一次就能夠了;  
// javac 轉化後的字符串鏈接操做
    public String concatString(String s1, String s2, String s3) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        sb.append(s3);
        return sb.toString();
    }
【3.4】輕量級鎖
1)重量級鎖定義:使用操做系統互斥量來實現的傳統鎖;
2)輕量級鎖的目的:是在沒有多線程競爭的前提下,減小傳統的重量級鎖使用操做系統互斥量產生的性能消耗;(乾貨——輕量級鎖的做用)
3)HotSpot虛擬機的對象頭分爲兩部分信息:(乾貨——HotSpot虛擬機的對象頭分爲兩部分信息)
3.1)第一部分:用於存儲對象自身的運行時數據,如哈希碼,GC分代年齡等;這部分數據的長度在32位和64位的虛擬機中分別爲 32bit 和 64bit,官方稱它爲 Mark Word,它是實現輕量級鎖和偏向鎖的關鍵;(乾貨——Mark Word 是實現輕量級鎖和偏向鎖的關鍵)
3.2)第二部分:用於存儲指向方法區對象類型數據的指針,若是是數組對象的話,還會有一個額外的部分用於存儲數組長度;
3.3)對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Word 被設計成一個非固定的數據結構以便在極小的空間內存儲儘可能多的信息,它會工具對象的狀態複用本身的存儲空間;
3.4)HotSpot 虛擬機對象頭Mark Word 以下圖所示:

4)在代碼進入同步塊的時候:
4.1)輕量級鎖的加鎖過程:(乾貨——輕量級鎖的加鎖過程)
step1)若是此同步對象沒有被鎖定(鎖標誌位爲01狀態):虛擬機首先將在當前線程的棧幀中創建一個名爲 鎖記錄的空間,用於存儲對象目前的Mark Word 的拷貝;
step2)而後,虛擬機將使用CAS 操做嘗試將對象的 Mark Word 更新爲指向 Lock Record的指針;
step3)若是這個更新工做成功了,那麼這個線程就擁有了該對象的鎖,而且對象Mark Word的鎖標誌位將轉變爲 00,即表示 此對象處於輕量級鎖定狀態;
step4)若是這個更新失敗了,虛擬機首先會檢查對象的Mark Word 是否指向當前線程的棧幀,若是隻說明當前線程已經擁有了這個對象的鎖,那就能夠直接進入同步塊繼續執行,不然說明這個鎖對象以及被其餘線程搶佔了。若是有兩條以上的線程爭用同一個鎖,那輕量級鎖就再也不有效,要膨脹爲重量級鎖,鎖標誌的狀態值變爲 10,Mark Word中存儲的就是指向重量級(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態;
4.2)輕量級鎖的解鎖過程:(乾貨——輕量級鎖的解鎖過程,其解鎖過程也是經過CAS 操做來進行的)
step1)若是對象的Mark Word仍然指向着線程的鎖記錄,那就用CAS 操做把對象當前的Mark Word 和 線程中複製的 Dispatched Mard Word替換回來;
step2)若是替換成功,整個同步過程就over了;
step3)若是替換失敗,說明有其餘線程嘗試過獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的線程;
Conclusion)
C1)輕量級鎖能提高程序同步性能的依據是: 對於絕大部分的鎖,在整個同步週期內都是不存在競爭的;
C2)若是沒有競爭,輕量級鎖使用CAS 操做避免了使用互斥量的開銷;但若是存在鎖競爭,除了互斥量的開銷外,還額外發生了CAS 操做,所以在有競爭的case下, 輕量級鎖會比傳統的重量級鎖更慢;
 
【3.5】偏向鎖
1)偏向鎖的目的:消除數據在無競爭狀況下的同步原語,進一步提升程序的運行性能;
2)若是說輕量級鎖是在無競爭的狀況使用CAS 操做去消除同步使用的互斥量:那偏向鎖就是在無競爭的狀況下把整個同步都消除掉,連CAS 操做都不作了;(乾貨——偏向鎖的定義)
3)偏向鎖的偏: 它的意思是這個鎖會偏向於 第一個得到它的線程,若是在接下來的執行過程當中,該鎖沒有被其餘的線程獲取,則持有偏向鎖的線程將永遠不須要再進行同步;
4)偏向鎖的原理:若當前虛擬機啓用了偏向鎖,那麼,當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設爲01, 即偏向模式;同時使用CAS 操做把獲取到這個鎖的線程的ID 記錄在對象的 Mark Word之中,若是 CAS操做成功,持有偏向鎖的線程之後每次進入這個鎖相關的同步塊時,虛擬機均可以再也不進行任何同步操做;(乾貨——偏向鎖的原理)
5)當有另外一個線程去嘗試獲取這個鎖時,偏向模式就結束了:根據鎖對象目前是否處於被鎖定的狀態, 撤銷偏向後恢復到未鎖定(標誌位爲01)或輕量級鎖定(標誌位爲00)的狀態,後續的同步操做就如上面介紹的輕量級鎖那樣執行;
Conclusion)
C1)偏向鎖能夠提升帶有同步但無競爭的程序性能;
C2)若是程序中大多數的鎖老是被多個不一樣的線程訪問:那偏向模式是多餘的;
相關文章
相關標籤/搜索