聊聊synchronized關鍵字


接觸java併發編程,用得最早最多的大概就是這個synchronized關鍵字了,synchronized是基於jvm實現的,不同於juc包裏的鎖的實現。發現想要講好synchronized並不容易,因爲synchronized是jvm的東西,想要講清楚就要涉及jvm,涉及jvm就要涉及源碼,涉及源碼就要回到C++,彙編。網上大部分文章都停留在jvm這一層,少數涉及到源碼,我看源碼也花了不少時間。這裏就純當聊天,慢慢展開了(後記:比我想象的內容要多,寫的太累了,剩下的慢慢更新了)。

1、synchronized基本用法

synchronized關鍵字可用於修飾方法和代碼塊。我們舉例說明:
假設我開了一家寵物店,客人可以到店裏來擼貓,店子比較小每日只提供一隻貓擼,貓每次只能供一位客人擼。
從上面的描述中,我們知道貓就是臨界資源,需要互斥訪問,所有對貓的操作都需要進行同步,加上synchronized關鍵字。

/**
 * Created by gameloft9 on 2019/4/28.
 */
@Slf4j
public class LuMaoService {

    private static String catName;

    /**
     * 同步方法的情況
     */
    public synchronized void luMao_A(String player) {
        try {
            // 一些準備工作
            log.info("{}進店了",player);
            log.info("{}準備洗手",player);
            Thread.sleep(3000);
            log.info("{}洗手完畢",player);

            log.info("{}開始擼{}貓",player,catName);
            Thread.sleep(2000);
        } catch (InterruptedException e) {
        }

        log.info("{}貓擼完了,離開了本店",player);
    }

    /**
     * 靜態同步方法的情況
     */
    public static synchronized void getOneCat(String newCatName) {
        log.info("今日提供{}貓", newCatName);
        catName = newCatName;
    }

    /**
     * 同步塊的情況
     */
    public void luMao_B(String player) {
        // 一些準備工作
        try{
            log.info("{}進店了",player);
            log.info("{}準備洗手",player);
            Thread.sleep(3000);
            log.info("{}洗手完畢",player);
        }catch(InterruptedException e){
        }

        synchronized (this) {
            try {
                log.info("{}開始擼{}貓",player,catName);
                Thread.sleep(2000);
            } catch (InterruptedException e) {
            }

            log.info("{}貓擼完了,離開了本店",player);
        }
    }
}

然後我們來測試下,現在同時有三個線程需要訪問臨界資源:

/**
 * 模擬多線程訪問臨界資源
 * Created by gameloft9 on 2019/4/28.
 */
public class TestLuMao implements Runnable {

    private LuMaoService luMaoService;

    private String name;

    public TestLuMao(String name,LuMaoService luMaoService){
        this.name = name;
        this.luMaoService = luMaoService;
    }

    public void run() {
        luMaoService.luMao_A(name);
    }
    
    public static void main(String[] args) {
        LuMaoService luMaoService = new LuMaoService();
        LuMaoService.getOneCat("加菲貓");

        new Thread(new TestLuMao("寡姐",luMaoService)).start();
        new Thread(new TestLuMao("美國隊長",luMaoService)).start();
        new Thread(new TestLuMao("鋼鐵俠",luMaoService)).start();
    }
}

在這裏插入圖片描述
從結果中,我們可以看到,確實是互斥的訪問了臨界資源,並沒有發生互相干擾的問題。當synchronized修飾實例方法的時候,這個方法每次就只能一個線程執行。當靜態資源需要互斥訪問的時候,synchronized就用於修飾相應的靜態方法,例如這裏是獲取貓。使用同步塊也可以達到同步實例方法的目的,例如這裏的luMao_B方法。與同步方法不同的是,同步塊縮小了同步的範圍,儘可能提高程序的併發程度。以擼貓爲例,進店洗手這一步操作其實是沒有必要進行同步的,別人在擼貓的時候,我可以先去洗手,等別人擼完了我就可以直接擼了。我們在測試代碼裏,用luMao_B代替luMao_A重新運行代碼,結果如下:
在這裏插入圖片描述
對比兩者的結果,同步塊的效率是要大於同步方法的,在方法中包含一些不要同步但是又非常耗時的操作時,尤其明顯。所以工作中,建議儘量使用同步塊。

