深入了解volatile和Java內(nèi)存模型
前言
在本篇文章當(dāng)中,主要給大家深入介紹Volatile關(guān)鍵字和Java內(nèi)存模型。在文章當(dāng)中首先先介紹volatile的作用和Java內(nèi)存模型,然后層層遞進(jìn)介紹實現(xiàn)這些的具體原理、JVM底層是如何實現(xiàn)volatile的和JVM實現(xiàn)的匯編代碼以及CPU內(nèi)部結(jié)構(gòu),深入剖析各種計算機(jī)系統(tǒng)底層原理。本篇文章超級干,請大家坐穩(wěn)扶好,發(fā)車了?。?!本文的大致框架如下圖所示:
為什么我們需要volatile?
保證數(shù)據(jù)的可見性
假如現(xiàn)在有兩個線程分別執(zhí)行不同的代碼,但是他們有同一個共享變量flag
,其中線程updater
會執(zhí)行的代碼是將flag
從false
修改成true
,而另外一個線程reader
會進(jìn)行while
循環(huán),當(dāng)flag
為true
的時候跳出循環(huán),代碼如下:
import java.util.concurrent.TimeUnit; class Resource { public boolean flag; public void update() { flag = true; } } public class Visibility { public static void main(String[] args) throws InterruptedException { Resource resource = new Resource(); Thread thread = new Thread(() -> { System.out.println(resource.flag); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } resource.update(); }, "updater"); new Thread(() -> { System.out.println(resource.flag); while (!resource.flag) { } System.out.println("循環(huán)結(jié)束"); }, "reader").start(); thread.start(); } }
運(yùn)行上面的代碼你會發(fā)現(xiàn),reader
線程始終打印不出循環(huán)結(jié)束
,也就是說它一直在進(jìn)行while
循環(huán),而進(jìn)行while
循環(huán)的原因就是resouce.flag=false
,但是線程updater
在經(jīng)過1秒之后會進(jìn)行更新?。槭裁?code>reader線程還讀取不到呢?
這實際上就是一種可見性的問題,updater
線程更新數(shù)據(jù)之后,reader
線程看不到,在分析這個問題之間我們首先先來了解一下Java內(nèi)存模型的邏輯布局:
在上面的代碼執(zhí)行順序大致如下:
reader
線程從主內(nèi)存當(dāng)中拿到flag
變量并且存儲到線程的本地內(nèi)存當(dāng)中,進(jìn)行while
循環(huán)。- 在休眠一秒之后,
Updater
線程從主內(nèi)存當(dāng)中拷貝一份flag
保存到本地內(nèi)存當(dāng)中,然后將flag
改成true
,將其寫回到主內(nèi)存當(dāng)中。 - 但是雖然
updater
線程將flag
寫回,但是reader
線程使用的還是之前從主內(nèi)存當(dāng)中加載到工作內(nèi)存的flag
,也就是說還是false
,因此reader
線程才會一直陷入死循環(huán)當(dāng)中。
現(xiàn)在我們稍微修改一下上面的代碼,先讓reader
線程休眠一秒,然后再進(jìn)行while
循環(huán),讓updater
線程直接修改。
import java.util.concurrent.TimeUnit; class Resource { public boolean flag; public void update() { flag = true; } } public class Visibility { public static void main(String[] args) throws InterruptedException { Resource resource = new Resource(); Thread thread = new Thread(() -> { System.out.println(resource.flag); resource.update(); }, "updater"); new Thread(() -> { System.out.println(resource.flag); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } while (!resource.flag) { } System.out.println("循環(huán)結(jié)束"); }, "reader").start(); thread.start(); } }
上面的代碼就不會產(chǎn)生死循環(huán)了,我們再來分析一下上面的代碼的執(zhí)行過程:
reader
線程先休眠一秒。updater
線程直接修改flag
為true
,然后將這個值寫回主內(nèi)存。- 在
updater
寫回之后,reader
線程從主內(nèi)存獲取flag
,這個時候的值已經(jīng)更新了,因此可以跳出while
循環(huán)了,因此上面的代碼不會出現(xiàn)死循環(huán)的情況。
像這種多個線程共享同一個變量的情況的時候,就會產(chǎn)生數(shù)據(jù)可見性的問題,如果在我們的程序當(dāng)中忽略這種問題的話,很容易讓我們的并發(fā)程序產(chǎn)生BUG。如果在我們的程序當(dāng)中需要保持多個線程對某一個數(shù)據(jù)的可見性,即如果一個線程修改了共享變量,那么這個修改的結(jié)果要對其他線程可見,也就是其他線程再次訪問這個共享變量的時候,得到的是共享變量最新的值,那么在Java當(dāng)中就需要使用關(guān)鍵字volatile
對變量進(jìn)行修飾。
現(xiàn)在我們將第一個程序的共享變量flag
加上volatile
進(jìn)行修飾:
import java.util.concurrent.TimeUnit; class Resource { public volatile boolean flag; // 這里使用 volatile 進(jìn)行修飾 public void update() { flag = true; } } public class Visibility { public static void main(String[] args) throws InterruptedException { Resource resource = new Resource(); Thread thread = new Thread(() -> { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(resource.flag); resource.update(); }, "updater"); new Thread(() -> { System.out.println(resource.flag); while (!resource.flag) { } System.out.println("循環(huán)結(jié)束"); }, "reader").start(); thread.start(); } }
上面的代碼是可以執(zhí)行完成的,reader
線程不會產(chǎn)生死循環(huán),因為volatile
保證了數(shù)據(jù)的可見性。即每一個線程對volatile
修飾的變量的修改,對其他的線程是可見的,只要有線程修改了值,那么其他線程就可以發(fā)現(xiàn)。
禁止指令重排序
指令重排序介紹
首先我們需要了解一下什么是指令重排序:
int a = 0; int b = 1; int c = 1; a++; b--;
比如對于上面的代碼我們正常的執(zhí)行流程是:
- 定義一個變量
a
,并且賦值為0。 - 定義一個變量
b
,并且賦值為1。 - 定義一個變量
c
,并且賦值為1。 - 變量
a
進(jìn)行自增操作。 - 變量
b
進(jìn)行自減操作。
而當(dāng)編譯器去編譯上面的程序時,可能不是安裝上面的流程一步步進(jìn)行操作的,編譯器可能在編譯優(yōu)化之后進(jìn)行如下操作:
- 定義一個變量
c
,并且賦值為1。 - 定義一個變量
a
,并且賦值為1。 - 定義一個變量
b
,并且賦值為0。
從上面來看代碼的最終結(jié)果是沒有發(fā)生變化的,但是指令執(zhí)行的流程和指令的數(shù)目是發(fā)生變化的,編譯器幫助我們省略了一些操作,這可以讓CPU執(zhí)行更少的指令,加快程序的執(zhí)行速度。
上面就是一個比較簡單的在編譯優(yōu)化當(dāng)中指令重排和優(yōu)化的例子。
但是如果我們在語句int c = 1
前面加上volatile
時,上面的代碼執(zhí)行順序就會保證a
和b
的定義在語句volatile int c = 1;
之前,變量a
和變量b
的操作在語句volatile int c = 1;
之后。
int a = 0; int b = 1; volatile int c = 1; a++; b--;
但是volatile
并不限制到底是a
先定義還是b
先定義,它只保證這兩個變量的定義發(fā)生在用volatile
修飾的語句之前。
volatile
關(guān)鍵字會禁止JVM和處理器(CPU)對含有volatile
關(guān)鍵字修飾的變量的指令進(jìn)行重排序,但是對于volatile
前后沒有依賴關(guān)系的指令沒有禁止,也就是說編譯器只需要保證編譯之后的代碼的順序語義和正常的邏輯一樣,它可以盡可能的對代碼進(jìn)行編譯優(yōu)化和重排序!
Volatile禁止重排序使用——雙重檢查單例模式
在單例模式當(dāng)中,有一種單例模式的寫法就雙重檢查單例模式,其代碼如下:
public class DCL { // 這里沒有使用 volatile 進(jìn)行修飾 public static DCL INSTANCE; public static DCL getInstance() { // 如果單例還沒有生成 if (null == INSTANCE) { // 進(jìn)入同步代碼塊 synchronized (DCL.class) { // 因為如果兩個線程同時進(jìn)入上一個 if 語句 // 的話,那么第一個線程會 new 一個對象 // 第二個線程也會進(jìn)入這個代碼塊,因此需要重新 // 判斷是否為 null 如果不判斷的話 第二個線程 // 也會 new 一個對象,那么就破壞單例模式了 if (null == INSTANCE) { INSTANCE = new DCL(); } } } return INSTANCE; } }
上面的代碼當(dāng)中INSTANCE
是沒有使用volatile
進(jìn)行修飾的,這會導(dǎo)致上面的代碼存在問題。在分析這其中的問題之前,我們首先需要明白,在Java當(dāng)中new一個對象會經(jīng)歷以下三步:
- 步驟1:申請對象所需要的內(nèi)存空間。
- 步驟2:在對應(yīng)的內(nèi)存空間當(dāng)中,對對象進(jìn)行初始化。
- 步驟3:對INSTANCE進(jìn)行賦值。
但是因為變量INSTANCE沒有使用volatile
進(jìn)行修飾,就可能存在指令重排序,上面的三個步驟的執(zhí)行順序變成:
- 步驟1。
- 步驟3。
- 步驟2。
假設(shè)一個線程的執(zhí)行順序就是上面提到的那樣,如果線程在執(zhí)行完成步驟3之后在執(zhí)行完步驟2之前,另外一個線程進(jìn)入getInstance
,這個時候INSTANCE != null
,因此這個線程會直接返回這個對象進(jìn)行使用,但是此時第一個線程還在執(zhí)行步驟2,也就是說對象還沒有初始化完成,這個時候使用對象是不合法的,因此上面的代碼存在問題,而當(dāng)我們使用volatile
進(jìn)行修飾就可以禁止這種重排序,從而讓他按照正常的指令去執(zhí)行。
不保證原子性
原子性:一個操作要么不做要么全做,而且在做這個操作的時候其他線程不能夠插入破壞這個操作的完整性
public class AtomicTest { public static volatile int data; public static void add() { for (int i = 0; i < 10000; i++) { data++; } } public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(AtomicTest::add); Thread t2 = new Thread(AtomicTest::add); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(data); } }
上面的代碼就是兩個線程不斷的進(jìn)行data++
操作,一共會進(jìn)行20000次,但是我們會發(fā)現(xiàn)最終的結(jié)果不等于20000,因此這個可以驗證volatile
不保證原子性,如果volatile
能夠保證原子性,那么出現(xiàn)的結(jié)果會等于20000。
Java內(nèi)存模型(JMM)
JMM下的內(nèi)存邏輯結(jié)構(gòu)
我們都知道Java程序可以跨平臺運(yùn)行,之所以可以跨平臺,是因為JVM幫助我們屏蔽了這些不同的平臺和操作系統(tǒng)的差異,而內(nèi)存模型也是一樣,各個平臺是不一樣的,Java為了保證程序可以跨平臺使用,Java虛擬機(jī)規(guī)范就定義了“Java內(nèi)存模型”,規(guī)定Java應(yīng)該如何并發(fā)的訪問內(nèi)存,每一個平臺實現(xiàn)的JVM都需要遵循這個規(guī)則,這樣就可以保證程序在不同的平臺執(zhí)行的結(jié)果都是一樣的。
下圖當(dāng)中的綠色部分就是由JMM進(jìn)行控制的
JMM對Java線程和線程的工作內(nèi)存還有主內(nèi)存的規(guī)定如下:
- 共享變量存儲在主內(nèi)存當(dāng)中,每個線程都可以進(jìn)行訪問。
- 每個線程都有自己的工作內(nèi)存,叫做線程的本地內(nèi)存。
- 線程如果想操作共享內(nèi)存必須首先將共享變量拷貝一份到自己的本地內(nèi)存。
- 線程不能直接對主內(nèi)存當(dāng)中的數(shù)據(jù)進(jìn)行修改,只能直接修改自己本地內(nèi)存當(dāng)中的數(shù)據(jù),然后通過JMM的控制,將修改后的值寫回到主內(nèi)存當(dāng)中。
這里區(qū)分一下主內(nèi)存和工作內(nèi)存(線程本地內(nèi)存):
- 主內(nèi)存:主要是Java堆當(dāng)中的對象數(shù)據(jù)。
- 工作內(nèi)存:Java虛擬機(jī)棧中存儲數(shù)據(jù)的某些區(qū)域、CPU的緩存(Cache)和寄存器。
因此線程、線程的工作內(nèi)存和主內(nèi)存的交互方式的邏輯結(jié)構(gòu)大致如下圖所示:
內(nèi)存交互的操作
JMM規(guī)定了線程的工作內(nèi)存應(yīng)該如何和主內(nèi)存進(jìn)行交互,即共享變量如何從內(nèi)存拷貝到工作內(nèi)存、工作內(nèi)存如何同步回主內(nèi)存,為了實現(xiàn)這些操作,JMM定義了下面8個操作,而且這8個操作都是原子的、不可再分的,如果下面的操作不是原子的話,程序的執(zhí)行就會出錯,比如說在鎖定的時候不是原子的,那么很可能出現(xiàn)兩個線程同時鎖定一個變量的情況,這顯然是不對的??!
- lock(鎖定):作用于主內(nèi)存的變量,它把一個變量標(biāo)識為一條線程獨(dú)占的狀態(tài)。
- unlock(解鎖):作用于主內(nèi)存的變量,它把一個處于鎖定狀態(tài)的變量釋放出來,釋放后的變量才可以被其他線程鎖定。
- read(讀取):作用于主內(nèi)存的變量,它把一個變量的值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,以便隨后的load動作使用。
- load(載入):作用于工作內(nèi)存的變量,它把read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存的變量副本中。
- use(使用):作用于工作內(nèi)存的變量,它把工作內(nèi)存中一個變量的值傳遞給執(zhí)行引擎,每當(dāng)虛擬機(jī)遇到一個需要使用變量的值的字節(jié)碼指令時將會執(zhí)行這個操作。
- assign(賦值):作用于工作內(nèi)存的變量,它把一個從執(zhí)行引擎接收的值賦給工作內(nèi)存的變量,每當(dāng)虛擬機(jī)遇到一個給變量賦值的字節(jié)碼指令時執(zhí)行這個操作。
- store(存儲):作用于工作內(nèi)存的變量,它把工作內(nèi)存中一個變量的值傳送到主內(nèi)存中,以便隨后的write操作使用。
- write(寫入):作用于主內(nèi)存的變量,它把store操作從工作內(nèi)存中得到的變量的值放入主內(nèi)存的變量中。
如果需要將主內(nèi)存的變量拷貝到工作內(nèi)存,就需要順序執(zhí)行read
和load
操作,如果需要將工作內(nèi)存的值更新回主內(nèi)存,就需要順序執(zhí)行store
和writer
操作。
JMM定義了上述8條規(guī)則,但是在使用這8條規(guī)則的時候,還需要遵循下面的規(guī)則:
- 不允許read和load、store和write操作之一單獨(dú)出現(xiàn),即不允許一個變量從主內(nèi)存讀取了但工作內(nèi)存不接受,或者工作內(nèi)存發(fā)起回寫了但主內(nèi)存不接受的情況出現(xiàn)。
- 不允許一個線程丟棄它最近的assign操作,即變量在工作內(nèi)存中改變了之后必須把該變化同步回主內(nèi)存。不允許一個線程無原因地(沒有發(fā)生過任何assign操作)把數(shù)據(jù)從線程的工作內(nèi)存同步回主內(nèi)存 中。·
- 一個新的變量只能在主內(nèi)存中“誕生”,不允許在工作內(nèi)存中直接使用一個未被初始化(load或assign)的變量,換句話說就是對一個變量實施use、store操作之前,必須先執(zhí)行assign和load操作。
- 一個變量在同一個時刻只允許一條線程對其進(jìn)行l(wèi)ock操作,但lock操作可以被同一條線程重復(fù)執(zhí)行多次,多次執(zhí)行l(wèi)ock后,只有執(zhí)行相同次數(shù)的unlock操作,變量才會被解鎖。
- 如果對一個變量執(zhí)行l(wèi)ock操作,那將會清空工作內(nèi)存中此變量的值,在執(zhí)行引擎使用這個變量前,需要重新執(zhí)行l(wèi)oad或assign操作以初始化變量的值。
- 如果一個變量事先沒有被lock操作鎖定,那就不允許對它執(zhí)行unlock操作,也不允許去unlock一個被其他線程鎖定的變量。
- 對一個變量執(zhí)行unlock操作之前,必須先把此變量同步回主內(nèi)存中(執(zhí)行store、write操作)。
重排序
重排序介紹
我們在上文當(dāng)中已經(jīng)談到了,編譯器為了更好的優(yōu)化程序的性能,會對程序進(jìn)行進(jìn)行編譯優(yōu)化,在優(yōu)化的過程當(dāng)中可能會對指令進(jìn)行重排序。我們這里談到的編譯器是JIT(即時編譯器)。它JVM當(dāng)中的一個組件,它可以通過分析Java程序當(dāng)中的熱點(diǎn)代碼(經(jīng)常執(zhí)行的代碼),然后會對這段代碼進(jìn)行分析然后進(jìn)行編譯優(yōu)化,將其直接編譯成機(jī)器代碼,也就是CPU能夠直接執(zhí)行的機(jī)器碼,然后用這段代碼代替字節(jié)碼,通過這種方式來優(yōu)化程序的性能,讓程序執(zhí)行的更快。
重排序通常有以下幾種重排序方式:
- JIT編譯器對字節(jié)碼進(jìn)行優(yōu)化重排序生成機(jī)器指令。
- CPU在執(zhí)行指令的時候,CPU會在保證指令執(zhí)行時的語義不發(fā)生變化的情況下(與單線程執(zhí)行的結(jié)果相同),可以通過調(diào)整指令之間的順序,讓指令并行執(zhí)行,加快指令執(zhí)行的速度。
- 還有一種不是顯式的重排序方式,這種方式就是內(nèi)存系統(tǒng)的重排序。這是由于處理器使用緩存和讀/寫緩沖區(qū),這使得加載和存儲操作看上去可能是在亂序執(zhí)行。這并不是顯式的將指令進(jìn)行重排序,只是因為緩存的原因,讓指令的執(zhí)行看起來像亂序。
as-if-serial規(guī)則
as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執(zhí)行結(jié)果不能被改變。編譯器、處理器都必須遵守as-if-serial語義,因為如果連這都不遵守,在單線程下執(zhí)行的結(jié)果都不正確,那我們寫的程序執(zhí)行的結(jié)果都不是我們想要的,這顯然是不正確的。
1. int a = 1; 2. int b = 2; 3. int c = a + b;
比如上面三條語句,編譯器和處理器可以對第一條和第二條語句進(jìn)行重排序,但是必須保證第三條語句必須執(zhí)行在第一和第二條語句之后,因為第三條語句依賴于第一和第二條語句,重排序必須保證這種存在數(shù)據(jù)依賴關(guān)系的語句在重排序之后執(zhí)行的結(jié)果和順序執(zhí)行的結(jié)果是一樣的。
happer-before規(guī)則
重排序除了需要遵循as-if-serial規(guī)則,還需要遵循下面幾條規(guī)則,也就是說不管是編譯優(yōu)化還是處理器重排序必須遵循下面的原則:
- 程序順序原則 :線程當(dāng)中的每一個操作,happen-before線程當(dāng)中的后續(xù)操作。
- 鎖規(guī)則 :解鎖(unlock)操作必然發(fā)生在后續(xù)的同一個鎖的加鎖(lock)之前。
- volatile規(guī)則 :volatile變量的寫,先發(fā)生于讀。
- 線程啟動規(guī)則 :線程的start()方法,happen-before它的每一個后續(xù)操作。
- 線程終止規(guī)則 :線程的所有操作先于線程的終結(jié),Thread.join()方法的作用是等待 當(dāng)前執(zhí)行的線程終止。假設(shè)在線程B終止之前,修改了共享變量,線程A從線程B的 join方法成功返回后,線程B對共享變量的修改將對線程A可見。
- 線程中斷規(guī)則 :對線程 interrupt()方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生,可以通Thread.interrupted()方法檢測線程是否中斷。
- 對象終結(jié)規(guī)則 :對象的構(gòu)造函數(shù)執(zhí)行,需要先于finalize()方法的執(zhí)行。
- 傳遞性 :A先于B ,B先于C 那么A必然先于C。
總而言之,重排序必須遵循下面兩條基本規(guī)則:
- 對于會改變程序執(zhí)行結(jié)果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
- 對于不會改變程序執(zhí)行結(jié)果的重排序,JMM對編譯器和處理器不做要求(JMM允許這種重排序)。
Volatile重排序規(guī)則
下表是JMM為了實現(xiàn)volatile的內(nèi)存語義制定的volatile重排序規(guī)則,列表示第一個操作,行表示第二個操作:
是否可以重排序 | 第二個操作 | 第二個操作 | 第二個操作 |
---|---|---|---|
第一個操作 | 普通讀/寫 | volatile讀 | volatile寫 |
普通讀/寫 | Yes | Yes | No |
volatile讀 | No | No | No |
volatile寫 | Yes | No | No |
說明:
- 比如在上表當(dāng)中說明,當(dāng)?shù)诙€操作是volatile寫的時候,那么這個指令不能和前面的普通讀寫和volatile讀寫進(jìn)行重排序。
- 當(dāng)?shù)谝粋€操作是volatile讀的時候,這個指令不能和后面的普通讀寫和volatile讀寫重排序。
Volatile實現(xiàn)原理
禁止重排序?qū)崿F(xiàn)原理
內(nèi)存屏障
在了解禁止重排序如何實現(xiàn)的之前,我們首先需要了解一下內(nèi)存屏障。所謂內(nèi)存屏障就是為了保證內(nèi)存的可見性而設(shè)計的,因為重排序的存在可能會造成內(nèi)存的不可見,因此Java編譯器(JIT編譯器)在生成指令的時候為了禁止指令重排序就會在生成的指令當(dāng)中插入一些內(nèi)存屏障指令,禁止指令重排序,從而保證內(nèi)存的可見性。
屏障類型 | 指令例子 | 解釋 |
---|---|---|
LoadLoad Barrier | Load1;LoadLoad;Load2 | 確保Load1數(shù)據(jù)的加載先于Load2和后面的Load指令 |
StoreStore Barrier | Store1;StoreStore;Store2 | 確保Store1操作的數(shù)據(jù)對其他處理器可見(將Cache刷新到內(nèi)存),即這個指令的執(zhí)行要先于Store2和后面的存儲指令 |
LoadStore Barrier | Load1;LoadStore;Store2 | 確保Load1數(shù)據(jù)加載先于Store2以及后面所有存儲指令 |
StoreLoad Barrier | Store1;StoreLoad;Load2 | 確保Store1數(shù)據(jù)對其他處理器可見,也就是將這個數(shù)據(jù)從CPU的Cache刷新到內(nèi)存當(dāng)中,這個內(nèi)存屏障會讓StoreLoad前面的所有的內(nèi)存訪問指令(不管是Store還是Load)全部完成之后,才執(zhí)行Store Load后面的Load指令 |
X86當(dāng)中內(nèi)存屏障指令
現(xiàn)在處理器一般可能不會支持上面屏障指令當(dāng)中的所有指令,但是一般都會支持Store Load屏障指令,因為這個指令可以達(dá)到其他三個指令的效果,因此在實際的機(jī)器指令當(dāng)中如果想達(dá)到上面的四種指令的效果,可能不需要四個指令,像在X86當(dāng)中就主要有三個內(nèi)存屏障指令:
lfence
,這是一種Load Barrier,一種讀屏障指令,這個指令可以讓高速緩存(CPU的Cache)失效,如果需要加載數(shù)據(jù),那么就需要從內(nèi)存當(dāng)中重新加載(這樣可以加載最新的數(shù)據(jù),因為如果其他處理器修改了緩存當(dāng)中的數(shù)據(jù)的時候,這個緩存當(dāng)中的值已經(jīng)不對了,去內(nèi)存當(dāng)中重新加載就可以拿到最新的數(shù)據(jù)),這個指令其實可以達(dá)到上面指令當(dāng)中LoadLoad和指令的效果。同時這條指令不會讓這條指令之后讀操作被調(diào)度到lfence
指令之前執(zhí)行。sfence
,這是一種Store Barrier,一種寫屏障指令,這個指令可以將寫入高速緩存的數(shù)據(jù)刷新到內(nèi)存當(dāng)中,這樣內(nèi)存當(dāng)中的數(shù)據(jù)就是最新的了,數(shù)據(jù)就可以全局可見了,其他處理器就可以加載內(nèi)存當(dāng)中最新的數(shù)據(jù)。這條指令有StoreStore的效果。同時這條指令不會讓在其之后的寫操作調(diào)度到其之前執(zhí)行。- 關(guān)于以上兩點(diǎn)的描述是稍微有點(diǎn)不夠準(zhǔn)確的,在下文我們在討論Store Buffer和Invalid Queue時我們會重新修正,這里這么寫是為了能夠幫助大家理解。
mfence
,這是一種全能型的屏障,相當(dāng)于上面lfence
和sfence
兩個指令的效果,除此之外這條指令可以達(dá)到StoreLoad指令的效果,這條指令可以保證mfence
操作之前的寫操作對mfence
之后的操作全局可見。
Volatile需要的內(nèi)存屏障
為了實現(xiàn)Volatile的內(nèi)存語義,Java編譯器(JIT編譯器)在進(jìn)行編譯的時候,會進(jìn)行如下指令的插入操作(這里你可以對照前面的volatile重排序規(guī)則,然后你就理解為什么要插入下面的內(nèi)存屏障了):
- 在每個volatile寫操作的前面插入一個StoreStore屏障。
- 在每個volatile寫操作的后面插入一個StoreLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadStore屏障。
Volatile讀內(nèi)存屏障指令插入情況如下:
Volatile寫內(nèi)存屏障指令插入情況如下:
其實上面插入內(nèi)存屏障只是理論上所需要的,但是因為不同的處理器重排序的規(guī)則不一樣,因此在插入內(nèi)存屏障指令的時候需要具體問題具體分析。比如X86處理器只會對讀-寫這樣的操作進(jìn)行重排序,不會對讀-讀、讀-寫和寫-寫這樣的操作進(jìn)行重排序,因此在X86處理器進(jìn)行內(nèi)存屏障指令的插入的時候可以省略這三種情況。
根據(jù)volatile重排序的規(guī)則表,我們可以發(fā)現(xiàn)在寫-讀的情況下,只禁止了volatile寫-volatile讀
的情況:
而X86僅僅只會對寫-讀的情況進(jìn)行重排序,因此我們在插入內(nèi)存屏障的時候只需要關(guān)心volatile寫-volatile讀
這一種情況,這種情況下我們需要使用的內(nèi)存屏障指令為StoreLoad,即volatile寫-StoreLoad-volatile讀
,因此在X86當(dāng)中我們只需要在volatile寫后面加入StoreLoad內(nèi)存屏障指令即可,在X86當(dāng)中Store Load對應(yīng)的具體的指令為mfence
。
Java虛擬機(jī)源碼實現(xiàn)Volatile語義
在Java虛擬機(jī)當(dāng)中,當(dāng)對一個被volatile修飾的變量進(jìn)行寫操作的時候,在操作進(jìn)行完成之后,在X86體系結(jié)構(gòu)下,JVM會執(zhí)行下面一段代碼,從而保證volatile的內(nèi)存語義:(下面代碼來自于:hotspot/src/os_cpu/linux_x86/vm/orderAccess_linux_x86.inline.hpp)
inline void OrderAccess::fence() { // 這里判斷是不是多處理器的機(jī)器,如果是執(zhí)行下面的代碼 if (os::is_MP()) { // 這里說明了使用 lock 指令的原因 有時候使用 mfence 代價很高 // 相比起 lock 指令來說會降低程序的性能 // always use locked addl since mfence is sometimes expensive #ifdef AMD64 // 這個表示如果是 64 位機(jī)器 __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory"); #else // 如果不是64位機(jī)器 s // 32位和64位主要區(qū)別就是 寄存器不同 在64 位當(dāng)中是 rsp 在32位機(jī)器當(dāng)中是 esp __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory"); #endif } }
上面代碼主要是通過內(nèi)聯(lián)匯編代碼去執(zhí)行指令lock
,如果你不熟悉C語言和內(nèi)聯(lián)匯編的形式也沒有關(guān)系,你只需要知道JVM會執(zhí)行lock
指令,lock
指令有mfence
相同的作用,它可以實現(xiàn)StoreLoad內(nèi)存屏障的作用,可以保證執(zhí)行執(zhí)行的順序,在前文當(dāng)中我們說mfence
是用于實現(xiàn)StoreLoad內(nèi)存屏障,因為lock
指令也可以實現(xiàn)同樣的效果,而且有時候mfence
的指令可能對程序的性能影響比較大,因此JVM使用lock
指令,這樣可以提高程序的性能。如果你對X86的lock
指令有所了解的話,你可能知道lock
還可以保證使用lock
的指令具有原子性,在X86的體系結(jié)構(gòu)下就可以使用lock
實現(xiàn)自旋鎖(CAS)。
可見性實現(xiàn)原理
可見性存在的根本原因是一個線程讀,一個線程寫,一個線程寫操作對另外一個線程的讀不可見,因此我們主要分析volatile的寫操作就行,因為如果都是進(jìn)行讀操作的話,數(shù)據(jù)就不會發(fā)生變化了,也就不存在可見性的問題了。
在上文當(dāng)中我們已經(jīng)談到了Java虛擬機(jī)在執(zhí)行volatile變量的寫操作時候,會執(zhí)行lock
指令,而這個指令有mfence
的效果:
- 將執(zhí)行
lock
指令的處理器的緩存行寫回到內(nèi)存當(dāng)中,因為我們進(jìn)行了volatile數(shù)據(jù)的更新,因此我們需要將這個更新的數(shù)據(jù)寫回內(nèi)存,好讓其他處理器在訪問內(nèi)存的時候,能夠看見被修改后的值。 - 寫回內(nèi)存的操作會使在其他CPU里緩存了該內(nèi)存地址的數(shù)據(jù)無效,這些處理器如果想使用這些數(shù)據(jù)的話,就需要從內(nèi)存當(dāng)中重新加載。因為修改了volatile變量的值,但是現(xiàn)在其他處理器中的緩存(Cache)還是舊值,因此我們需要讓其他處理器緩存了這個用volatile修飾的變量的緩存行失效,那么其他處理器想要再使用這個數(shù)據(jù)的話就需要重新去內(nèi)存當(dāng)中加載,而最新的數(shù)據(jù)已經(jīng)更新到內(nèi)存當(dāng)中了。
深入內(nèi)存屏障——Store Buffer和Invalid Queue
在前面我們提到了lock
指令,lock
指令可保證其他CPU當(dāng)中緩存了volatile變量的緩存行無效。這是因為當(dāng)處理器修改數(shù)據(jù)之后會在總線上發(fā)送消息說改動了這個數(shù)據(jù),而其他處理器會通過總線嗅探的方式在總線上發(fā)現(xiàn)這個改動消息,然后將對應(yīng)的緩存行置為無效。
這其實是處理器在處理共享數(shù)據(jù)時保證緩存數(shù)據(jù)一致性(Cache coherence)的協(xié)議,比如說Intel的MESI協(xié)議,在這個協(xié)議之下緩存行有以下四種狀態(tài):
- 已修改Modified (M) 緩存行是臟的(dirty),與主存的值不同。如果別的CPU內(nèi)核要讀主存這塊數(shù)據(jù),該緩存行必須回寫到主存,狀態(tài)變?yōu)楣蚕?S).
- 獨(dú)占Exclusive (E)緩存行只在當(dāng)前緩存中,但是干凈的(clean)緩存數(shù)據(jù)和主存數(shù)據(jù)相同。當(dāng)別的緩存讀取它時,狀態(tài)變?yōu)楣蚕恚划?dāng)寫數(shù)據(jù)時,變?yōu)橐研薷臓顟B(tài)。
- 共享Shared (S)緩存行也存在于其它緩存中且是干凈的。緩存行可以在任意時刻拋棄。
- 無效Invalid (I)緩存行是無效的。
- 因為MESI協(xié)議涉及的內(nèi)容還是比較多的,如果你想仔細(xì)了解MESI協(xié)議,請看文末,這里就不詳細(xì)說明了!
假設(shè)在某個時刻,CPU的多個核心共享一個內(nèi)存數(shù)據(jù),其中一個一個核心想要修改這個數(shù)據(jù),那么他就會通過總線給其他核心發(fā)送消息表示想要修改這個數(shù)據(jù),然后其他核心將這個數(shù)據(jù)修改為Invalid狀態(tài),再給修改數(shù)據(jù)的核心發(fā)送一個消息,表示已經(jīng)收到這個消息,然后這個修改數(shù)據(jù)的核心就會將這個數(shù)據(jù)的狀態(tài)設(shè)置為Modified。
在上面的例子當(dāng)中當(dāng)一個核心給其他CPU發(fā)送消息時需要等待其他CPU給他返回確認(rèn)消息,這顯然會降低CPU的性能,為了能夠提高CPU處理數(shù)據(jù)的性能,硬件工程師做了一層優(yōu)化,在CPU當(dāng)中加了一個部分,叫做“Store Buffer”,當(dāng)CPU寫數(shù)據(jù)之后,需要等待其他處理器返回確認(rèn)消息,因此處理器先不將數(shù)據(jù)寫入緩存(Cache)當(dāng)中,而時寫入到Store Buffer當(dāng)中,然后繼續(xù)執(zhí)行指令不進(jìn)行等待,當(dāng)其他處理器返回確認(rèn)消息之后,再將Store Buffer當(dāng)中的消息寫入緩存,以后如果CPU需要數(shù)據(jù)就會先從Store Buffer當(dāng)中去查找,如果找不到才回去緩存當(dāng)中找,這個過程也叫做Store Forwarding。
處理器在接受到其他處理器發(fā)來的修改數(shù)據(jù)的消息的時候,需要將被修改的數(shù)據(jù)對應(yīng)的緩存行進(jìn)行失效處理,然后再返回確認(rèn)消息,為了提高處理器的性能,CPU會在接到消息之后立即返回,然后將這個Invalid的消息放入到Invalid Queue當(dāng)中,這就可以降低處理器響應(yīng)Invalid消息的時間。其實這樣做還有一個好處,因為處理器的Store Buffer是有限的,如果發(fā)出Invalid消息的處理器遲遲接受不到響應(yīng)信息的話,那么Store Buffer就可以寫滿,這個時候處理器還會卡住,然后等待其他處理器的響應(yīng)消息,因此處理器在接受到Invalid的消息的時候立馬返回也可以提升發(fā)出Invalid消息的處理器的性能,會減少處理器卡住的時間,從而提升處理器的性能。
Store Buffer、Valid Queue、CPU、CPU緩存以及內(nèi)存的邏輯結(jié)構(gòu)大致如下:
還記得前面的兩條指令lfence
和sfence
嗎,現(xiàn)在我們重新回顧一下這兩條指令:
lfence
,在前面的內(nèi)容當(dāng)中,這個屏障能夠讓高速緩存失效,事實上是,它掃描Invalid Queue中的消息,然后讓對應(yīng)數(shù)據(jù)的緩存行失效,這樣的話就可以更新到內(nèi)存當(dāng)中最新的數(shù)據(jù)了。這里的失效并不是L1緩存失效,而是L2和L3中的緩存行失效,讀取數(shù)據(jù)也不一定從內(nèi)存當(dāng)中讀取,因為L1Cache當(dāng)中可能有最新的數(shù)據(jù),如果有的話就可以從L1Cache當(dāng)中讀取。sfence
,在前面的內(nèi)容當(dāng)中,我們談到這個屏障時,說它可以將寫入高速緩存的數(shù)據(jù)刷新到內(nèi)存當(dāng)中,這樣內(nèi)存當(dāng)中的數(shù)據(jù)就是最新的了,數(shù)據(jù)就可以全局可見了。事實上這個內(nèi)存屏障是將StoreBuffer當(dāng)中的數(shù)據(jù)刷行到L1Cache當(dāng)中,這樣其他的處理器就可以看到變化了,因為多個處理器是共享同一個L1Cache的,比如下圖當(dāng)中的CPU結(jié)構(gòu)。當(dāng)然它也是可以被刷新到內(nèi)存當(dāng)中的。
(下面圖片來源于網(wǎng)絡(luò))
MESI協(xié)議
在前面的文章當(dāng)中我們已經(jīng)提到了在MESI協(xié)議當(dāng)中緩存行的四種狀態(tài):
- 已修改Modified (M) 緩存行是臟的(dirty),與主存的值不同。如果別的CPU內(nèi)核要讀主存這塊數(shù)據(jù),該緩存行必須回寫到主存,狀態(tài)變?yōu)楣蚕?S).
- 獨(dú)占Exclusive (E)緩存行只在當(dāng)前緩存中,但是干凈的(clean)緩存數(shù)據(jù)和主存數(shù)據(jù)相同。當(dāng)別的緩存讀取它時,狀態(tài)變?yōu)楣蚕?;?dāng)寫數(shù)據(jù)時,變?yōu)橐研薷臓顟B(tài)。
- 共享Shared (S)緩存行也存在于其它緩存中且是干凈的。緩存行可以在任意時刻拋棄。
- 無效Invalid (I)緩存行是無效的。
下圖表示不同處理器緩存同一個數(shù)據(jù)的緩存行的狀態(tài)是否相容:
- 比如說“I”那一行,處理器A的緩存行H包含數(shù)據(jù)
data
,而且這個緩存行的狀態(tài)是Invalid,那么其他處理器包含數(shù)據(jù)data
的緩存行的狀態(tài)可以是“M、E、S、I”當(dāng)中的任意一個。 - 再比如說包含數(shù)據(jù)
data
的緩存行是“Shared”的狀態(tài),說明這個數(shù)據(jù)是各個處理器共享的,因此其他的緩存行不可能是“Exclusive”狀態(tài),因為不可能既共享也獨(dú)占。當(dāng)然肯定也不是“Modified”,如果是“Modified”狀態(tài),那么其他緩存行只能是“Invalid”的狀態(tài),而不會是“Shared”狀態(tài)
在介紹MESI協(xié)議之前,我們先介紹一些基本操作:
處理器對緩存的請求:
- PrRd: 處理器請求讀一個緩存塊。
- PrWr: 處理器請求寫一個緩存塊。
總線對緩存的請求:
- BusRd: 總線上有一個消息:其他處理器請求讀一個緩存塊。
- BusRdX: 總線上有一個消息:其他處理器請求寫一個自己不擁有的緩存塊。
- BusUpgr: 總線上有一個消息:其他處理器請求寫一個自己擁有的緩存塊。
- Flush:總線上有一個消息:請求回寫整個緩存到主存。
- FlushOpt: 總線上有一個消息:整個緩存塊被發(fā)到總線,然后通過總線送給另外一個處理器(緩存到緩存的復(fù)制)。
下圖是MESI這四種狀態(tài)在不同的操作之下的轉(zhuǎn)換圖(紅色表示總線事務(wù),黑色表示處理器事務(wù)):(圖片來自維基百科)
- 假如現(xiàn)在是“M”狀態(tài),現(xiàn)在如果有其他處理器想要讀數(shù)據(jù)(BusRd)或者處理器想要將這個數(shù)據(jù)寫回內(nèi)存(flush),那么這個“M”狀態(tài)就轉(zhuǎn)變成“S”狀態(tài)了。
- 假如現(xiàn)在是“E”狀態(tài),如果有總線請求讀(BusRd),那么這個狀態(tài)就需要從獨(dú)占(E)變成共享(S)。
不同的初始狀態(tài)在不同的處理器操作下的狀態(tài)變化:
初始狀態(tài) | 操作 | 響應(yīng) |
---|---|---|
Invalid(I) | PrRd | 給總線發(fā)BusRd信號 其他處理器看到BusRd,檢查自己是否有有效的數(shù)據(jù)副本,通知發(fā)出請求的緩存 狀態(tài)轉(zhuǎn)換為(S)Shared, 如果其他緩存有有效的副本 狀態(tài)轉(zhuǎn)換為(E)Exclusive, 如果其他緩存都沒有有效的副本 如果其他緩存有有效的副本, 其中一個緩存發(fā)出數(shù)據(jù);否則從主存獲得數(shù)據(jù) |
Exclusive(E) | PrRd | 無總線事務(wù)生成 狀態(tài)保持不變 讀操作為緩存命中 |
Shared(S) | PrRd | 無總線事務(wù)生成 狀態(tài)保持不變 讀操作為緩存命中 |
Modified(M) | PrRd | 無總線事務(wù)生成 狀態(tài)保持不變 讀操作為緩存命中 |
Invalid(I) | PrWr | 給總線發(fā)BusRdX信號 狀態(tài)轉(zhuǎn)換為(M)Modified如果其他緩存有有效的副本, 其中一個緩存發(fā)出數(shù)據(jù);否則從主存獲得數(shù)據(jù) 如果其他緩存有有效的副本, 見到BusRdX信號后無效其副本 向緩存塊中寫入修改后的值 |
Exclusive(E) | PrWr | 無總線事務(wù)生成 狀態(tài)轉(zhuǎn)換為(M)Modified向緩存塊中寫入修改后的值 |
Shared(S) | PrWr | 發(fā)出總線事務(wù)BusUpgr信號 狀態(tài)轉(zhuǎn)換為(M)Modified其他緩存看到BusUpgr總線信號,標(biāo)記其副本為(I)Invalid. |
Modified(M) | PrWr | 無總線事務(wù)生成 狀態(tài)保持不變 寫操作為緩存命中 |
不同的初始狀態(tài)在不同的總線消息下的狀態(tài)變化:
初始狀態(tài) | 操作 | 響應(yīng) |
---|---|---|
Invalid(I) | BusRd | 狀態(tài)保持不變,信號忽略 |
Exclusive(E) | BusRd | 狀態(tài)變?yōu)楣蚕?br />發(fā)出總線FlushOpt信號并發(fā)出塊的內(nèi)容 |
Shared(S) | BusRd | 狀態(tài)變?yōu)楣蚕?br />可能發(fā)出總線FlushOpt信號并發(fā)出塊的內(nèi)容(設(shè)計時決定那個共享的緩存發(fā)出數(shù)據(jù)) |
Modified(M) | BusRd | 狀態(tài)變?yōu)楣蚕?br />發(fā)出總線FlushOpt信號并發(fā)出塊的內(nèi)容,接收者為最初發(fā)出BusRd的緩存與主存控制器(回寫主存) |
Exclusive(E) | BusRdX | 狀態(tài)變?yōu)闊o效 發(fā)出總線FlushOpt信號并發(fā)出塊的內(nèi)容 |
Shared(S) | BusRdX | 狀態(tài)變?yōu)闊o效 可能發(fā)出總線FlushOpt信號并發(fā)出塊的內(nèi)容(設(shè)計時決定那個共享的緩存發(fā)出數(shù)據(jù)) |
Modified(M) | BusRdX | 狀態(tài)變?yōu)闊o效 發(fā)出總線FlushOpt信號并發(fā)出塊的內(nèi)容,接收者為最初發(fā)出BusRd的緩存與主存控制器(回寫主存) |
Invalid(I) | BusRdX/BusUpgr | 狀態(tài)保持不變,信號忽略 |
總結(jié)
在本篇文章當(dāng)中主要是介紹了volatile和JMM的具體作用和規(guī)則,然后仔細(xì)介紹了實現(xiàn)這些的底層原理,尤其是內(nèi)存屏障以及它在X86當(dāng)中的具體實現(xiàn),這一部分的內(nèi)容比較抽象,可能難以理解本篇文章涉及的內(nèi)容比較多,可能需要大家慢慢的仔細(xì)思考才能理解。
以上就是深入了解volatile和Java內(nèi)存模型的詳細(xì)內(nèi)容,更多關(guān)于Java內(nèi)存模型 volatile的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
登錄EasyConnect后無法通過jdbc訪問服務(wù)器數(shù)據(jù)庫問題的解決方法
描述一下近期使用EasyConnect遇到的問題,下面這篇文章主要給大家介紹了關(guān)于登錄EasyConnect后無法通過jdbc訪問服務(wù)器數(shù)據(jù)庫問題的解決方法,文中通過實例代碼介紹的非常詳細(xì),需要的朋友可以參考下2023-02-02java打印從1到100的值(break,return斷句)
java 先寫一個程序,打印從1到100的值。之后修改程序,通過使用break關(guān)鍵詞,使得程序在打印到98時退出。然后嘗試使用return來達(dá)到相同的目的2017-02-02基于 SpringBoot 實現(xiàn) MySQL 讀寫分離的問題
這篇文章主要介紹了基于 SpringBoot 實現(xiàn) MySQL 讀寫分離的問題,本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2021-02-02