線程安全的實現方法


線程安全的實現方法

什么是線程安全?

線程安全:《JVM的高性能與最佳實踐》有說,“多個線程訪問同一個對象,如果我們不用考慮線程運行時的調度和交替執行,不用做額外的同步,或者在調用時候不用進行協調操作,調用的結果總是正確的結果,那么這個對象是線程安全的”

線程安全的對象具有以下特征:對象本身已經封裝了所有必要的正確性保障手段,對象的使用者不用考慮多線程的問題。

 

Java的線程安全定義有哪些?

1、不可變

在Java(JDK1.5之后)中,不可變的對象一定是線程安全的。無論是對象的方法還是對象的使用者,都不必采用線程安全的措施。比如使用final關鍵字,只要一個對象正確的被構建出來,那其外部的可見狀態,永遠不會被改變,也永遠不會看到它在多喝線程中不一致的狀態。如果線程共享的數據是基礎數據類型,只要在定義的時候使用final關鍵字修飾即可,如果共享的是一個對象,就需要保證對象的行為不對其產生影響才可以。比如在對象的帶有狀態的變量全部聲明為final。

擴展:為int、float等基礎類型前邊加final是該對象的值不可變,在Map、類等對象前加final,是對象的引用不可變。String類型的數據本身是不可變的。

 

2、絕對的線程安全

絕對的線程安全定義:不管運行環境如何,調用者都不需要進行額外的線程安全措施。

在javaAPI中,標注自己是安全的類,大多都不是絕對的線程安全的,比如Vector,類中屬性大多都加了synchronized,但是還達不到絕對的線程安全。

 

3、相對的線程安全

相對的線程安全就是我們通常意義上的線程安全,需要對這個對象的單獨操作是安全的不需要做額外的保障措施,但是對一些特定順序的連續調用,需要做一些額外的保障措施。大部分的線程安全類都是這樣的比如,hashTable、Vector等

 

4、線程兼容

指的是對象本事是線程不安全的,但是調用端正確的調用手段可以達到線程安全的目的,JavaAPI中大部分的類都可以達到這個目的,比如ArrayList、HashMap等

 

5、線程對立

指的是無論采取什么並發措施,都無法達到線程安全的目的,比如Thread類中的 suspend() 和 resume(),如果兩個線程同時持有一個線程的對象一個去suspend()一個去resume(),如果並發進行的話,可能會導致死鎖。

 

線程安全如何實現?

1、互斥同步(阻塞同步)

 (1)synchronized關鍵字是常見的阻塞手段,synchronized編譯之后會在代碼塊前后添加minitorenter和monitorexit指令,synchronized根據修飾的類或者對象,進行鎖定,嘗試獲取鎖,成功之后monitorenter會將鎖計數器加一,monitorexit指令會將計數器減一,為0則釋放鎖。synchronized指令對同一個線程是可重入的。線程執行完之前,會阻塞后邊的線程。

值得注意的是,Java的線程都是映射到操作系統(OS)上的,如果要阻塞或者喚醒一個線程都需要操作系統來幫忙,從用戶態轉換到核心態,很耗cpu時。對於簡單的代碼塊,狀態轉換比代碼更耗時。

(2)java.util.concurrent中的可重入鎖一樣可以說實現同步,功能與synchronized類似,一樣具有具有可重入性、互斥性,區別是一個是API層面的鎖,一個是原生語法層面的。相比synchronized,ReentrantLock添加了更多的功能,主要是三點:

  一是等待可中斷(持有鎖的線程長時間不釋放的時候,等待的線程可執行其它操作),

  二是可實現公平鎖(構造參數的boolean值代表是否公平鎖)(可根據等待鎖的時間順序依次獲取鎖,syncharonized是非公平鎖)

  三是可指定解鎖條件(ReentrantLock可綁定多個Condition,只需要lock.newCondition()即可)

兩個鎖對比,synchronized在多線程高並發的情況下,性能下降的非常嚴重,ReentranLock是最佳選擇,synchronized有很多要優化的地方

2、非阻塞同步

互斥同步是屬於悲觀鎖的並發策略,因為它總認為會出現並發問題,所以做同步措施。最新的硬件指令集提供了一個基於沖突檢測的樂觀鎖並發策略:先進行操作,如果有沒有沖突,就操作成功,如果有沖突,在進行補償(常見的是重新操作,直到成功)。

