併發編程系列(五)—深入理解synchronized關鍵字

併發編程系列之基礎篇(五)—深入理解synchronized

前言

大家好,牧碼心今天給大家推薦一篇併發編程系列之基礎篇(五)—深入理解synchronized的文章,希望對你有所幫助。內容如下:

  • 同步鎖概要
  • synchronized 的特性
  • synchronized 的用法
  • synchronized 的實現
  • synchronized 的原理
  • synchronized 的優化

概要

併發編程中,有時候會碰到多個線程同時訪問同一個共享,可變的資源,這個資源我們稱爲臨界資源,如對象,變量,文件等數據。但由於多線程執行過程中順序不可控,會存在併發不安全的問題。所以我們需要採用同步機制,在多線程的情況下,同一時刻,只能有一個線程訪問臨界資源。
java中提供了兩種方式來實現同步互斥訪問:synchronized 和 Lock,它們的本質都是加鎖來保證互斥訪問資源,本文主要介紹synchronized 的特性,使用以及底層原理。

synchronized 的特性

  • 原子性
    所謂原子性就是指一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行
    synchronized修飾的類或對象的所有操作都是原子的,因爲在執行操作之前必須先獲得類或對象的鎖,直到執行完才能釋放,這中間的過程無法被中斷(除了已經廢棄的stop()方法),即保證了原子性

  • 可見性
    所謂可見性是指多個線程訪問一個資源時,該資源的狀態、值信息等對於其他線程都是可見的。
    synchronized和volatile都具有可見性,其中synchronized對一個類或對象實例加鎖時,一個線程如果要訪問該類或對象必須先獲得它的鎖,而這個鎖的狀態對於其他任何線程都是可見的,並且在釋放鎖之前會將對變量的修改刷新到主內存當中,保證資源變量的可見性,如果某個線程佔用了該鎖,其他線程就必須在鎖池中等待鎖的釋放。
    而volatile的實現類似,被volatile修飾的變量,每當值需要修改時都會立即更新主存,主內存是共享的,所有線程可見,所以確保了其他線程讀取到的變量永遠是最新值,保證可見性。

  • 有序性
    所謂有序性值程序執行的順序按照代碼先後執行。
    synchronized和volatile都具有有序性,Java允許編譯器和處理器對指令進行重排,但是指令重排並不會影響單線程的順序,它影響的是多線程併發執行的順序性。synchronized保證了每個時刻都只有一個線程訪問同步代碼塊,也就確定了線程執行同步代碼塊是分先後順序的,保證了有序性。

  • 可重入鎖
    所謂可重入鎖指的是一個線程擁有了鎖仍然還可以重複申請此鎖。
    synchronized和ReentrantLock都是可重入鎖。當一個線程試圖操作一個由其他線程持有的對象鎖的臨界資源時,將會處於阻塞狀態,但當一個線程再次請求自己持有對象鎖的臨界資源時,這種情況屬於重入鎖。用僞代碼表示如:

 private synchronized void method() {
       .....
       method();
       .....
    }

method()在獲取到對象鎖以後,在遞歸調用時不需要等上一次調用先釋放鎖後再獲取鎖,而是直接進入執行,這說明了synchronized的可重入性.。除了遞歸調用,調用同類的其它同步方法,調用父類同步方法,都是可重入的,前提是同一對象去調用。

synchronized 的用法

sychronized可以修復靜態方法,成員函數,也可以修飾代碼塊。但是歸根結底它上鎖的資源只有兩類:一個是對象實例,一個是類。下面我們用僞代碼來說明具體用法:

public class SynchronizedTest {
    private int i=0;
    private static  int j=0;
    // 成員函數加鎖,需要獲取當前類實例對象的鎖才能進入同步塊。否則阻塞等待;
    public synchronized void add1(){
        i++;
    }
    // 靜態方法加鎖,需要獲取當前類的鎖才能進入同步塊,
    public static synchronized void add2(){
        j++;
    }
    public void add3(){
        synchronized (this){
            // 代碼塊持有當前對象鎖,需要獲取當前類實例對象的鎖才能進入同步塊。否則阻塞等待
        }
        synchronized (SynchronizedTest.class){
            // 代碼塊持有當前類的鎖,需要獲取當前類的鎖才能進入同步塊。否則阻塞等待
        }
    }
}