synchronized是需要對某個對象上鎖的,在同步實例方法中,上鎖的對象是實例對象。在靜態同步方法中,上鎖的是類對象,即LuMaoService.class。同步塊中,上鎖的對象必須顯示給出,例如示例代碼中的this代表實例對象自己。弄清楚了鎖對象,才能避免一些稀奇古怪的錯誤。還是上面的例子,改寫一下測試代碼,每個線程自己new一個LuMaoService,然後運行。

/**
 * 模擬多線程訪問臨界資源
 * Created by gameloft9 on 2019/4/28.
 */
public class TestLuMao implements Runnable {

    private LuMaoService luMaoService;

    private String name;

    public TestLuMao(String name,LuMaoService luMaoService){
        this.name = name;
        this.luMaoService = luMaoService;
    }

    public TestLuMao(String name){
        this.name = name;
    }

    public void run() {
        LuMaoService luMaoService = new LuMaoService();
        LuMaoService.getOneCat("加菲貓");

        luMaoService.luMao_A(name);
    }

    public static void main(String[] args) {
        new Thread(new TestLuMao("寡姐")).start();
        new Thread(new TestLuMao("美國隊長")).start();
        new Thread(new TestLuMao("鋼鐵俠")).start();
    }
}

在這裏插入圖片描述
從運行結果可以看到,他們同時擼起貓來了。問題出在哪裏呢?原來,調用的是不同的實例的同步方法,雖然還是同步方法,但是鎖卻不是同一把,當然起不了作用。在使用同步方法的時候,犯這樣的錯誤還比較少,使用同步塊的時候,不小心就很容易犯這樣的錯誤,特別是和wait、notify組合使用的時候。這樣的錯誤例子後面講wait、notify的時候會給出。

2、synchronized與volatile

volatile也是java的一個關鍵字,它用於修飾共享變量。Java 語言規範第三版中對 volatile 的定義如下: java 編程語言允許線程訪問共享變量,爲了確保共享變量能被準確和一致的更新,線程應該確保通過排他鎖單獨獲得這個變量。Java 語言提供了 volatile,在某些情況下比鎖更加方便。如果一個字段被聲明成 volatile,java 線程內存模型確保所有線程看到這個變量的值是一致的。volatile保證變量的更新,對於所有的線程都是立即可見的。

共享變量
在多個線程之間能夠被共享的變量被稱爲共享變量。共享變量包括所有的實例變量,靜態變量和數組元素。他們都被存放在堆內存中,Volatile 只作用於共享變量。

如果某個共享變量屬於臨界資源,一寫多讀,僅僅使用volatile關鍵字修飾即可。如果是多寫,那麼還需要搭配synchronized關鍵字:

/**
 * Created by gameloft9 on 2019/4/28.
 */
public class Counter {

    private volatile int count = 0;

    public synchronized void increaseByOne(){
        count += 1;
    }

    public int getCount(){
        return count;
    }
}

因爲volatile只能保證可見性,並不能保證原子性。volatile要講透徹,還需要涉及到CPU緩存的MESI協議,內存屏障等概念。本文的主題是synchronized,就不再深入了。對volatile感興趣的同學可以百度下。

3、synchronized與wait、notify

synchronized不僅僅可以用來實現互斥,當和wait、notify一起使用時還可以實現同步。有時候線程雖然進入了同步塊,但是執行的條件並不成熟,需要等待別的線程完成前提任務後才能做。典型的就是生產者消費者問題,產品隊列不滿的時候,生產者才能生產消費品並放入隊列中,否則就需要等待消費者從中取出產品消費。產品隊列不空的時候,消費者才能從中取出產品消費,否則就需要等待生產者往產品隊列放入產品。還有類似的問題就是兩個線程按順序打印AB,三個線程按順序打印ABC等等。

wait、notify的標準使用方式是:

 synchronized (lock){
      while(執行條件不滿足){
          try{
              lock.wait(500); // 讓出鎖,讓其它線程有機會獲取到鎖
          }catch(InterruptedException e){
          }
      }

      if(執行條件滿足){
          // 執行邏輯
          // 可能需要更改條件變量值
      }

      lock.notifyAll(); //通知其他線程
}

由於虛假喚醒的問題(虛假喚醒問題可以參考之前的文章LockSupport原理),我們wait應該始終放在一個while循環裏面,而不是像下面這樣:

synchronized (lock){
     if(執行條件不滿足){
          try{
              lock.wait);
          }catch(InterruptedException e){
          }
      }

      if(執行條件滿足){
          // 執行邏輯
           // 可能需要更改條件變量值
      }

      lock.notifyAll(); //通知其他線程
}

這樣的寫法由於線程被錯誤的喚醒了,但條件其實並不滿足,最終造成程序異常。
使用wait、notify的時候,一定要注意鎖對象的一致性,不然會導致莫名其妙的報錯。例如synchronized鎖住的是一個對象,而wait、notify卻使用了另外一個對象,如下面代碼所示:

  synchronized (lock){
      while(執行條件不滿足){
          try{
              wait(500); // 讓出鎖,讓其它線程有機會獲取到鎖
          }catch(InterruptedException e){
          }
      }

      if(執行條件滿足){
          // 執行邏輯
          // 可能需要更改條件變量值
      }

      notifyAll(); //通知其他線程
}

這樣的代碼運行起來會拋出異常:

Exception in thread "Thread-0" java.lang.IllegalMonitorStateException

上面synchronized鎖住的是一個lock對象,而wait、notify由於誤操作,沒有使用lock.wait()、lock.notifyAll()。它鎖住的其實是this對象,也就是我們的實例對象。因此寫代碼的時候,一定要注意synchronized、wait、notify使用的是同一個鎖對象,新手很容易犯這樣的錯誤。

另外一個需要注意的是,wait、notify必須在synchronized代碼塊裏才行,否則也會出現IllegalMonitorStateException異常。不這樣做的話會有一個“lost-wake-up”的問題,原因是線程的執行是無序的,隨時可能存在線程切換,notify可能早於wait執行,導致wait的線程永遠不可能被喚醒。具體可以參考這篇文章:阿里面試題,Java中wait()方法爲什麼要放在同步塊中?後面講synchronized底層實現的時候,會對這個問題做進一步的說明。

根據《Effective JAVA》,由於jdk 1.5以後引入了原子類(例如AtomicInteger)、Executor組件、併發集合(例如ConcurrentHashMap)和同步器(例如CountdownLatch),代碼裏很少需要自己去寫wait、notify了。所以除非真的需要,否則還是用jdk提供的這些高級工具來實現吧,例如ReentrantLock就提供了Condition對象,可以模擬wait和notify操作,如下所示:

/**
 * Created by gameloft9 on 2020/4/21.
 */
public class Client {
    private static Lock lock = new ReentrantLock();
    private static Condition isDone = lock.newCondition(); // 條件對象,通過它進行wati、 notify
    private static volatile int count = 1;

    public static void main(String[] args) throws Exception{
        new Thread(new Runnable() {
            @Override
            public void run() {
                doSomething();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                afterProcess();
            }
        }).start();

    }


    public static void doSomething() {
        lock.lock();
        try {
            System.out.println("do任務執行");
            Thread.sleep(2000); // 模擬任務執行
            count = 0;

            // 任務完成通知線程
            System.out.println("do任務執行完畢,通知");
            isDone.signal(); // 模式sychronized的notify
        } catch (Exception e) {

        } finally {
            lock.unlock();
        }
    }

    public static void afterProcess() {
        lock.lock();
        try {
            while (count != 0) {
                System.out.println("after等待");
                isDone.await(); // 模擬sychronized wait()
            }

            if (count == 0) {
                System.out.println("after任務執行");
                Thread.sleep(2000); // 模擬任務執行
            }

            System.out.println("after任務執行完畢");
        } catch (Exception e) {
        } finally {
            lock.unlock();
        }
    }
}

在這裏插入圖片描述
因此現在一般的建議是,能使用無鎖編程就不要使用重型synchronized,能用同步塊就不要用同步方法,能鎖實例就不要鎖類。

4、synchronized與interrupt

線程中斷用於線程需要中斷自己運行的時候,通過調用interrupt()實現:
如果線程是阻塞(Object.wait, Thread.join和Thread.sleep)的,則線程會自動檢測中斷,並拋出中斷異常(InterruptedException),然後將中斷信號復位。這也是爲什麼調用sleep()的時候必須要try包括並catch一下InterrupteException。

try{      
   Thread.sleep(3000); 
}catch(InterruptedException e){
 // 中斷信號已經復位,調用Thread.currentThread().isInterrupted() == false;
}

如果線程沒有被阻塞,僅僅是向當前線程發送了一箇中斷信號,線程不會幫助我們檢測中斷、拋異常和復位的,需要我們手動進行中斷檢測,在檢測到中斷後,應用可以做相應的處理。
這和synchronized有什麼關係呢?
意思就是在同步塊裏,如果沒有阻塞操作,即使當前線程調用了interrupt()操作,仍然不會中斷線程。例如:

/**
 * Created by gameloft9 on 2019/4/28.
 */
@Slf4j
public class Counter {
    private int count = 0;

    public void increase(){
       synchronized (this){
           while(true){
               count += 1;
               log.info("cont = {}",count);
           }
       }
    }
}

我們同步塊裏就循環的對count進行+1操作,然後開線程調用,然後馬上調用interrupt()中斷自己。

/**
 * Created by gameloft9 on 2019/4/28.
 */
@Slf4j
public class TestCounter implements Runnable {
    private Counter counter;

    public TestCounter(Counter counter) {
        this.counter = counter;
    }

    public void run() {
        counter.increase();
    }

    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread tmp = new Thread(new TestCounter(counter));
        tmp.start();

        tmp.interrupt(); // 調用中斷,沒什麼作用
    }
}

在這裏插入圖片描述
結果是線程並沒有因爲中斷而停止,一直不停的打印。
因此在同步塊裏要檢測出中斷信號,必須手動檢測,例如:

synchronized (this){
           while(true){
               // 手動檢測中斷
               if(Thread.currentThread().isInterrupted()){
                   // Thread.interrupted(); //根據業務需求確定是否需要清除中斷位
                   return;
               }

               count += 1;
               log.info("cont = {}",count);
           }
       }

對於上面的用法和一些條條框框,我們將從底層原理出發再做一遍解釋。

5、synchronized底層原理

synchronized原理部分應該是本文最難的部分。網上文章有很多,基本離不開三個東西:monitorenter、monitorexit兩個指令,對象頭分析,鎖分類及升級。monitorenter和monitorexit兩個指令比較簡單,javap命令看一下字節碼就明白了。另外兩個講清楚的不多,要麼是對象頭分析不完整,要麼是鎖升級講解含糊不清,所以這裏決定解決這兩個問題。

5.1、對象頭(markword)

以HotSpot-1.6 jvm爲例,synchronized底層用到了鎖,這個鎖就存在對象頭裏。對象頭是每個類都有的,因爲在jvm實現裏面,所有類都會繼承下面這個oopDesc的類,每個java類天然的就擁有鎖機制。