這種樂觀鎖的同步策略需要將操作和沖突檢測放在一個指令集里邊。

常見的類似指令 :

   (1)測試並設置(Test And Set)

   (2)獲取並增加(Fetch And Increment)

   (3)交換(Swap)

   (4)比較並交換(Compare And Swap)

   (5)加載鏈接/條件存儲(Load Linked/Store Conditional)

CAS指令需要三個參數(V、A、B)V是內存地址,A是舊值,B是新值,當且僅當V符合A的值時候,cpu會將新值B更新到地址V,這是連續的原子操作

JDK1.5之后,在Java的sun.misc.Unsafe提供CAS操作,如:CompareAndSwapInt()、compareAndSwapLong()等

CAS的漏洞:“ABA問題”,原值A,地址目前也是A,但是無法確認A是否是被修改過的A,還是原來的A

3、無同步方案

一些代碼天生是線程安全的,因此不需要進行線程安全的操作。如:

(1)可重入代碼:一些代碼可以運行的時候,可以中斷執行其它代碼,在獲取線程的執行權之后繼續執行,不會有錯誤。

可重入代碼有以下特征:不依賴存儲在堆上的數據和公共資源、用到的數據是參數傳入,不調用不可重入數據。

可如此判斷:一個方法,它的結果是可以預測的,輸入了相同的數據,既可以返回預測的數據。

(2)線程本地存儲:如果一段代碼必須與其它線程共享數據,我們就看看這數據能否限制在同一個線程內,如果可以的話,即可以無須同步。

 

 

JVM的鎖優化

1、自旋鎖與自適應鎖

線程的阻塞和喚醒需要CPU從用戶態轉為核心態,頻繁的阻塞和喚醒對CPU來說是一件負擔很重的工作,勢必會給系統的並發性能帶來很大的壓力。同時我們發現在許多應用上面,對象鎖的鎖狀態只會持續很短一段時間,為了這一段很短的時間頻繁地阻塞和喚醒線程是非常不值得的。

所以引入自旋鎖。 

何謂自旋鎖?

  所謂自旋鎖,就是讓該線程等待一段時間,不會被立即掛起(就是不讓前來獲取該鎖(已被占用)的線程立即阻塞),看持有鎖的線程是否會很快釋放鎖。

怎么等待呢?即執行一段無意義的循環(自旋)。自旋鎖默認自旋10次。

JDK1.6引入了自適應的自旋鎖,所謂自適應就意味着自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。

  線程如果自旋成功了,那么下次自旋的次數會更加多,因為虛擬機認為既然上次成功了,那么此次自旋也很有可能會再次成功,那么它就會允許自旋等待持續的次數更多。反之,如果對於某個鎖,很少有自旋能夠成功的,那么在以后要或者這個鎖的時候自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源。

2、鎖消除

 

為了保證數據的完整性,我們在進行操作時需要對這部分操作進行同步控制,但是在有些情況下,JVM檢測到不可能存在共享數據競爭,這是JVM會對這些同步鎖進行鎖消除。鎖消除的依據是逃逸分析的數據支持。 

 

  如果不存在競爭,為什么還需要加鎖呢?

 

  所以鎖消除可以節省毫無意義的請求鎖的時間。變量是否逃逸,對於虛擬機來說需要使用數據流分析來確定,但是對於我們程序員來說這還不清楚么?我們會在明明知道不存在數據競爭的代碼塊前加上同步嗎?但是有時候程序並不是我們所想的那樣?

 

  我們雖然沒有顯示使用鎖,但是我們在使用一些JDK的內置API時,如StringBuffer、Vector、HashTable等,這個時候會存在隱形的加鎖操作。

比如StringBuffer的append()方法,Vector的add()方法:

public void vectorTest(){
    Vector<String> vector = new Vector<String>();           
    for(int i = 0 ; i < 10 ; i++){
        vector.add(i + "");
     } 
      
    System.out.println(vector);
}

在運行這段代碼時,JVM可以明顯檢測到變量vector沒有逃逸出方法vectorTest()之外,所以JVM可以大膽地將vector內部的加鎖操作消除。

 

3、鎖粗化

 

