1. 程式人生 > >Java並發編程學習:線程安全與鎖優化

Java並發編程學習:線程安全與鎖優化

內部 高級功能 java並發編程 9.png runnable finally 虛擬 min roman

本文參考《深入理解java虛擬機第二版》

一。什麽是線程安全?

這裏我借《Java Concurrency In Practice》裏面的話:當多個線程訪問一個對象,如果不考慮這些線程在運行時環境下的調度和交替執行,也不需要額外的同步,或者調用方進行任何其他的協調操作,調用這個對象的行為都可以獲得正確的結果,那麽這個對象是線程安全的。

我的理解:多線程訪問一個對象,任何情況下,都能保持正確行為,就是對象就是安全的。

我們可以將Java語言中各種操作共享的數據分為以下5類:不可變、 絕對線程安全、 相對線程安全、 線程兼容和線程對立。
1.不可變

不可變(Immutable)的對象一定是線程安全的,無論是對象的方法實現還是方法的調用者,都不需要再采取任何的線程安全保障措施。
Java語言中,如果共享數據是一個基本數據類型,那麽只要在定義時使用final關鍵字修飾它就可以保證它是不可變的。 如果共享數據是一個對象,那就需要保證對象的行為不會對其狀態產生任何影響才行,如果讀者還沒想明白這句話,不妨想一想java.lang.String類的對象,它是一個典型的不可變對象,我們調用它的substring()、 replace()和concat()這些方法都不會影響它原來的值,只會返回一個新構造的字符串對象。保證對象行為不影響自己狀態的途徑有很多種,其中最簡單的就是把對象中帶有狀態的變量都聲明為final,這樣在構造函數結束之後,它就是不可變的
  不可變的對象的,也就是被聲明成fianl的對象,只要被正確構建出來,在不發現this逃逸的情況下,其外部狀態永遠不會改變,永遠不會看到多個線程中處於不一致的狀態。也就是說所有對象的共享變量都聲明成final ,那麽就是安全的。

2.絕對線程安全

絕對的線程安全完全滿足Brian Goetz給出的線程安全的定義,這個定義其實是很嚴格的,一個類要達到不管運行時環境如何,調用者都不需要任何額外的同步措施

Java API中標註自己是線程安全的類,大多數都不是絕對的線程安全。
如果說java.util.Vector是一個線程安全的容器,相信所有的Java程序員對此都不會有異議,因為它的add()、 get()和size()這類方法都是被synchronized修飾的,盡管這樣效率很低,但確實是安全的。 但是,即使它所有的方法都被修飾成同步,也不意味著調用它的時候永遠都不再需要同步手段了。如果一個線程要查找i位置的變量,結果另一個線程把他刪除了,就會包異常。


技術分享圖片

拋出異常的原因:因為如果另一個線程恰好在錯誤的時間裏刪除了一個元素,導致序號i 已經不再可用的話,再用i 訪問數組就會拋出一個 ArrayIndexOutOfBoundsException

如果要保證這段代碼能夠正確執行下去,修改後的代碼為

// 對線程安全的容器 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.相對線程安全

相對的線程安全就是我們通常意義上所講的線程安全,它需要保證對這個對象單獨的操作是線程安全的,我們在調用的時候不需要做額外的保措施,但是對於一些特定順序的連續調用,就可能需要在調用端使用額外的同步手段來保證調用的正確性。
Java語言中,大部分的線程安全類都屬於這種類型,例如VectorHashTableCollectionssynchronizedCollection()方法包裝的集合等。

4.線程兼容

線程兼容是指對象本身並不是線程安全的,但是可以通過在調用端正確地使用同步手段來保證對象在並發環境中可以安全地使用,我們平常說一個類不是線程安全的,絕大多數時候指的是這一種情況。 Java API中大部分的類都是屬於線程兼容的,如與前面的VectorHashTable相對應的集合類ArrayListHashMap等。

5.線程對立

