Java多線程之synchronized詳解


目錄

  • synchronized簡介
  • 同步的原理
  • 對象頭與鎖的實現
  • 鎖的優化與升級
  • Monitor Record
  • 鎖的對比

synchronized簡介

synchronized關鍵字,一般稱之為“同步鎖”或者重量級鎖(JAVA SE 1.6之后引入了偏向鎖輕量級鎖)。它具有可重入性.
根據鎖的鎖的“對象”不同可以分為對象鎖和類鎖:

  • 對象鎖:

    • 對於普通的同步方法,鎖的是當前實例的對象
    • 對於同步方法塊,如果synchronized括號里配置的是類的實例對象,則鎖的是配置的對象
  • 類鎖:Class對象鎖

    • 對於靜態同步方法,鎖的是當前類(具體說是當前類的Class對象)
    • 對於同步方法塊,如果synchronized括號里配置的是類的Class對象,則鎖的是當前類
      類鎖其實也鎖的是一個對象,不過是特殊的Class對象,所以類鎖並不是真實存在的。但是他們之間有不同的目的
  • 對象鎖用來控制實例方法之間的同步

  • 類鎖是用來控制靜態方法(或者靜態變量互斥體)之間的同步的。

同步的原理

JVM基於進入和退出Monitor對象來實現方法的同步和代碼塊同步。每個對象都有一個Monitor與之關聯,當其被占用就會處於鎖定的狀態。
Monitor並不是一個對象,只是習慣了這樣一個稱呼,他被保存在對象頭的Mark Word中。
在Java虛擬機(HotSpot)中,Monitor是由ObjectMonitor實現的。

代碼塊的同步

測試代碼如下:

public class SynchronizedTest {
    private void test2(){
        synchronized (this){
            System.out.println(Thread.currentThread().getName()+"獲取鎖"+this.toString());
        }
    }
}

查看編譯后的字節碼文件如下(省略部分內容):

...
 2 astore_1
 3 monitorenter  
 4 getstatic #2 <java/lang/System.out>
 ....
38 invokevirtual #11 <java/io/PrintStream.println>
41 aload_1
42 monitorexit
43 goto 51 (+8)
46 astore_2
47 aload_1
48 monitorexit
...