我們知道在使用同步鎖的時候,需要讓同步塊的作用范圍盡可能小,僅在共享數據的實際作用域中才進行同步。這樣做的目的是為了使需要同步的操作數量盡可能縮小,如果存在鎖競爭,那么等待鎖的線程也能盡快拿到鎖。 

 

  在大多數的情況下,上述觀點是正確的,LZ也一直堅持着這個觀點。但是如果一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗,所以引入鎖粗化的概念。 

 

  那什么是鎖粗化?

 

就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個范圍更大的鎖。

 

  如上面實例:vector每次add的時候都需要加鎖操作,JVM檢測到對同一個對象(vector)連續加鎖、解鎖操作,會合並一個更大范圍的加鎖、解鎖操作,即加鎖解鎖操作會移到for循環之外。

4、輕量級鎖

引入輕量級鎖的主要目的是在多沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗 

當關閉偏向鎖功能或者多個線程競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖,其步驟如下:獲取鎖。

  1. 判斷當前對象是否處於無鎖狀態(hashcode、0、01),若是,則JVM首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝(官方把這份拷貝加了一個Displaced前綴,即Displaced Mark Word);否則執行步驟(3);

  2. JVM利用CAS操作嘗試將對象的Mark Word更新為指向Lock Record的指正,如果成功表示競爭到鎖,則將鎖標志位變成00(表示此對象處於輕量級鎖狀態),執行同步操作;如果失敗則執行步驟(3);

  3. 判斷當前對象的Mark Word是否指向當前線程的棧幀,如果是則表示當前線程已經持有當前對象的鎖,則直接執行同步代碼塊;否則只能說明該鎖對象已經被其他線程搶占了,這時輕量級鎖需要膨脹為重量級鎖,鎖標志位變成10,后面等待的線程將會進入阻塞狀態; 

釋放鎖輕量級鎖的釋放也是通過CAS操作來進行的,主要步驟如下:

  1. 取出在獲取輕量級鎖保存在Displaced Mark Word中的數據;

  2. 用CAS操作將取出的數據替換當前對象的Mark Word中,如果成功,則說明釋放鎖成功,否則執行(3);

  3. 如果CAS操作替換失敗,說明有其他線程嘗試獲取該鎖,則需要在釋放鎖的同時需要喚醒被掛起的線程。

  輕量級鎖能提升程序同步性能的依據是“對於絕大部分的鎖,在整個同步周期內都是不存在競爭的”,這是一個經驗數據。輕量級鎖在當前線程的棧幀中建立一個名為鎖記錄的空間,用於存儲鎖對象目前的指向和狀態。如果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。

 5、偏向鎖

 引入偏向鎖主要目的是:為了在無多線程競爭的情況下盡量減少不必要的輕量級鎖執行路徑。上面提到了輕量級鎖的加鎖解鎖操作是需要依賴多次CAS原子指令的。那么偏向鎖是如何來減少不必要的CAS操作呢?我們可以查看Mark work的結構就明白了。

只需要檢查是否為偏向鎖、鎖標識為以及ThreadID即可,處理流程如下:獲取鎖。

  1. 檢測Mark Word是否為可偏向狀態,即是否為偏向鎖1,鎖標識位為01;

  2. 若為可偏向狀態,則測試線程ID是否為當前線程ID,如果是,則執行步驟(5),否則執行步驟(3);

  3. 如果線程ID不為當前線程ID,則通過CAS操作競爭鎖,競爭成功,則將Mark Word的線程ID替換為當前線程ID,否則執行線程(4);

  4. 通過CAS競爭鎖失敗,證明當前存在多線程競爭情況,當到達全局安全點,獲得偏向鎖的線程被掛起,偏向鎖升級為輕量級鎖,然后被阻塞在安全點的線程繼續往下執行同步代碼塊;

  5. 執行同步代碼塊。 

釋放鎖偏向鎖的釋放采用了一種只有競爭才會釋放鎖的機制,線程是不會主動去釋放偏向鎖,需要等待其他線程來競爭。偏向鎖的撤銷需要等待全局安全點(這個時間點是上沒有正在執行的代碼)。

其步驟如下:

  1. 暫停擁有偏向鎖的線程,判斷鎖對象石是否還處於被鎖定狀態;

  2. 撤銷偏向蘇,恢復到無鎖狀態(01)或者輕量級鎖的狀態。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM