【Java併發】synchronized優化

CAS

  • CAS的全稱是 Compare And Swap(比較相同再交換),是現代CPU廣泛支持的一種對內存中的共享數據進行操作的一種特殊指令
  • 作用:CAS可以將比較和交換轉換爲原子操作,這個原子操作直接由CPU保證,CAS可以保證共享變量賦值時的原子操作
  • CAS操作依賴三個值:內存中的值V、舊的預估值X、要修改的新值B,如果內存中的值V = 舊的預估值X,就將新值B保存到內存中。

CAS原理

  • Java中的AtomicInteget類就是使用CAS 和 volatile 實現無鎖併發的,通過看看AtomicInteget類的getAndIncrement()方法是如何實現來分析CAS原理,以下爲JDK部分源碼
public class AtomicInteger {
	private static final Unsafe unsafe = Unsafe.getUnsafe();

	// 存儲value在內存中的偏移量
	// 當前對象的地址加上這個偏移量得到的就是value值在內存中的地址,從而得到內存中value的值
    private static final long valueOffset;

	// 存儲的value值,volatile修飾,保證可見性
    private volatile int value;
    
	public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
}

public final class Unsafe {

	public final int getAndAddInt(Object paramObject, long paramLong, int paramInt) 
	{
		int i; // 存儲內存中的值
		do {
			i = getIntVolatile(paramObject, paramLong); // 這一步就是通過當前對象地址和偏移量得到內存中的value值
		} while (!compareAndSwapInt(paramObject, paramLong, i, i + paramInt));
		return i;
	}
}
  • getAndIncrement()方法最終調用的是Unsafe類中的getAndAddInt()方法,這個方法傳 當前AtomicInteger對象,偏移量,以及1。

  • getIntVolatile():通過當前對象地址和偏移量得到內存中的value值,賦值給i

  • compareAndSwapInt()有三個參數:內存中的值V、舊的預估值X、要修改的新值B,當內存中的值v = 舊的預估值X時,就將新值B保存到內存中,同時返回true。

  • 兩個線程競爭的場景:

    • 如果兩個線程同時調用getAndIncrement()方法
    • 線程1獲取到內存中的value值爲0,也就是說i = 0
    • 此時切換到線程2,線程2也獲取到 i = 0
    • 這時仍然是線程2執行,通過調用compareAndSwapInt()方法,內存中的value值變成了1,並返回true
    • 不滿足循環條件,線程2跳出循環,執行完畢
    • 此時切換到線程1,調用compareAndSwapInt()方法是發現,內存中的value值爲1,而預估的舊值i是0,兩個值不相等,不會更新內存中的value值,返回false
    • 滿足循環條件,繼續上面的操作,直到賦值成功
  • CAS獲取共享變量時,爲了保證該變量的可見性,需要加volatile修飾,結合CAS和volatile可以實現無鎖併發,適用於競爭不激烈,多核CPU的場景下。

    • 1、因爲沒有使用synchronized,所以線程不會陷入阻塞,這事效率提升的因素之一。
    • 2、但如果競爭激烈,重試必然頻繁發生,反而效率會受影響。

樂觀鎖和悲觀鎖

  • 悲觀鎖:從悲觀的角度出發。總是假設最壞的情況,每次去拿數據的時候,都認爲別人會修改,所以每次再拿數據的時候都會上鎖,這樣別人想拿這個數據的時候就會阻塞。
  • 悲觀鎖性能較差。
  • synchronized我們也稱爲悲觀鎖。JDK中的ReentrantLock也是一種悲觀鎖。
  • 樂觀鎖:從樂觀的角度出發。總是假設最好的情況,每次去拿數據的時候,都認爲別人不會修改,就算修改了也沒關係,再重試即可。所以不會上鎖,但是再更新的時候會判斷一下此期間別人有沒有去修改這個數據,如果沒人修改則更新,有人修改則重試。
  • 樂觀鎖綜合性能較好。
  • CAS這種機制就是樂觀鎖。

synchronized鎖升級過程

  • 無鎖 — 偏向鎖 —輕量級鎖 — 重量級鎖

Java對象的佈局

  • 一個Java對象包含對象頭、實例對象、對齊填充

在這裏插入圖片描述
在這裏插入圖片描述

偏向鎖

  • 偏向鎖時JDK1.6中的重要引進,因爲HotSpot作者經過研究實踐發現,在大多數情況下,鎖不僅不存在多線程競爭,而且總是由一個線程多次獲得,爲了讓線程獲得鎖的代價更低,引進了偏向鎖。
  • 偏向鎖會偏向於第一個獲得它的線程,會在對象頭存儲鎖偏向的線程ID,以後該線程進入和退出同步塊時只需要檢查是否爲偏向鎖、鎖標誌位以及ThreadID即可。
  • 不過一旦出現多個線程競爭時,必須撤銷偏向鎖,所以撤銷偏向鎖消耗的性能必須小於之前省下來的CAS原子操作的性能消耗,不然就得不償失了。

