volatile的特性
volatile是Java中用於修飾變數的關鍵字,其主要是保證了該變數的可見性以及順序性,但是沒有保證原子性;其是Java中最為輕量級的同步關鍵字;
接下來我將會一步步來分析volatile關鍵字是如何在Java程式碼層面、位元組碼層面、JVM原始碼層次、彙編層面、作業系統層面、CPU層面來保證可見性和順序性的;
Java程式碼層面
當一個變數被定義為volatile之後,具備兩項特性:
- 保證此變數對所有執行緒的可見性
- 禁止指令重排序優化
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在JVM原始碼方面的運用
在JVM原始碼方面,我編譯了OpenJDK7然後利用find與grep進行全域性查詢,然後進行方法追蹤,由於涉及到大量C++的知識,我便跳過其C++程式碼追蹤,而直接看最後追蹤到的函式;
先來做一個總結,其實volatile的JVM原始碼的原理對應的是被稱為記憶體屏障來實現的;
點選檢視程式碼
static void loadload();
static void storestore();
static void loadstore();
static void storeload();
- 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協議,如此便可以輕量化的實現記憶體屏障;