class oopDesc {
  friend class VMStructs;
 private:
  volatile markOop  _mark; // 這個就是對象頭,它是一個指針!並不指向實際的地址。
  union _metadata {
    wideKlassOop    _klass;
    narrowOop       _compressed_klass;
  } _metadata;
  ......

我們再來看對象頭markOop是什麼,下面是對象頭markOop的類結構。

class markOopDesc: public oopDesc {
 private:
  // 將this指針轉換爲無符號整數,後面用它和鎖標識位運算得出鎖地址。
  uintptr_t value() const { return (uintptr_t) this; }

 public:
  // 對象頭裏都是枚舉和方法,沒有字段
  // 對象頭分佈枚舉
  enum { age_bits                 = 4,
         lock_bits                = 2,
         biased_lock_bits         = 1,
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2
  };

  // 鎖標誌位值,請結合下面的圖理解
  enum { locked_value             = 0, // 輕量級鎖
         unlocked_value           = 1, // 無鎖狀態
         monitor_value            = 2, // 重量級鎖
         marked_value             = 3, // 標記該對象無效,應當被GC
         biased_lock_pattern      = 5 // 偏向鎖
  };
 ......
}

對象頭邏輯上並不是一個面向對象的類,它實際上是一個用來描述鎖相關內容的字(word),因此對象頭的指針並不指向某個實際的地址。在32位機器裏,這個word就是一個32bit的佈局。這樣我們就正式的引入對象頭的佈局(我懶得畫了,這裏借用網上的圖片- -):
在這裏插入圖片描述
根據對象頭分佈的枚舉,我們可以瞭解到每個標記位的長度,例如lock_bits=2表示鎖標記位佔2個bit,biased_lock_bits = 1表示用1bit表示是否啓用偏向鎖(要和鎖標記位組合使用),4bit分代年齡(與java內存管理有關),2bitEpoch(偏向時間戳)。每個標記位存的內容與鎖類型是息息相關的,因此對象頭的內容的分析會分散在下面鎖分類裏面。

5.2、偏向鎖

有關鎖的狀態類型和實際存儲內容是完全根據對象頭的低三位來判斷的,例如 0 0 1表示該對象沒有被上鎖,處於無鎖狀態。剩餘空間存的就是分代年齡和對象的hashCode。1 0 1表示該對象上的是一把偏向鎖,它會存下持有這把鎖的線程ID,最開始沒有線程競爭的時候線程ID是0,此時對象頭分佈如下:

0|Epoch|分代年齡|1 01

我們知道偏向鎖之所以叫偏向鎖,是因爲它偏心於第一個持有它線程。就好比我們去吃飯,首先要點蓋澆飯,然後老闆纔會去做蓋澆飯給你吃。但是如果每次我們都吃蓋澆飯,不吃別的,老闆一看我進來了,就直接給我做蓋澆飯,就省去了點餐的步驟,是不是快多了?

經研究表明,很多同步方法經常是隻有一個線程訪問的,既然都是同一個線程,那乾脆把線程ID存下來好了,下次進入同步方法,發現還是你,那恭喜直接執行吧。使用偏向鎖,避免了使用CAS原子操作來上鎖解鎖,性能得到了提高。

5.3、輕量級鎖(待更新)

如果偏向鎖遇到了競爭,那麼說明偏向鎖不適合了,那麼它會升級成輕量級的鎖。這個時候鎖標記位會被改成00,然後剩下的空間存放着指向鎖記錄的指針。

5.4、重量級鎖(待更新)

5.5、整個鎖升級流程(待更新)

6、參考文章

1-阿里面試題,Java中wait()方法爲什麼要放在同步塊中?
2-【Java併發編程實戰】—–synchronized
3-聊聊併發(一)——深入分析 Volatile 的實現原理
4-聊聊併發(二)——Java SE1.6 中的 Synchronized
5-Thread之八:interrupt中斷
6-biased-locking-in-hotspot
7-synchronized原理
8-Java – 偏向鎖、輕量級鎖、自旋鎖、重量級鎖
9-JVM同步方法之偏向鎖
10-JVM-鎖消除+鎖粗化 自旋鎖、偏向鎖、輕量級鎖 逃逸分析-30

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