偏向鎖原理

  • 當線程第一次訪問同步代碼塊並獲取鎖時,偏向鎖處理流程如下:
    • 虛擬機將會把對象頭中的標誌位設置爲01,偏向鎖位設置爲1,即偏向鎖模式
    • 同時使用CAS操作把獲取到這個鎖的線程ID記錄在對象的Mark Work中
    • 如果CAS操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步代碼塊時,虛擬機都可以不再進行任何同步操作
    • 偏向鎖的效率高。

偏向鎖的撤銷

  • 偏向鎖的撤銷動作必須等待全局安全點
  • 暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態
  • 撤銷偏向鎖(偏向鎖位設置爲0),恢復到無鎖(標誌位爲01)或輕量級鎖(標誌位爲00)的狀態

偏向鎖的好處

  • 偏向鎖是隻有一個線程執行同步塊時進一步提高性能,適用於一個線程反覆獲得同一個鎖的情況,偏向鎖可以提高帶有同步但無競爭的程序性能。
  • 它同樣時一個帶有效益權衡性質的優化,也就是說,它並不一定總是對程序運行有立,如果成語中大多數的鎖總是被多個不同的線程訪問,比如線程池,那偏向鎖模式就是多餘的
  • 在JDK5中,偏向鎖默認是關閉的,而到了JDK6中,偏向鎖已經默認開啓,但應用程序啓動幾秒後纔會激活,可以使用 -XX:BiasedLockingStartupDelay=0設置偏向鎖延遲啓動爲0(默認是5s),如果確定應用程序中所有鎖通常情況下處於競爭狀態,可以通過-XX:-UseBiasedLocking=false參數關閉偏向鎖

輕量級鎖

  • 輕量級鎖是JDK1.6之中加入的新型鎖機制,它是相當於使用“monitor”的傳統鎖而言的,傳統鎖稱爲重量級鎖。
  • 輕量級鎖不是爲了代替重量級鎖的,它只是在某些特定的場景下性能較優
  • 目的:在多線程交替執行同步塊的情況下(當前線程執行同步代碼塊過程中,不會有其他線程去獲取鎖),引入輕量級鎖,儘量避免重量級鎖引起的性能消耗。但是如果多個線程在同一時刻進入臨界區,會導致輕量級鎖膨脹升級重量級鎖,所以輕量級鎖並不是爲了代替重量級鎖
  • 對於輕量級鎖,其性能提升的依據是對於絕大部分的鎖,在整個生命週期內都是不會存在競爭的,如果打破這個依據則除了互斥的開銷外,還有額外的CAS操作,因此在有多線程競爭的情況下,輕量級鎖比重量級鎖更慢。

輕量級鎖原理

  • 當關閉偏向鎖或多個線程競爭偏向鎖導致偏向鎖升級爲輕量級鎖,則會嘗試獲取輕量級鎖,其步驟如下:
    • 1、判斷當前對象是否處於無鎖狀態(hashcode 、0、01),如果是,則JVM首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Work的拷貝,將對象的Mark Work複製到棧幀中的Lock Record中,Lock Record中的owner指向當前對象。(鎖對象的Mark Work中的信息複製到Lock Record)
    • 2、JVM利用CAS操作嘗試將對象的Mark Work更新爲指向Lock Record的指針,如果成功表示競爭到鎖,則將鎖標誌位變成00,執行同步操作
    • 3、如果失敗,則判斷當前對象的Mark Work是否指向當前線程的棧幀,如果是,則表示當前線程已經擁有當前對象的鎖,執行同步代碼塊;否則,只能說明該鎖對象被其他線程搶佔了,這時輕量級鎖需要膨脹爲重量級鎖,標誌位變成10,後面等待的線程進入阻塞狀態。

輕量級鎖的釋放

  • 輕量級鎖的釋放也是通過CAS操作進行的,主要步驟如下:
    • 1、取出在獲取輕量級鎖保存在LockRecord中MarkWork的數。
    • 2、用CAS操作將取出的數據替換當前對象的MarkWork,如果成功,則說明釋放鎖成功。
    • 3、如果CAS操作替換失敗,說明其他線程嘗試釋放鎖,則輕量級鎖需要膨脹爲重量級鎖