線程對立是指無論調用端是否采取了同步措施,都無法在多線程環境中並發使用的代碼。 由於Java語言天生就具備多線程特性,線程對立這種排斥多線程的代碼是很少出現的,而且通常都是有害的,應當盡量避免。
一個線程對立的例子是Thread類的suspend()和resume()方法,如果有兩個線程同時持有一個線程對象,一個嘗試去中斷線程,另一個嘗試去恢復線程,如果並發進行的話,無論調用時是否進行了同步,目標線程都是存在死鎖風險的,如果suspend()中斷的線程就是即將要執行resume()的那個線程,那就肯定要產生死鎖了。

線程安全的實現方法(備註:以下內容參考鏈接:https://www.cnblogs.com/pacoson/p/5351355.html) 1)互斥同步 1.1)互斥同步:是常見的並發正確性保障手段; 1.2)同步:是指在多個線程並發訪問共享數據時,保證共享數據在同一個時刻被一個線程使用。 1.3)互斥:互斥是實現同步的一種手段;臨界區,互斥量和信號量都是主要的互斥實現方式。因此,在這4個字裏面,互斥是因,同步是果;互斥是方法,同步是目的; 1.4)最基本的互斥同步手段就是 synchronized關鍵字:synchronized關鍵字經過 編譯之後,會在同步塊的前後分別形成 monitorenter 和 monitorexit 這個兩個字節碼指令,這兩個字節碼都需要一個 reference類型的參數來指明要鎖定和解鎖的對象;如果java程序中的synchronized明確指定了對象參數,那就是這個對象的reference;如果沒有明確指定,那就根據 synchronized修飾的實例方法還是類方法,去取對應的對象實例或Class 對象來作為鎖對象; 1.5)根據虛擬機規範的要求:在執行monitorenter指令時,如果這個對象沒有鎖定或當前線程已經擁有了那個對象的鎖,鎖的計數器加1,相應的,在執行 monitorexit 指令時會將鎖計數器減1;當計數器為0時,鎖就被釋放了; Attention)對於monitorenter 和 monitorexit 的行為描述中,有兩點需要註意: 01.synchronized同步塊對同一條線程來說是可重入的, 不會出現自己把自己鎖死的問題; 02.同步塊在已進入的線程執行完之前,會阻塞後面其他線程 的進入; 1.6)除了synchronized之外,還可以使用 java.util.concurrent 包中的重入鎖(ReentrantLock)來實現同步; 1.6.1)synchronized 和 ReentrantLock 的區別: 一個表現為 API 層面的互斥鎖(lock() 和 unlock() 方法配合 try/finally 語句塊來完成),另一個表現為 原生語法層面的互斥鎖; 1.6.2)ReentrantLock增加了一些高級功能:主要有3項:等待可中斷,可實現公平鎖, 以及鎖可以綁定多個條件;
技術分享圖片 技術分享圖片case1)等待可中斷:指當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改為處理其他事情,可中斷特性對處理執行時間非常長的同步塊很有幫助; case2)公平鎖:指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖; case3)鎖綁定多個條件:指一個 ReentrantLock對象可以同時綁定多個 Condition對象,而在 synchronized中,鎖對象的wait() 和 notify() 或 notifyAll() 方法可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外地添加一個鎖,而ReentrantLock 則無需這樣做,只需要多次調用 newCondition() 方法即可;

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

技術分享圖片

技術分享圖片對上圖的分析(Analysis):

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

A2)虛擬機在未來的性能改進中肯定也會更加偏向於原生的 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問題,改用傳統的互斥同步可能會比原子類更高效;

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虛擬機的對象頭分為兩部分信息: 3.1)第一部分:用於存儲對象自身的運行時數據,如哈希碼,GC分代年齡等;這部分數據的長度在32位和64位的虛擬機中分別為 32bit 和 64bit,官方稱它為 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)輕量級鎖的解鎖過程: 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)如果程序中大多數的鎖總是被多個不同的線程訪問:那偏向模式是多余的;

Java並發編程學習:線程安全與鎖優化