Java併發雜談(一):volatile的底層原理,從位元組碼到CPU

Huangzzzzz發表於2022-03-02

volatile的特性

volatile是Java中用於修飾變數的關鍵字,其主要是保證了該變數的可見性以及順序性,但是沒有保證原子性;其是Java中最為輕量級的同步關鍵字;
接下來我將會一步步來分析volatile關鍵字是如何在Java程式碼層面、位元組碼層面、JVM原始碼層次、彙編層面、作業系統層面、CPU層面來保證可見性和順序性的;

Java程式碼層面

當一個變數被定義為volatile之後,具備兩項特性:

  1. 保證此變數對所有執行緒的可見性
  2. 禁止指令重排序優化

volatile所保證的可見性

volatile所修飾的變數在一條執行緒修改一個變數的值的時候,新值對於其他執行緒來說是可以立即知道的;
普通變數的值線上程間傳遞的時候都是通過主記憶體去完成;

根據JMM我們可以知道,每一個執行緒其實都有它單獨的棧空間,而實際的物件其實都是存放在主記憶體中的,所以如果是普通物件的話,便會有一個棧空間的物件主記憶體中的物件存在差異的時間;而volatile所修飾的變數其保持了可見性,其會強制讓棧空間所存在的對應變數失效,然後從主記憶體強制重新整理到棧空間,如此便每次看到的都是最新的資料;

volatile所保證的禁止指令重排

Java的每一行語句其實都對應了一行或者多行位元組碼語句,而每一行位元組碼語句又對應了一行或者多行彙編語句,而每一行彙編語句又對應了一行或者多行機器指令;但是CPU的指令優化器可能會對其指令順序進行重排,優化其執行效率,但是這樣也可能會導致併發問題;而volatile便可以強制禁止優化指令重排;

volatile在位元組碼層面的運用

我們先看到以下程式碼

點選檢視程式碼
public class Main {
        static int a ;
        static volatile int b ;
        public static synchronized void change(int num) {
                num = 0;
        }

        public static void main(String[] args) {
                a = 10;
                b = 20;
                change(a);
        }
}

我們先試用javac來將java檔案編譯為class檔案,然後通過javap -v來反編譯;

點選檢視程式碼
Classfile /opt/software/java-study/Main.class
  Last modified Mar 1, 2022; size 400 bytes
  MD5 checksum c7691713c9365588495a60da768c32a6
  Compiled from "Main.java"
public class Main
  SourceFile: "Main.java"
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#20         //  java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#21         //  Main.a:I
   #3 = Fieldref           #5.#22         //  Main.b:I
   #4 = Methodref          #5.#23         //  Main.change:(I)V
   #5 = Class              #24            //  Main
   #6 = Class              #25            //  java/lang/Object
   #7 = Utf8               a
   #8 = Utf8               I
   #9 = Utf8               b
  #10 = Utf8               <init>
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               change
  #15 = Utf8               (I)V
  #16 = Utf8               main
  #17 = Utf8               ([Ljava/lang/String;)V
  #18 = Utf8               SourceFile
  #19 = Utf8               Main.java
  #20 = NameAndType        #10:#11        //  "<init>":()V
  #21 = NameAndType        #7:#8          //  a:I
  #22 = NameAndType        #9:#8          //  b:I
  #23 = NameAndType        #14:#15        //  change:(I)V
  #24 = Utf8               Main
  #25 = Utf8               java/lang/Object
{
  static int a;
    flags: ACC_STATIC

  static volatile int b;
    flags: ACC_STATIC, ACC_VOLATILE

  public Main();
    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 1: 0

  public static synchronized void change(int);
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=1, locals=1, args_size=1
         0: iconst_0      
         1: istore_0      
         2: return        
      LineNumberTable:
        line 5: 0
        line 6: 2

  public static void main(java.lang.String[]);
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: bipush        10
         2: putstatic     #2                  // Field a:I
         5: bipush        20
         7: putstatic     #3                  // Field b:I
        10: getstatic     #2                  // Field a:I
        13: invokestatic  #4                  // Method change:(I)V
        16: return        
      LineNumberTable:
        line 9: 0
        line 10: 5
        line 11: 10
        line 12: 16
}

我們仔細觀察加了volatile修飾的變數與其他變數的區別便可以看出,其主要是在flags中新增了一個**ACC_VOLATILE**;同時先進行**putstatic**指令;

volatile在JVM原始碼方面的運用

在JVM原始碼方面,我編譯了OpenJDK7然後利用find與grep進行全域性查詢,然後進行方法追蹤,由於涉及到大量C++的知識,我便跳過其C++程式碼追蹤,而直接看最後追蹤到的函式;

先來做一個總結,其實volatile的JVM原始碼的原理對應的是被稱為記憶體屏障來實現的;

點選檢視程式碼
static void     loadload();
static void     storestore();
static void     loadstore();
static void     storeload();
這四個分別對應了經常在書中看到的JSR規範中的讀寫屏障
  • LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。
  • LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。
  • StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種記憶體屏障的功能

對於volatile操作而言,其操作步驟如下:

  • 每個volatile寫入之前,插入一個StoreStore,寫入以後插入一個StoreLoad
  • 每個volatile讀取之前,插入一個LoadLoad,讀取之後插入一個LoadStore

在JVM原始碼層次而言,記憶體屏障直接起到了禁止指令重排的作用,且之後與匯流排鎖或者MESI協議配合實現了可見性;

彙編層次

在彙編層次而言,我是使用JITWatch配合hsdis進行的轉匯編,可以發現在含有volatile的變數的時候,彙編指令會有一個lock字首,而lock字首在CPU層次中自己實現了記憶體屏障的功能;

CPU層次

在x86的架構中,含有lock字首的指令擁有兩種方法實現;
一種是開銷很大的匯流排鎖,它會把對應的匯流排直接全部鎖住,如此明顯是不合理的;
所以後期intel引入了快取鎖以及mesi協議,如此便可以輕量化的實現記憶體屏障;

相關文章