沒內鬼,來點乾貨!volatile和synchronized

題外話

這篇筆記是我《沒內鬼》系列第二篇,其實我計劃是把設計模式和多線程併發分爲兩個系列,統一叫《一塊兒學系列》來系統的介紹java

相關的知識,可是想到這篇筆記去年就寫成了,一直不發心也癢癢,因此整理一番就發出來,但願你們指正~ 面試

另外推薦我上一篇爆文沒內鬼,來點乾貨!SQL優化和診斷設計模式

一塊兒學習,一塊兒進步!緩存

volatile關鍵字

volatile關鍵字是在通常面試中常常問到的一個點,你們對它的回答莫過於兩點:安全

  • 保證內存可見性
  • 防止指令重排

那爲了更有底氣,那我們就來深刻看看吧微信

JMM內存模型

我們在聊volatile關鍵字的時候,首先須要瞭解JMM內存模型,它自己是一種抽象的概念並不真實存在,草圖以下:多線程

在這裏插入圖片描述

JMM內存模型規定了線程的工做機理:即全部的共享變量都存儲在主內存,若是線程須要使用,則拿到主內存的副本,而後操做一番,再放到主內存裏面去併發

這個能夠引起一個思考,這是否是就是多線程併發狀況下線程不安全的根源?假如全部線程都操做主內存的數據,是否是就不會有線程不安全的問題,隨即引起下面的問題app

爲何須要JMM內存模型

關於這個問題,我感受過於硬核,我只能簡單的想象假如沒有JMM,全部線程能夠直接操做主內存的數據會怎麼樣ide

  • 上文說過,JMM模型並非真實存在的,它只是一種規範,這種規範反而能夠統一開發者的行爲,若是沒有規範,可能Java所提倡的一次編譯,到處運行就涼涼了
  • 另外咱們都知道CPU 時間片輪起色制(就是在極短的時間切換進程,讓用戶無感知的享受多個進程運行的效果),線程在執行時候其實也是輪着來,假如A線程正在操做一個金錢數據,操做到一半,輪給B線程了,B線程把金額給改了,A線程最後又以錯誤的數據去入庫等等,那問題不就大了去了?

因此我想面對這樣的場景,前輩們才模仿CPU解決緩存一致性的思路肯定了JMM模型(能力不足,純屬猜想)

在多處理器系統中,每一個處理器都有本身的高速緩存,而他們又共享同一主存

volatile如何保證內存可見性

咱們來看一段代碼:

public class VolatileTest {
    static volatile String key;
    public static void main(String[] args){
        key = "Happy Birthday To Me!";
    }
}

經過對代碼進行javap命令,獲取其字節碼,內容以下(能夠忽略啦):

