之前說了volatile加在全局變量上, 可以保證變量的可見性. 那么volatile到底是怎么保證變量的可見性的呢?
首先, 我們來說一下, java代碼是怎么執行的.
一、java代碼從jvm虛擬機到底層cpu等硬件是如何交互運行的?
先來看看程序代碼在jvm虛擬機層面是如何工作的
package com.alibaba.nacos.test; /** * Description * <p> * </p> * DATE 2020/8/31. * * @author luoxiaoli. */ public class CodeVisiable { private static boolean initFlag = false; public static void refresh() { System.out.println("refresh data....."); initFlag = true; System.out.println("refresh data success"); } public static void main(String[] args) { // 線程A Thread threadA = new Thread(() -> { while (!initFlag) { } System.out.println("線程:" + Thread.currentThread().getName() + "當前線程秀談到initFlag的狀態已經改變"); }, "threadA"); threadA.start(); // 中間休眠500hs try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } // 線程B Thread threadB = new Thread(() -> { while (!initFlag) { refresh(); } }, "threadB"); threadB.start(); } }
以這個代碼為例來說明:
第一步. 類CodeVisiable會被類加載器ClassLoader加載, 加載完以后, 將類基本信息放入元數據區-->a.class, 然后會在堆里面創建一個class對象.
第二步: 程序如果要想運行, 首先要啟動一個線程
然后加載元數據區的方法, 比如refresh()方法. 啟動線程后, 首先, 會在線程棧開辟一塊棧幀, 然后執行操作數棧
操作數棧第一步, 就是獲取一個常量. 將其壓入棧,
然后字節碼執行引擎, 調用常量iconst0, 執行字節碼操作
如上面的步驟, 這樣程序代碼在jvm中的流程就結束了
如果說從jvm的角度來說, jvm的流程是結束了, 但是, 仔細思考, 整個 JVM運行時數據區, 還有開辟的線程0, 都是在那里呢? 都是在內存里的.
但我們知道如果要想執行iconst0這個變量, 需要誰來調度? 需要cpu來調度. 忘內存里寫入一個值, 需要有cpu來控制.
變量iconst0到底是怎么放進去的呢?
剛開始iconst0是在內存空間的.
iconst0是jvm字節碼執行引擎才認識的代碼, 如果想要往操作數棧中寫值, 那么它對應的邏輯必須要放虧到cpu上, 而cpu只認0101的二進制.
jvm字節碼執行引擎, 內置了兩個解釋器, 一個叫做JIT, 以及叫做解釋執行器. 所以consts0這個常量的字節碼, 會被解釋執行器/JIT進行翻譯, 翻譯成匯編指令.
為什么說java慢, 相對於c來說, java很慢, 他慢就慢在這里了.
那么, 匯編指令能夠直接在cpu上執行么?
當然是不可以的, 因為匯編指令是硬件原語. 還需要將匯編指令翻譯成二進制代碼, 也就是cpu能夠識別的語言. 這個過程是很快的.
這過程中, 將字節碼翻譯成匯編指令也就是硬件原語, 是在軟件層面上操作的,速度更慢. 將匯編指令翻譯成二進制, 是在硬件成面的, 速度相對快一些
所以, 我們看到, 我們的一個java代碼至少要被執行兩次, 才能被放到cpu上執行.
此時. 只是具備了被cpu執行的可能. 還不能被執行. 什么時候才能被執行呢?
這里雖然准備好了指令以及數據, 但是cpu並不是說馬上就會執行, 而是當二進制代碼所在的線程被cpu調用了, cpu才會執行二進制代碼
cpu怎么知道, 什么時候來調度線程呢?
我們知道cpu的內核又兩種KLT和ULT, jvm使用的是klt
其實,在OS底層,有線程變量池, 線程變量池里的線程和我們的線程棧是1對1的關系.
當cpu調度到線程變量池的某個線程的時候, 就會去執行這個線程的二進制代碼.
二. volatile的可見性問題: volatile是如何保證可見性的呢?
就是依賴硬件原語(匯編語言) 給我們提供的這個功能.
下面看看一個變量加了volatile以后, 底層到底做了什么. 想要看到底層的源碼,
第一步: 我們需要下載一個額外的插件
這兩個插件包, 第一個對應的是64位操作系統, 第二個對應的是32位操作系統.
第二步: 解壓后, 有兩個文件 ,將這兩個文件放到如下目錄下
第三步: 再啟動配置上增加啟動參數.
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly.*Jmm03_CodeVisibility.refresh
其中,標紅的那段代碼, 就是會把匯編指令打印出來
mac版本下載地址和使用方法參考: https://blog.csdn.net/iter_zc/article/details/41897137?readlog
運行程序, 我們看到, 會打印出來一個匯編指令碼
其中, 加了volatile關鍵字的變量, 在執行到第31行, 寫volatile的時候, 加了一個鎖. 加鎖的那一個行代碼是第31行. 剛好就是initFlag=true這一行
我們來看看這個鎖是什么意思呢?
查找手冊, 我們發現, LOCK的含義是, 加了一個總線鎖.
lock會觸發硬件緩存鎖定機制, 鎖定機制有兩種: 總線鎖和緩存一致性協議
為什么會有兩種鎖呢? 這就和cpu的發展有關系了.
早期的cpu技術比較落后, 才使用的總線鎖, 來保存緩存的一致性.
總線: cpu想要訪問內存條, 必須要通過總線去訪問, 如下圖. 如果有多個cpu想要同時訪問內存條, 就需要獲取總線的鎖, 誰獲取到鎖了, 誰就能訪問內存條.
可以看到這種方法的缺點, 一旦搶到鎖, 那么只有這個cpu可以執行,其他cpu就沒有辦法在訪問內存里的這個變量了. 沒有辦法發揮多核並發的能力.
因此發展出來了緩存一致性協議. 現在使用最普遍的是mesi協議,
三. mesi協議的工作原理
四個字母分別代表在緩存里不同的四個狀態: M:已修改 E:獨占 S:共享 I:已失效
MESI 是4種狀態的首字母。每個Cache line有4個狀態,可用2個bit表示,它們分別是:
狀態
|
描述
|
監聽任務
|
M 修改 (Modified)
|
該Cache line有效,數據被修改了,和內存中的數據不一致,數據只存在於本Cache中。
|
緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存將該緩存行寫回主存並將狀態變成S(共享)狀態之前被延遲執行。
|
E 獨享、互斥 (Exclusive)
|
該Cache line有效,數據和內存中的數據一致,數據只存在於本Cache中。
|
緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S(共享)狀態。
|
S 共享 (Shared)
|
該Cache line有效,數據和內存中的數據一致,數據存在於很多Cache中。
|
緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,並將該緩存行變成無效(Invalid)。
|
I 無效 (Invalid)
|
該Cache line無效。
|
無
|
如上圖, 一共有4個狀態, 那么, 這四個狀態之間是如何轉換的呢?
計算機啟動的時候, cpu會啟動一個監控程序, 監控總線中被lock標記的變量. 我們知道加了volatile的變量, 就被lock標記了. 那么被lock標記后的變量是如何工作的呢? 一initFlag為例說明
1. 計算器啟動, 有兩個cpu, 那么兩個cpu都會監聽bus總線上帶有lock標記的變量
2. 內存中有一個變量initFlag=false.
3. cpu core0 要調用initFlag, 這時候 ,首先拷貝一份initFlag 放入到bus總線, bus總線監控到initFlag帶有lock標記, 於是所有cpu都監控到這個變量.
4. 然后將initFlag 拷貝到L3 cache-->L2 cache ,此時, 只有一個cpu使用到這個變量, 所以, initFlag此時的狀態是獨享的狀態.
5. 另一個cpu core1 也要調用initFlag變量, 通過bus總線監控到已經有線程在使用這個變量了, 於是, cpu core0也監控到cpu core1 使用這個變量了, 此時, 將initFlag的狀態由獨占變為共享狀態. 同時cpu core1的中initFlag的狀態也是共享狀態.
6. 接下來, cpu core1和cpu core2都想要去修改這個變量, 是如何操作的呢? 我們知道, 在緩存中, 有一個緩存行, 變量保存在緩存行里, 每個cpu需要搶占鎖, 然后鎖住緩存行, 並告訴bus總線, 我搶到鎖了, 監聽bus總線的所有cpu都將得知, 當前已經有一個線程獲取的鎖, 我們要將這個變量丟棄, 於是變量從共享狀態變為丟棄狀態.
7. 獲得鎖的cpu, 修改變量, 這時變量的狀態從共享狀態變為修改狀態. 然后重新協會到主內存, 在經過bus總線的時候, 所有cpu都被告知initFlag變臉已經被修改, 需要重新獲取新的initFlag變量.
8. 當兩個線程同時修改initFlag, 並同時搶到鎖, 怎么辦呢? 他們會同時告訴bus總線, 我搶到鎖了, 由bus總線裁決, 到底有誰來執行.
這就是volatile為何能夠保證可見性的原因. 原因就是加了lock標記,
問題1: 一個緩存行64個字節, 那如果有個對象是128個字節, 怎么辦呢?
緩存行本身是可以保證原子性的, 但是如果一個變量是128字節, 那怎么辦呢? 跨緩存行就不是原子的了, 不是原子的, 緩存一致性協議就搞不定了, 緩存一致性協議就升級為總線鎖了 ,誰搶到誰贏.
問題2: 既然最終都可以總線鎖解決問題, 為什么還要用總線裁決呢?
因為: 總線裁決速度快, 效率高, 只需要裁決一下. 但總線鎖要鎖很久, 效率低. 總線裁決比總線鎖快的多得多. 多數情況下, 總線裁決是可以解決問題的. 很少會遇到超過64字節的變量
四. volatile為什么不能保證原子性呢?
緩存一致性協議, 不能對寄存器生效.
上面那句話是什么意思呢?
比如: cpu core0 從內存里讀取了一個volatile變量 counter = 0, 然后將其從L1緩存總將變量加載到寄存器進行計算. 計算完寫回到L1 緩存, 此時, 變量的狀態是修改, 然后通知bus總線, 所有的cpu都會監測到counter變量已經被修改, 丟棄自己現有的變量. 比如 cpu core1 此時會丟棄counter = 0, 但是如果counter已經被讀取到寄存器進行計算了. 即使在L1內存中的數據被丟棄, 獲取到了新的counter值, 當寄存器計算完以后, 會重新回寫到L1緩存, 此時會覆蓋剛剛讀取到的counter=1, 將自己計算的counter=1寫入內存中.
L1緩存中的變量有兩種賦值方式, 一種是從內存加載進來, 另一種是從寄存器回寫過來的.
因為緩存一致性協議只能失效緩存行的數據, 而不能失效寄存器的數據, 導致volatile不能做到原子性.
--------------------------------------以下是課件內容----------------------------------------------
1.1 MESI協議緩存狀態
狀態
|
描述
|
監聽任務
|
M 修改 (Modified)
|
該Cache line有效,數據被修改了,和內存中的數據不一致,數據只存在於本Cache中。
|
緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存將該緩存行寫回主存並將狀態變成S(共享)狀態之前被延遲執行。
|
E 獨享、互斥 (Exclusive)
|
該Cache line有效,數據和內存中的數據一致,數據只存在於本Cache中。
|
緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S(共享)狀態。
|
S 共享 (Shared)
|
該Cache line有效,數據和內存中的數據一致,數據存在於很多Cache中。
|
緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,並將該緩存行變成無效(Invalid)。
|
I 無效 (Invalid)
|
該Cache line無效。
|
無
|
1.2 MESI狀態轉換
1.觸發事件
觸發事件
|
描述
|
本地讀取(Local read)
|
本地cache讀取本地cache數據
|
本地寫入(Local write)
|
本地cache寫入本地cache數據
|
遠端讀取(Remote read)
|
其他cache讀取本地cache數據
|
遠端寫入(Remote write)
|
其他cache寫入本地cache數據
|
2.cache分類:
狀態
|
觸發本地讀取
|
觸發本地寫入
|
觸發遠端讀取
|
觸發遠端寫入
|
M狀態(修改)
|
本地cache:M 觸發cache:M 其他cache:I
|
本地cache:M 觸發cache:M 其他cache:I
|
本地cache:M→E→S 觸發cache:I→S 其他cache:I→S 同步主內存后修改為E獨享,同步觸發、其他cache后本地、觸發、其他cache修改為S共享
|
本地cache:M→E→S→I 觸發cache:I→S→E→M 其他cache:I→S→I 同步和讀取一樣,同步完成后觸發cache改為M,本地、其他cache改為I
|
E狀態(獨享)
|
本地cache:E 觸發cache:E 其他cache:I
|
本地cache:E→M 觸發cache:E→M 其他cache:I 本地cache變更為M,其他cache狀態應當是I(無效)
|
本地cache:E→S 觸發cache:I→S 其他cache:I→S 當其他cache要讀取該數據時,其他、觸發、本地cache都被設置為S(共享)
|
本地cache:E→S→I 觸發cache:I→S→E→M 其他cache:I→S→I 當觸發cache修改本地cache獨享數據時時,將本地、觸發、其他cache修改為S共享.然后觸發cache修改為獨享,其他、本地cache修改為I(無效),觸發cache再修改為M
|
S狀態(共享)
|
本地cache:S 觸發cache:S 其他cache:S
|
本地cache:S→E→M 觸發cache:S→E→M 其他cache:S→I 當本地cache修改時,將本地cache修改為E,其他cache修改為I,然后再將本地cache為M狀態
|
本地cache:S 觸發cache:S 其他cache:S
|
本地cache:S→I 觸發cache:S→E→M 其他cache:S→I 當觸發cache要修改本地共享數據時,觸發cache修改為E(獨享),本地、其他cache修改為I(無效),觸發cache再次修改為M(修改)
|
I狀態(無效)
|
本地cache:I→S或者I→E 觸發cache:I→S或者I →E 其他cache:E、M、I→S、I 本地、觸發cache將從I無效修改為S共享或者E獨享,其他cache將從E、M、I 變為S或者I
|
本地cache:I→S→E→M 觸發cache:I→S→E→M 其他cache:M、E、S→S→I
|
既然是本cache是I,其他cache操作與它無關
|
既然是本cache是I,其他cache操作與它無關
|
下圖示意了,當一個cache line的調整的狀態的時候,另外一個cache line 需要調整的狀態。
M
|
E
|
S
|
I
|
|
M
|
×
|
×
|
×
|
√
|
E
|
×
|
×
|
×
|
√
|
S
|
×
|
×
|
√
|
√
|
I
|
√
|
√
|
√
|
√
|
舉個例子: 現在有2個long 型變量 a 、b,如果有t1在訪問a,t2在訪問b,而a與b剛好在同一個cache line中,此時t1先修改a,將導致b被刷新!
怎么解決偽共享?
Java8中新增了一個注解:@sun.misc.Contended。加上這個注解的類會自動補齊緩存行,需要注意的是此注解默認是無效的,需要在jvm啟動時設置 -XX:-RestrictContended 才會生效。
@sun.misc.Contended public final static class TulingVolatileLong { public volatile long value = 0L; //public long p1, p2, p3, p4, p5, p6; }
MESI優化和他們引入的問題
value = 3;
void exeToCPUA(){
value = 10;
isFinsh = true;
}
void exeToCPUB(){
if(isFinsh){
//value一定等於10?!
assert value == 10;
}
}
- 對於所有的收到的Invalidate請求,Invalidate Acknowlege消息必須立刻發送
- Invalidate並不真正執行,而是被放在一個特殊的隊列中,在方便的時候才會去執行。
- 處理器不會發送任何消息給所處理的緩存條目,直到它處理Invalidate。
asw