首先我們知道被static修飾的靜態方法、靜態屬性都是歸類所有,同時該類的所有實例對象都可以訪問。但是普通成員屬性、成員方法是歸實例化的對象所有,必須實例化之後才能訪問,這也是爲什麼靜態方法不能訪問非靜態屬性的原因。我們明確了這些屬性、方法歸哪些所有之後就可以理解上面幾個synchronized的鎖到底是加給誰的了。

  • add1()方法沒有被static修飾,也就是說該方法是歸實例化的對象所有,那麼這個鎖就是加給SynchronizedTest類所實例化的對象。
  • add2()方法是靜態方法,歸SynchronizedTest類所有,所以這個鎖是加給SynchronizedTest類的。
  • add3()方法中兩個同步代碼塊,第一個代碼塊所鎖定的是當前對象,鎖是給當前實例化對象的;第二個代碼塊所鎖定的是SynchronizedTest.class,該鎖是加給SynchronizedTest類的。

弄清楚這些鎖是上給誰的就應該很容易懂synchronized的使用啦,只要記住要進入同步方法或同步塊必須先獲得相應的鎖纔行。那麼我下面再列舉出一個非常容易進入誤區的例子:

public class TestDemo implements Runnable {
    private static int j=0;

    @Override
    public void run() {
        for (int i=0;i<1000;i++){
            synchronized (this){
                j++;
            }
        }

    }
    public static void main(String[] args) throws Exception {
        Thread t1=new Thread(new TestDemo());
        Thread t2=new Thread(new TestDemo());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("執行結果:"+j);
    }
}

上面代碼就是用兩個線程分別對i加1000次,理論結果應該是2000,而且用了synchronized鎖住當前對象的代碼塊,保證了其線程安全性。可執行結果存在小於2000的,原因在於synchronized加鎖的是當前對象實例,但是在創建線程時卻new了兩個TestDemo實例,也就是說這個鎖是給這兩個實例加的鎖,並沒有達到同步的效果,所以纔會出現錯誤。至於爲什麼小於2000,要理解i++的過程就明白了。

synchronized 的實現分析

synchronized內置鎖是一種對象鎖(鎖的是對象而非引用),作用粒度是對象,可以用來實現對臨界資源的同步互斥訪問,是可重入的。它有2中加鎖方式:一個是對方法上鎖,一個是構造同步代碼塊。這2種方式的執行過程都是一樣。
1.執行同步塊時先獲取鎖,獲取到鎖後,鎖的計數器+1;
2.同步塊執行完成後釋放鎖。鎖的計數器-1;
3.如果獲取失敗就阻塞式等待鎖的釋放
這2種方式只是在同步塊識別方式上有所不一樣,從class字節碼文件可以表現出來,一個是通過方法flags標誌,一個是monitorenter和monitorexit指令操作。我們用javap -verbose *.class 來反編譯,來看具體看如下分析:

  • 同步方法
// 在方法上加鎖,查看反編譯後的字節碼
public class SynchronizedMethod {
	public synchronized void method() {}
}

// javap -verbose SynchronizedMethod.class 執行得到
public synchronized void method();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 11: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  this   Lcom/greekw/thread/example/SynchronizedMethod;
  • 同步代碼塊
// 在代碼塊中加鎖,並查看反編譯後的字節碼
public class SynchronizedThis {
	public void method() {
		synchronized(this) {}
	}
}
// 由javap -verbose SynchronizedThis.class 得到,然後找到method方法所在的指令塊,可以清楚的看到其實現上鎖和釋放鎖的過程
 public void method();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_1
         5: monitorexit
         6: goto          14
         9: astore_2
        10: aload_1
        11: monitorexit
        12: aload_2
        13: athrow
        14: return

從反編譯的結果可以看到

  • 同步代碼塊是由monitorenter指令和monitorexit控制鎖的獲取和釋放,但是爲什麼會有兩個monitorexit呢?其實第二個monitorexit是來處理異常的,仔細看反編譯的字節碼,正常情況下第一個monitorexit之後會執行goto指令,而該指令轉向的就是14行的return,也就是說正常情況下只會執行第一個monitorexit釋放鎖,然後返回。而如果在執行中發生了異常,第二個monitorexit就起作用了,它是由編譯器自動生成的,在發生異常時處理異常然後釋放掉鎖。
  • 同步方法是ACC_SYNCHRONIZED標誌控制,這標誌用來告訴JVM這是一個同步方法,在進入該方法之前先獲取相應的鎖,鎖的計數器加1,方法結束後計數器-1,如果獲取失敗就阻塞住,知道該鎖被釋放。

synchronized 的實現原理

在理解鎖實現原理之前先了解一下HotSpot虛擬機中,對象在內存中存儲的佈局可以分爲三塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。分佈如圖:
對象在內存中存儲的佈局圖
圖中我們可以看到對象頭,實例數據,對齊填充位等元素分佈,每個元素具體說明如下:

  • 對象頭:比如 hash碼,對象所屬的年代,對象鎖,鎖狀態標誌,偏向鎖(線程)ID,偏向時間,數組長度(數組對象)等;
  • 實例數據:即創建對象時,對象中成員變量,方法等;
  • 對齊填充:對象的大小必須是8字節的整數倍;