在編譯后的字節碼文件中出現了monitorentermonitorexit兩個指令,作用如下:

  • monitorenter指令會嘗試獲取``monitor`的所有權,即會嘗試獲取對象的鎖(保存在對象頭中)。過程如下:

    • 如果monitor的進入數位0,則該線程進入monitor,然后將進入數設置為1,該線程即為monitor的所有者。
    • 如果線程已經占有了該monitor,則是重新進入,將monitor的進入數加1.
    • 如果其他線程已經占有了monitor則該西安城進入阻塞狀態,直到monitor的進入數為0,再嘗試獲取monitor所有權
  • monitorexit指令的執行線程必須是monitor的持有者。指令執行時monitor的進入數減1,如果減1后計數器為0,
    則該線程將不再持有這個monitor,其他被這個monitor阻塞的線程可以嘗試去獲取這個 monitor 的所有權。

    • monitorexit指令出現了兩次,第1次為同步正常退出釋放鎖;第2次為發生異步退出釋放鎖;

Synchronized的底層是通過一個monitor的對象來完成,其實wait/notify等方法也依賴於monitor對象,
這就是為什么只有在同步的塊或者方法中才能調用wait/notify等方法,否則會拋出java.lang.IllegalMonitorStateException的異常的原因。

同步方法

源代碼如下

public class SynchronizedTest {
    public   synchronized  void test() {
        System.out.println(Thread.currentThread().getName()+"獲取鎖"+this.toString());
    }
}

編譯后字節碼文件如下(省略部分內容):

public synchronized void test();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
...

對於同步方法,在字節碼文件中沒有使用monitorentermonitorexit來完成同步(理論上可以),但是多了一個ACC_SYNCHRONIZED的標記,
對於靜態方法還會多出ACC_STATIC標記。JVM就是根據該標記來實現方法同步的。

當方法調用時,調用指令會檢查方法的ACC_SYNCHRONIZED訪問標記是否被設置,如果設置了執行線程將先法獲取monitor,獲取成功才能執行方法體,
方法體執行完成后釋放monitor,在方法執行期間,任何一個其他的線程都無法再獲取同一個monitor對象。

總結

兩種同步方式本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過字節碼來完成。
兩個指令的執行是JVM通過調用操作系統的互斥原語mutex來實現,
被阻塞的線程會被掛起、等待重新調度,會導致“用戶態和內核態”兩個態之間來回切換,對性能有較大影響。

對象頭與鎖的實現

JVM中,對象在內存中的布局分為三個部分:對象頭、實例數據、填充信息

  • 對象頭:Java對象頭一般占2個機器碼(在32位虛擬機中,一個機器碼占4個字節,64位機器中占8個字節),
    對於數組類型需要額外的一個機器碼來保存數組的長度,也就是需要3個機器碼。

  • 實例數據: 存放類的屬性數據信息,包括父類的屬性信息

  • 填充信息:由於虛擬機要求,對象的起始地址必須是8字節的整數倍,填充數據不是必須的,僅僅用於字節對齊

synchronized用的鎖就存放在對象頭里面。在Hospot虛擬機中,對象頭主要包括以下信息:

  • Mark Word(標記字段):用於存儲對象自身運行時的數據,他是實現偏向鎖和輕量級鎖的關鍵。
  • Class Pointer(類型指針):對象指向他的類元數據的指針,虛擬機可以通過這個指針確定對象是那個類的實例。

Mark Word用於存儲對象自身運行時的數據,如哈希碼(HashCode),GC分代信息,鎖狀態標志,線程持有的鎖,偏向線程ID,偏向時間戳等,
下圖是無所狀態下Mark Word的存儲結構:


對象頭的信息是與對象自身定義的數據無關的額外的存儲成本,考慮到虛擬機的空間效率,mark word被設計成一個非固定的數據結構,
以便在極小的空間內存儲盡量多的信息,隨着對象狀態的改變復用自己的存儲空間。當對象狀態改變時可能會變為以下四種結構:

鎖的優化與升級

JDK5引入了CAS原子操作,JDK6synchronized進行了較大的改動,包括JDK5引入的CAS自選之外,還增加了自適應的CAS自旋、
鎖消除、鎖粗化、偏向鎖、輕量級鎖等等優化策略。由於此關鍵字的的優化使得新跟那個極大提高了、同時語義清晰、操作簡單、無需手動關系,
所以推薦情況下盡量使用此關鍵字,同時在性能上此關鍵字還有優化的空間。

  • 鎖的四種狀態——無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態
  • 鎖的升級——鎖的升級是單向的、也就是說只能從低到高升級,不會出現鎖的降級。
  • JDK6中默認是開啟偏向鎖和輕量級鎖,可通過設置虛擬機參數:-XX:-UseBiasedLocking來禁用偏向鎖。

自旋鎖

讀線程可以通過三種方式實現:

  • 用戶態線程
  • 內核線程
  • 混合實現
    Java線程是通過混合實現的。因此Java線程的阻塞和喚醒需要從用戶態轉為內核態,而且對於臨界區比較小的代碼,
    對象鎖狀態只會持續很短的時間,而為此頻繁的阻塞和喚醒后續的線程是一件非常不划算的事情。因此引入了自旋鎖:
  • 自旋鎖:當一個線程嘗試獲取某個鎖時,如果該鎖已經被其他線程占用,就會一直循環檢測是否被釋放,而不是進入線程掛起或者睡眠狀態。

自旋鎖適用於保護臨界區很小的情況,臨界區很小話,所占用的時間就很短。自旋鎖索然可以避免線程切換帶來的開銷。但是CPU這段時間一直時空轉,
因此浪費了這段時間的CPU的計算能力。如果持有鎖的線程很快就釋放了鎖,那么自旋的效率就非常好,反之,自旋的線程就會白白消耗掉處理器的時間。
因此自旋的次數必須要有一個限度,如果自選超過了定義的是按仍然沒有獲取到鎖,線程就應該被掛起

自旋鎖在JDK 1.4.2中引入,默認關閉,但是可以使用-XX:+UseSpinning開開啟;
JDK1.6中默認開啟,同時自旋的默認次數為10次,可以通過參數-XX:PreBlockSpin來調整。
假如將參數調整為10,但是系統很多線程都是等你剛剛退出的時候就釋放了鎖(假如多自旋一兩次就可以獲取鎖),是不是很尷尬。
於是JDK1.6引入自適應的自旋鎖,讓虛擬機會變得越來越聰明。

自適應性自旋鎖

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

上一個線程如果通過自旋成功獲取了鎖,那么當前線程就會有很大的概率也自旋成功,所以在一定程度商會增加自旋的次數。
反之,如果對於某個鎖,很少能有線程通過自旋成功獲取鎖,那么以后有線程嘗試獲取這個鎖的時候可能會減少自選的次數甚至省略掉自旋過程,以免浪費處理器資源。

鎖消除

鎖消除是指虛擬機即時編譯器在運行時,對代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。

如果一段代碼中,堆上的數據都不會逃逸出去從而被其他線程訪問,那么就可以把他們當作線程私有的數據對待,自然加鎖也就不需要進行。

例如下面代碼:

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

上面的代碼看起來和加鎖沒有什么關系,但是String是一個不可變類,在JDK1.5之前會轉化成StringBuffer對象的連續append()
操作,在JDK1.5之后會轉化為StringBuilder都對象連續的append()操作;代碼如下:

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

StringBuffer#append()中有一個同步塊,鎖的對象就是sb。虛擬機觀察sb,很快就會發現它的動態作用域被限制在concatString()方法內部。
因此這里雖然有鎖,但是可以被安全的消除掉,在即時編譯之后,這段代碼會忽略掉所有的同步而直接執行了。

StringBuffer#append()代碼如下:

public synchronized StringBuffer append(StringBuffer sb) {
    toStringCache = null;
    super.append(sb);
    return this;
}

鎖粗化

在使用同步鎖的時候,需要讓同步塊作用范圍盡可能的小——僅在共享數據的是作用域才進行同步,
這樣做可以式臨界區內的操作盡可能的小,如果存在競爭那兒等待鎖的線程也能盡快獲得鎖。盡管這種想法是正確的,
但是如果一系列的連續的加鎖解鎖操作,可能會導致不必要要的性能損失,所以引入了鎖粗化

  • 鎖粗化:將多個連續的加鎖、解鎖的操作連接在一起,擴展成一個作用范圍更大的鎖。
public String concatString(String s1){
    StringBuffer sb=new StringBuffer();
    for(ing i=0;i<10;i++){
        sb.append(s1)
    }
    return sb.toString();
} 

sb.append()的每次擦做都需要加鎖,JVM檢測到同一個對象的連續的加鎖解鎖操作,
會將其合並成一個范圍更大鎖,加鎖加鎖的過程將會被移到for之外。

偏向鎖

HotSpot作者經過研究實踐發現,在大多數情況下,鎖不僅不存在多線程競爭,
而且總是由同一線程多次獲得,
為了讓線程獲得鎖的代價更低,引進了偏向鎖

偏向鎖是在單線程只想代碼塊時使用的機制,或者說在沒有競爭的情況下才有用
在多線程競爭的情況下(即:線程A尚未執行完同步代碼塊,線程B發出了申請鎖的申請),
則一定轉化為輕量級鎖或者重量級鎖。

引入偏向鎖的目的時:為了在沒有多縣城競爭的情況下盡量減少不必要的輕量級鎖的的執行。
因為輕量級鎖的加鎖解鎖操作時需要依賴CAS原子指令的,而偏向鎖只需要在置換ThreadId
的時候依賴一次CAS原子指令。輕量級鎖是為了在縣城交替執行同步塊時提高性能,
而偏向鎖則時在只有一個線程執行同步塊時進一步提高性能

偏向鎖獲得和撤銷

  • 偏向鎖的核心思想:一旦線程第一次獲得監視器對象,之后讓監視器對象“偏向”這個線程,
    之后多次調用則可以避免CAS操作。

當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧楨中的鎖記錄存儲偏向的線程ID
以后該線程進入和退出時不需要花費CAS操作來爭奪鎖資源,只需要檢查是否為偏向鎖、
鎖標識以及ThreadId即可,處理流程如下。

  1. 檢查Mark Word是否是可偏向狀態,即是否為偏向鎖1,鎖標識位為01;
  2. 若為可偏向狀態,則測試線程ID是否為當前線程ID,如果是,則執行第5,否則執行3
  3. 如果測試線程ID不是當前線程ID,則通過CAS操作競爭鎖,競爭成功,
    則將MarkWord的偏向線程ID替換為當前線程ID,否則執行4
  4. 通過CAS競爭失敗,證明當前存在多線程競爭的情況,當到達全局安全點,
    獲得偏向鎖的線程被掛起,偏向鎖升級為輕量級鎖,然后被阻塞在安全點的線程繼續往下執行同步代碼塊
  5. 執行同步代碼塊

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

  1. 暫停擁有偏向鎖的線程
  2. 判斷鎖對象是否還處於被鎖定的狀態,否,則恢復到無所狀態(01),以允許其他線程競爭,
    是則掛起持有鎖的當前寫線程。並將指向當前線程的鎖即可路地址的指針放入對象頭MarkWord
    升級為輕量級鎖狀態(00),然后恢復持有鎖的當前線程,進入輕量級鎖競爭模式

這里當前線程被掛起再恢復的過程中沒有發生鎖的轉移,仍然在當前線程手中,只是穿插了個“將對象頭中的線程ID
變更為指向鎖記錄地址的指針”這么個事。

流程圖如下:

偏向鎖的關閉

在JDK5中偏向鎖默認是關閉的,而JDK以后的版本中偏向鎖已經默認開啟。
但是他應用程序啟動后幾秒鍾內才會激活,如果有必要可以使用參數-XX:BiasedLockingStartupDelap=0來關閉延遲。
如果並發數較大同時同步代碼塊執行時間較長,則被多個線程同時訪問的概率就很大,
就可以使用參數-XX:-UseBiasedLocking=false來禁止偏向鎖(但這是個JVM參數,不能針對某個對象鎖來單獨設置)。

輕量級鎖

輕量級鎖考慮的是競爭鎖對象的線程不多,而且線程持有鎖的時間也不長的情景。
因為阻塞線程需要CPU從用戶態轉到內核態,代價較大,如果剛剛阻塞不久這個鎖就被釋放了,
那這個代價就有點得不償失了,因此這個時候就干脆不阻塞這個線程,讓它自旋這等待鎖釋放。
當關閉偏向鎖功能或者多個線程競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖,其步驟如下:

輕量級鎖加鎖

  1. 在線程進入同步代碼塊的時候,如果統不獨額對象沒有被鎖定(鎖標志為為01,是否是偏向鎖為0),
    則虛擬機首先在當前線程的棧中創建保存鎖對象的Mark Word的拷貝的鎖記錄(Lock Record)空間,
    官方把這個拷貝稱之為Displaced Mark Word,此時線程堆棧與對象頭的狀態如下圖
    線程堆棧
    對象頭
  2. 將對象頭中的Mark Word復制到鎖記錄(Lock Record)中
// 將Mark Word保存在鎖記錄中
lock->set_displaced_header(mark);
class BasicLock VALUE_OBJ_CLASS_SPEC {
 friend class VMStructs;
 private:
  volatile markOop _displaced_header;
 public:
  void         set_displaced_header(markOop header)   { _displaced_header = header; }
  ......  
};
  1. 拷貝成功后,虛擬機將使用CAS操作嘗試將對象Mark Word替換為指向鎖記錄
    (當前線程的Lock Record)的指針,並將Lock Record中的owner指針指向object mark word
    如果成功則指向步驟(4),否則指向步驟(5).
// lock: 指向Lock Record的指針
// obj()->mark_addr(): 鎖對象的Mark Word地址
// mark: 鎖對象的Mark Word
if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
   TEVENT (slow_enter: release stacklock) ;
   return ;
}
  1. 如果這個更新操作成功了,那么當前線程就擁有了該對象的鎖,並且Mark Word鎖標志為設置為"00",
    即標識此對象處於輕量級鎖定狀態,此時線程堆棧與對象頭的狀態如下圖:

  2. 如果這個更新操作失敗了,虛擬機首先會檢查對象Mark Word中的Lock Word是否是指向
    當前的棧楨范圍內,是則執行步驟(6),否則執行步驟(7)

  3. 如果是指向當前線程的棧楨的地址范圍則表明該線程已經獲得了這個對象的鎖,現在是重入的獲得鎖。
    但是線程在每次獲取鎖的時候都會創建鎖記錄(Lock Record)的空間。所以鎖重入的時候也會創建鎖記錄空間。
    但是除了第一次設置Displaced Mark Word,其余的設置為null

// Mark Word 處於加鎖狀態,當前線程持有的鎖(Mark Word 指向的是當前線程的棧幀地址范圍)
if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) {
  assert(lock != mark->locker(), "must not re-lock the same lock");
  assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock");
  // 鎖重入,將 Displaced Mark Word 設置為 null
  lock->set_displaced_header(NULL);
  return;
}
  1. 如果鎖對象的Mark Word中的Lock Word不是指向當前線程的棧楨范圍,則表明存在的多個線程競爭,
    當前線程會自選執行步驟(3),若自旋結束時仍未獲得鎖,輕量級鎖就要膨脹為重量級鎖,鎖標志狀態值變為“10”,
    Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,當前線程以及后面等待鎖的線程也要進入阻塞狀態
// The object header will never be displaced to this lock,
// so it does not matter what the value is, except that it
// must be non-zero to avoid looking like a re-entrant lock,
// and must not look locked either.
lock->set_displaced_header(markOopDesc::unused_mark());
ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);

輕量級鎖解鎖

解鎖過程如下:

  1. 通過CAS操作嘗試用線程中復制的Displaced Mark Word替換當前的Mark Word
  2. 如果替換成功,整個同步過程就完成了,恢復到無鎖狀態(01)
  3. 如果替換失敗,則說明有其他線程嘗試過獲取該鎖(此鎖已經膨脹),要在釋放同時喚醒被掛起的線程

整個流程如下:

重量級鎖

Synchronized是通過與對象關聯的監視器鎖(Monitor)來實現的。
但是監視器鎖本質又是依賴於底層的操作系統的Mutex Lock來實現的。
而操作系統實現線程之間的切換這就需要從用戶態轉換到核心態,這個成本非常高,
狀態之間的轉換需要相對比較長的時間,這就是為什么Synchronized效率低的原因。
因此,這種依賴於操作系統Mutex Lock所實現的鎖我們稱之為 “重量級鎖”。

鎖的變化過程

Monitor Record

Monitor Record是線程私有的數據結構,每一個線程都有一個可用的Monitor Record列表,同
時還有一個全局可用列表,每一個被鎖住的對象的Mark Word都會和一個Lock Record關聯(
對象頭的MarkWord中的Lock Word Point指向與之關聯的Lock Record的起始地址)。
同時Lock Record中有一個Owner字段存放擁有該鎖的線程ID,表示該鎖被這個線程占有。

Lock Record的數據結構

屬性 描述
Owner 初始時為NULL表示當前沒有任何線程擁有該monitor record,當線程成功擁有該鎖后保存線程唯一標識,當鎖被釋放時又設置為NULL
EntryQ 關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor record失敗的線程
RcThis 表示blocked或waiting在該monitor record上的所有線程的個數
Nest 用來實現 重入鎖的計數
HashCode 保存從對象頭拷貝過來的HashCode值(可能還包含GC age)
Candidate 用來避免不必要的阻塞或等待線程喚醒,因為每一次只有一個線程能夠成功擁有鎖,如果每次前一個釋放鎖的線程喚醒所有正在阻塞或等待的線程,會引起不必要的上下文切換(從阻塞到就緒然后因為競爭鎖失敗又被阻塞)從而導致性能嚴重下降。Candidate只有兩種可能的值0表示沒有需要喚醒的線程1表示要喚醒一個繼任線程來競爭鎖。

鎖的對比

類型 優點 缺點 使用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行同步方法相比,僅存在納秒級的差距 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 適用於一個線程訪問同步塊場景
輕量級鎖 競爭的線程不會阻塞,提高了程序的響應速度 如果始終得不到鎖競爭的線程,使用自旋會消耗cpu 追求響應時間,同步塊執行速度非常快
重量級鎖 線程競爭不使用自旋,不消耗cpu 線程阻塞,響應時間緩慢 追求吞吐量,同步塊執行速度較長

參考資料


免責聲明!

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



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