自旋鎖

  • monitor實現鎖的時候,會阻塞和喚起線程,線程的阻塞和喚起需要CPU從用戶態轉換爲內核態,頻繁的阻塞和喚起對CPU來說是一件負擔很重的工作,對CPU開銷很大,切換成本很高,給系統的併發性能帶來很大的壓力。
  • 同時,虛擬機開發團隊注意到在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間阻塞和喚起線程並不值得,如果物理機上有一個以上的處理器,能讓兩個或以上的線程同時執行,我們就讓後面的線程“稍等一下”,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。
  • 爲了讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖。
  • 自旋鎖在JDK1.4.2中已經引入,但默認是關閉的,在JDK1.6中,更改爲默認開啓
  • 自旋等待不能代替阻塞,它對處理器數量有要求,且自旋等待本身雖然避免了線程切換,但他是要佔用CPU時間,因此,如果鎖被佔用時間很短,自旋等待的效果很好,反之會帶來性能上的浪費。因此,自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應該使用傳統方式去掛起線程了。
  • 自旋次數默認是10次,可以用參數-XX:ProBlockSpin來更改

適應性自旋鎖

  • 在JDK1.6中引入了自適應的自旋鎖。
  • 自適應意味着自旋的時間不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者狀態來決定
  • 如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼JVM就會任務這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間
  • 如果對於某個鎖,自旋很少成功獲得過,那以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費CPU資源

鎖消除

  • 鎖削除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行削除。
  • 鎖削除的主要判定依據來源於逃逸分析的數據支持,如果判斷到一段代碼中,在堆上的所有數據都不會逃逸出去被其他線程訪問到,那就可以把它們當作棧上數據對待,認爲它們是線程私有的,同步加鎖自然就無須進行。
  • 逃逸分析的基本行爲就是分析對象動態作用域:當一個對象在方法中被定義後,它可能被外部方法所引用,例如作爲調用參數傳遞到其他地方中,稱爲方法逃逸。
public String contactString(String s1, String s2, String s2) {
	StringBuffer stringBuffer = new StringBuffer();
	return stringBuffer.append(s1).append(s2).append(s3).toString();
}
  • 上面那段代碼,StringBuffer的append()方法是同步方法,3次append()方法相當於加了3次鎖。
  • 同步方法的鎖對象是this,也就是上面new出來的StringBuffer對象stringBuffer。
  • 當兩個線程調用contactString()方法時,兩個線程的鎖對象是不同的,也就是說,某種程度上來講,這兩個線程是不存在競爭關係的,此時加3次鎖是毫無意義的。
  • 實際上,根據逃逸分析,可以看到stringBuffer的作用域僅限在contactString()方法內,也就是說stringBuffer的所有引用永遠不會“逃逸”到concatString()方法之外,其他線程無法訪問到它,所以這裏雖然有鎖,但是可以被安全地削除掉,在即時編譯之後,這段代碼就會忽略掉所有的同步而直接執行了

鎖粗化

  • 通常情況下,爲了保證多線程間的有效併發,會要求每個線程持有鎖的時間儘可能短,但是大某些情況下,一個程序對同一個鎖不間斷、高頻地請求、同步與釋放,會消耗掉一定的系統資源,因爲鎖的講求、同步與釋放本身會帶來性能損耗,這樣高頻的鎖請求就反而不利於系統性能的優化了,雖然單次同步操作的時間可能很短。
  • JVM會探測到一連串細小的操作都使用同一個對象加鎖,將同步代碼塊的範圍放大,放到這串操作的外面,這樣就只需要加1次鎖就可以了。
  • 鎖粗化就是告訴我們任何事情都有個度,有些情況下我們反而希望把很多次鎖的請求合併成一個請求,以降低短時間內大量鎖請求、同步、釋放帶來的性能損耗。
for (int i = 0; i < 100; i++) {
	synchronized (obj) {
		// 做一些能很快完成的操作
	}
}
  • 鎖粗化後
synchronized (obj) {
	for (int i = 0; i < 100; i++) {
		// 做一些能很快完成的操作
	}
}

日常代碼中對synchronized的優化

減少synchronized的範圍

  • 同步代碼塊中儘量短,減少同步代碼塊中代碼執行的時間,減少鎖的競爭
  • 這樣輕量級鎖或自旋鎖就能保證線程安全,避免鎖升級爲重量級鎖

降低synchronized鎖的粒度

  • 將一個鎖拆分爲多個鎖,非相關的代碼塊使用兩個不同的鎖對象上鎖
    • Hashtable:get/put/remove操作使用的是同步方法,鎖對象是當前對象this,鎖定整個哈希表,一個操作正在進行時,其他操作也同時鎖定,效率低下。
    • ConcurrentHashMap:get無鎖,put操作使用同步代碼塊,鎖對象是當前桶的第一個元素,局部鎖定,只鎖定桶,對當前元素鎖定時,對其他元素不鎖定。

讀寫分離

  • 讀取時不加鎖,寫入和刪除時加鎖
  • ConcurrentHashMap,CopyOnWriteArrayList和CopyOnWriteSet

超全資料

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