這裏重點分析下對象頭,它是synchronized實現鎖的基礎,因爲synchronized申請鎖、上鎖、釋放鎖都與對象頭有關。對象頭主要結構是由Mark Word 和 Class Metadata Address組成,其中Mark Word存儲對象的hashCode、鎖信息或分代年齡或GC標誌等信息,Class Metadata Address是類型指針指向對象的類元數據,JVM通過該指針確定該對象是哪個類的實例。

  • 實現原理
    synchronized是基於JVM內置鎖實現,通過內部對象Monitor(監視器鎖)實現,基於進入與退出Monitor對象實現方法與代碼塊同步,監視器鎖的實現依賴底層操作系統的Mutex lock(互斥鎖)實現,它是一個重量級鎖性能較低。當然JVM內置鎖在1.5之後版本做了重大的優化,如鎖粗化(LockCoarsening)、鎖消除(Lock Elimination)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)、適應性自旋(Adaptive Spinning)等技術來減少鎖操作的開銷,內置鎖的併發性能已經基本與Lock持平。鎖的類型和狀態在對象頭Mark Word中都有記錄,在申請鎖、鎖升級等過程中JVM都需要讀取對象的Mark Word數據。

monitor(監視器)對象存在於每個Java對象的對象頭中(存儲的指針的指向),synchronized鎖便是通過這種方式獲取鎖的,也是爲什麼Java中任意對象可以作爲鎖的原因

synchronized 鎖的優化

JDK一直在對synchronized優化,其中最大的一次優化就是在jdk6的時候,新增了兩個鎖狀態,通過鎖消除、鎖粗化、自旋鎖等方法使用各種場景,給synchronized性能帶來了很大的提升。
鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨着鎖的競爭,鎖的升級路徑是:無鎖——>偏向鎖——>輕量級鎖——>重量級鎖,鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級。下圖爲鎖的升級全過程:
鎖的升級全過程

  • 偏向鎖
    偏向鎖的作用:減少統一線程獲取鎖的代價。在大多數情況下,鎖不存在多線程競爭,總是由同一線程多次獲得,那麼此時就是偏向鎖。
    核心思想:如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word的結構也就變爲偏向鎖結構,當該線程再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程只需要檢查Mark Word的鎖標記位爲偏向鎖以及當前線程ID等於Mark Word的ThreadID即可,這樣就省去了大量有關鎖申請的操作。所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個線程申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因爲這樣場合極有可能每次申請鎖的線程都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹爲重量級鎖,而是先升級爲輕量級鎖。
  • 輕量級鎖
    輕量級鎖是由偏向鎖升級而來,當存在第二個線程申請同一個鎖對象時,偏向鎖就會立即升級爲輕量級鎖。注意這裏的第二個線程只是申請鎖,不存在兩個線程同時競爭鎖,可以是一前一後地交替執行同步塊。所以輕量級鎖所適應的場景是線程交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹爲重量級鎖。
  • 重量級鎖
    重量級鎖是由輕量級鎖升級而來,當同一時間有多個線程競爭鎖時,鎖就會被升級成重量級鎖,此時其申請鎖帶來的開銷也就變大。重量級鎖一般使用場景會在追求吞吐量,同步塊或者同步方法執行時間較長的場景。
  • 鎖消除
    消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,在JIT編譯時,對運行上下文進行掃描,去除不可能存在競爭的鎖。通過這種
    方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間,如下StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬於一個局部變量,並且不會被其他線程所使用,因此StringBuffer不可能存在共享資源競爭的情景,JVM會自動將其鎖消除
  • 鎖粗化
    鎖粗化是虛擬機對另一種極端情況的優化處理,通過擴大鎖的範圍,避免反覆加鎖和釋放鎖。
  • 自旋鎖與自適應自旋鎖
    輕量級鎖失敗後,虛擬機爲了避免線程真實地在操作系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。
    自旋鎖:許多情況下,共享數據的鎖定狀態持續時間較短,切換線程不值得,通過讓線程執行循環等待鎖的釋放,不讓出CPU。如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將線程在操作系統層面掛起,這就是自旋鎖的優化方式。但是它也存在缺點:如果鎖被其他線程長時間佔用,一直不釋放CPU,會帶來許多的性能開銷。
    自適應自旋鎖:這種相當於是對上面自旋鎖優化方式的進一步優化,它的自旋的次數不再固定,其自旋的次數由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定,這就解決了自旋鎖帶來的缺點。

參考

  • https://cloud.tencent.com/developer/article/1465413
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章