public class com.mine.juc.lock.VolatileTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#21         // java/lang/Object."<init>":()V
   #2 = String             #22            // Happy Birthday To Me!
   #3 = Fieldref           #4.#23         // com/mine/juc/lock/VolatileTest.key:Ljava/lang/String;
   #4 = Class              #24            // com/mine/juc/lock/VolatileTest
   #5 = Class              #25            // java/lang/Object
   #6 = Utf8               key
   #7 = Utf8               Ljava/lang/String;
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               Lcom/mine/juc/lock/VolatileTest;
  #15 = Utf8               main
  #16 = Utf8               ([Ljava/lang/String;)V
  #17 = Utf8               args
  #18 = Utf8               [Ljava/lang/String;
  #19 = Utf8               SourceFile
  #20 = Utf8               VolatileTest.java
  #21 = NameAndType        #8:#9          // "<init>":()V
  #22 = Utf8               Happy Birthday To Me!
  #23 = NameAndType        #6:#7          // key:Ljava/lang/String;
  #24 = Utf8               com/mine/juc/lock/VolatileTest
  #25 = Utf8               java/lang/Object
{
  static volatile java.lang.String key;
    descriptor: Ljava/lang/String;
    flags: ACC_STATIC, ACC_VOLATILE

  public com.mine.juc.lock.VolatileTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 11: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/mine/juc/lock/VolatileTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: ldc           #2                  // String Happy Birthday To Me!
         2: putstatic     #3                  // Field key:Ljava/lang/String;
         5: return
      LineNumberTable:
        line 16: 0
        line 17: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  args   [Ljava/lang/String;
}
SourceFile: "VolatileTest.java"

請你們注意這一段代碼:

static volatile java.lang.String key;
    descriptor: Ljava/lang/String;
    flags: ACC_STATIC, ACC_VOLATILE

能夠看到,volatile關鍵字在編譯的時候會主動爲變量增長標識:ACC_VOLATILE,再研究下去就過於硬核了(彙編指令),我可能硬不起來(手動狗頭),之後我會再對它進行深刻的研究,咱們只用瞭解到,Java關鍵字volatile,是在編譯階段主動爲變量增長了ACC_VOLATILE標識,以此保證了它的內存可見性

即然volatile能夠保證內存可見性,那至少有一個場景咱們是能夠放心使用的,即:一寫多讀場景

另外,你們在驗證volatile內存可見性的時候,不要使用 System.out.println() ,緣由以下:

public void println() {
    newLine();
}

/**
 * 是否是赫然看到一個synchronized,具體緣由見下文
 */
private void newLine() {
    try {
        synchronized (this) {
            ensureOpen();
            textOut.newLine();
            textOut.flushBuffer();
            charOut.flushBuffer();
            if (autoFlush)
                out.flush();
        }
    }
    catch (InterruptedIOException x) {
        Thread.currentThread().interrupt();
    }
    catch (IOException x) {
        trouble = true;
    }
}

爲何會有指令重排

爲了優化程序性能,編譯器和處理器會對Java編譯後的字節碼和機器指令進行重排序,在單線程狀況下不會影響結果,然而在多線程狀況下,可能會出現莫名其妙的問題,案例見下文

指令重排例子

在這裏插入圖片描述

運行這段代碼咱們可能會獲得一個匪夷所思的結果:咱們得到的單例對象是未初始化的。爲何會出現這種狀況?由於指令重排

首先要明確一點,同步代碼塊中的代碼也是可以被指令重排的。而後來看問題的關鍵

INSTANCE = new Singleton();

雖然在代碼中只有一行,編譯出的字節碼指令能夠用以下三行表示

  • 1.爲對象分配內存空間
  • 2.初始化對象
  • 3.將INSTANCE變量指向剛分配的內存地址

因爲步驟2,3交換不會改變單線程環境下的執行結果,故而這種重排序是被容許的。也就是咱們在初始化對象以前就把INSTANCE變量指向了該對象。而若是這時另外一個線程恰好執行到代碼所示的2處

if (INSTANCE == null)

那麼這時候有意思的事情就發生了:雖然INSTANCE指向了一個未被初始化的對象,可是它確實不爲null了,因此這個判斷會返回false,以後它將return一個未被初始化的單例對象!

以下:

在這裏插入圖片描述

因爲重排序是編譯器和CPU自動進行的,如何禁止指令重排?

INSTANCE變量加個volatile關鍵字就行,這樣編譯器就會根據必定的規則禁止對volatile變量的讀寫操做重排序了。而編譯出的字節碼,也會在合適的地方插入內存屏障,好比volatile寫操做以前和以後會分別插入一個StoreStore屏障和StoreLoad屏障,禁止CPU對指令的重排序越過這些屏障

即然保證了內存可見,爲何仍是線程不安全?

volatile 關鍵字雖然保證了內存可見,可是問題來了,見代碼:

index += 1;

這短短一行代碼在字節碼級別其實分爲了多個步驟進行,如獲取變量,賦值,計算等等,如CPU基本執行原理通常,真正執行的是一個個命令,分爲不少步驟

volatile 關鍵字能夠保證的是單個讀取操做是具備原子性的(每次讀取都是從主內存獲取最新的值)

可是如 index += 1; 實質是三個步驟,三次行爲,所以它沒法保證整塊代碼的原子性

synchronize關鍵字

駁斥關於類鎖的概念

首先駁斥一個關於類鎖的概念,synchronize就是對象鎖,在普通方法,靜態方法,同步塊時鎖的對象分別是:

類型 代碼示例 鎖住的對象
普通方法 synchronized void test() { } 當前對象
靜態方法 synchronized static void test() { } 鎖的是當前類的Class 對象
同步塊 void fun () { synchronized (this) {} } 鎖的是()中的對象

你們都贊成在同步代碼塊中,鎖住的是括號裏的對象,那麼見如下代碼:

public class SynDemo {

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (SynDemo.class) {
                    System.out.println("真的有所謂的類鎖?");
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();

        Thread.sleep(500);
        answer();
    }

    synchronized static void answer () {
        System.out.println("答案清楚了嗎");
    }
}

// 輸出結果
// 真的有所謂的類鎖?
// 間隔2秒多左右
// 答案清楚了嗎

因此實際上所謂的類鎖,徹底就是當前類的Class對象,因此不要被誤導,synchronize就是對象鎖

synchronize實現原理

JVM 是經過進入、退出對象監視器(Monitor 來實現對方法、同步塊的同步的

具體實現是在編譯以後在同步方法調用前加入一個 monitor.enter 指令,在退出方法和異常處插入 monitor.exit 的指令。

其本質就是對一個對象監視器 Monitor 進行獲取,而這個獲取過程具備排他性從而達到了同一時刻只能一個線程訪問的目的

而對於沒有獲取到鎖的線程將會阻塞到方法入口處,直到獲取鎖的線程 monitor.exit 以後才能嘗試繼續獲取鎖。

流程圖以下:

在這裏插入圖片描述

代碼例子:

public static void main(String[] args) {
    synchronized (Synchronize.class){
        System.out.println("Synchronize");
    }
}

字節碼:

public class com.crossoverjie.synchronize.Synchronize {
  public com.crossoverjie.synchronize.Synchronize();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // class com/crossoverjie/synchronize/Synchronize
       2: dup
       3: astore_1
       **4: monitorenter**
       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: ldc           #4                  // String Synchronize
      10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      13: aload_1
      **14: monitorexit**
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any
}

爲何會有兩次monitorexit

同步代碼塊添加了一個隱式的try-finally,在finally中會調用monitorexit命令釋放鎖,目的是爲了不異常狀況就沒法釋放鎖

synchronized鎖的幾種形式

以前你們都說千萬不要用synchronized,效率太差啦,可是Hotspot團隊對synchronized進行許多優化,提供了三種狀態的鎖:偏向鎖、輕量級鎖、重量級鎖,這樣一來synchronized性能就有了極大的提升

偏向鎖:就是鎖偏向某一個線程。主要是爲了處理同一個線程屢次獲取同一個鎖的狀況,好比鎖重入或者一個線程頻繁操做同一個線程安全的容器,可是一旦出現線程之間競爭同一個鎖,偏向鎖就會撤銷,升級爲輕量級鎖

輕量級鎖:是基於CAS操做實現的。線程使用CAS嘗試獲取鎖失敗後,進行一段時間的忙等,也就是所謂的自旋操做。嘗試一段時間仍沒法獲取鎖纔會升級爲重量級鎖

重量級鎖:是基於底層操做系統實現的,每次獲取鎖失敗都會直接讓線程掛起,這會帶來用戶態內核態的切換,性能開銷比較大

打一個比方:你們在排隊打飯,你有一個專屬通道,叫作帥哥美女專屬通道,只有你一我的能夠自由的同行,這就叫偏向鎖

忽然有一天,我來了,我也自誇帥哥,因此我盯上了你的通道,可是你還在打飯,而後我就搶過去和你一塊兒打飯,可是這樣效率比較低,因此阿姨沒問個人時候,我就玩會手機等你,這就叫輕量級鎖

忽然還有一天,我餓到不行,什麼帥哥美女通通滾蛋,就我一我的先打飯,全部阿姨爲我服務,給我服務完了再輪到大家,這就叫重量級鎖

synchronized除了上鎖還有什麼做用

  • 得到同步鎖
  • 清空工做內存
  • 從主內存中拷貝對象副本到本地內存
  • 執行代碼
  • 刷新主內存數據
  • 釋放同步鎖

這也就是上文提到的System.out.println()爲什麼會影響內存可見性的緣由了

Tips

字節碼獲取方法:

用法: javap <options> <classes>
其中, 可能的選項包括:
  -help  --help  -?        輸出此用法消息
  -version                 版本信息
  -v  -verbose             輸出附加信息
  -l                       輸出行號和本地變量表
  -public                  僅顯示公共類和成員
  -protected               顯示受保護的/公共類和成員
  -package                 顯示程序包/受保護的/公共類
                           和成員 (默認)
  -p  -private             顯示全部類和成員
  -c                       對代碼進行反彙編
  -s                       輸出內部類型簽名
  -sysinfo                 顯示正在處理的類的
                           系統信息 (路徑, 大小, 日期, MD5 散列)
  -constants               顯示最終常量
  -classpath <path>        指定查找用戶類文件的位置
  -cp <path>               指定查找用戶類文件的位置
  -bootclasspath <path>    覆蓋引導類文件的位置

最後

感謝如下博文及其做者:

面試官沒想到一個Volatile,我都能跟他扯半小時

死磕Synchronized底層實現--概論

最後的最後

文章中我留了一個小小的彩蛋,若是你能發現也證實你看的很是仔細啦

夏天到啦,加我微信,我來請你吃一根雪糕~ 僅限5.13日一天哦

在這裏插入圖片描述

相關文章
相關標籤/搜索