前言
我們知道volatile關鍵字的作用是保證變數在多執行緒之間的可見性,它是java.util. concurrent套件的核心,沒有volatile就沒有這麼多的並發類別給我們使用。
本文詳細解讀volatile關鍵字如何保證變數在多執行緒之間的可見性,在此之前,有必要講解一下CPU快取的相關知識,掌握這部分知識一定會讓我們更能理解volatile的原理,從而更好、更正確地使用volatile關鍵字。
CPU快取
CPU快取的出現主要是為了解決CPU運算速度與記憶體讀寫速度不符的矛盾,因為CPU運算速度要比記憶體讀寫速度快得多,舉例:
一次主記憶體的存取通常在數十到數百個時脈週期
一次L1快取的讀寫只需要1~2個時脈週期
一次L2快取的讀寫也只需要數十個時脈週期
#這種存取速度的顯著差異,導致CPU可能會花費很長時間等待資料到來或將資料寫入記憶體。
基於此,現在CPU大多數情況下讀寫都不會直接存取記憶體(CPU都沒有連接到記憶體的腳位),取而代之的是CPU緩存,CPU快取是位於CPU與記憶體之間的暫存器,它的容量比記憶體小得多但是交換速度卻比記憶體快得多。而快取中的數據是記憶體中的一小部分數據,但這一小部分是短時間內CPU即將存取的,當CPU調用大量數據時,就可先從快取中讀取,從而加快讀取速度。
依照讀取順序與CPU結合的緊密程度,CPU快取可分為:
#一級緩存:簡稱L1 Cache,位於CPU核心的旁邊,是與CPU結合最緊密的CPU快取
二級快取:簡稱L2 Cache,分內部與外部兩種晶片,內部晶片二級快取運轉速度與主頻相同,外部晶片二級快取運轉速度則只有主頻的一半
三級快取:簡稱L3 Cache,部分高階CPU才有
#每一級快取所儲存的資料全部都是下一層快取中的一部分,這三種緩存的技術難度和製造成本是相對遞減的,所以其容量也相對遞增。
當CPU要讀取一個資料時,首先從一級快取中查找,如果沒有再從二級快取中查找,如果還是沒有再從三級快取或內存中查找。一般來說每級快取的命中率大概都有80%左右,也就是說全部資料量的80%都可以在一級快取中找到,只剩下20%的總資料量才需要從二級快取、三級快取或記憶體中讀取。
使用CPU快取所帶來的問題
##用一張圖表示CPU -->CPU快取-->主記憶體資料讀取之間的關係:
當系統運行時,CPU執行計算的過程如下:
如果伺服器是單核心CPU,那麼這些步驟不會有任何的問題,但是如果伺服器是多核心CPU,那麼問題來了,以Intel Core i7處理器的高速緩存概念模型為例(圖摘自《深入理解電腦系統》):
試想以下一種情況:
核0讀取了一個位元組,根據局部性原理,它相鄰的位元組同樣被讀入核0的快取
#核3做了上面同樣的工作,這樣核0與核3的快取擁有相同的資料
核0修改了那個字節,被修改後,那個字節被寫回核0的緩存,但是該訊息並沒有寫回主存
#核3存取該位元組,由於核0並未將資料寫回主存,資料不同步
#為了解決這個問題,CPU製造商制定了一個規則:當一個CPU修改快取中的位元組時,伺服器中其他CPU會被通知,它們的快取將被視為無效。於是,在上面的情況下,核3發現自己的快取中資料已無效,核0將立即把自己的資料寫回主存,然後核3重新讀取該資料。
可以看出,快取在多核心CPU的使用情況下會有一些效能的缺失。
反組譯Java字節碼,查看組譯層對volatile關鍵字做了什麼
有了上面的理論基礎,我們可以研究volatile關鍵字到底是如何實現的。先寫一段簡單的程式碼:
1 /** 2 * @author 五月的仓颉 3 */ 4 public class LazySingleton { 5 6 private static volatile LazySingleton instance = null; 7 8 public static LazySingleton getInstance() { 9 if (instance == null) {10 instance = new LazySingleton();11 }12 13 return instance;14 }15 16 public static void main(String[] args) {17 LazySingleton.getInstance();18 }19 20 }
先反編譯一下這段程式碼的.class文件,看一下產生的字節碼:
#沒有任何特別的。我們知道,字節碼指令,例如上圖的getstatic、ifnonnull、new等,最終對應到作業系統的層面,都是轉換成一條指令去執行,我們使用的PC機、應用伺服器的CPU架構通常都是IA-32架構的,這種架構採用的指令集是CISC(複雜指令集),而組合語言則是這種指令集的助記符。
因此,既然在字節碼層面我們看不出什麼端倪,那下面就看看將程式碼轉換為彙編指令能看出什麼端倪。 Windows上要看到以上程式碼對應的匯編碼不難(吐槽一句,說說不難,為了這個問題我找遍了各種資料,差點就準備安裝虛擬機,在Linux系統上搞了),訪問hsdis工具路徑可直接下載hsdis工具,下載完畢之後解壓,將hsdis-amd64.dll與hsdis-amd64.lib兩個檔案放在%JAVA_HOME%\jre\bin\server路徑下即可,如下圖:
然後跑main函數,在跑main函數之前,加入如下虛擬機器參數:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*LazySingleton.getInstance
執行main函數即可,程式碼產生的彙編指令為:
1 Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output 2 CompilerOracle: compileonly *LazySingleton.getInstance 3 Loaded disassembler from D:\JDK\jre\bin\server\hsdis-amd64.dll 4 Decoding compiled method 0x0000000002931150: 5 Code: 6 Argument 0 is unknown.RIP: 0x29312a0 Code size: 0x00000108 7 [Disassembling for mach='amd64'] 8 [Entry Point] 9 [Verified Entry Point]10 [Constants]11 # {method} 'getInstance' '()Lorg/xrq/test/design/singleton/LazySingleton;' in 'org/xrq/test/design/singleton/LazySingleton'12 # [sp+0x20] (sp of caller)13 0x00000000029312a0: mov dword ptr [rsp+0ffffffffffffa000h],eax14 0x00000000029312a7: push rbp15 0x00000000029312a8: sub rsp,10h ;*synchronization entry16 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@-1 (line 13)17 0x00000000029312ac: mov r10,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}18 0x00000000029312b6: mov r11d,dword ptr [r10+58h]19 ;*getstatic instance20 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@0 (line 13)21 0x00000000029312ba: test r11d,r11d22 0x00000000029312bd: je 29312e0h23 0x00000000029312bf: mov r10,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}24 0x00000000029312c9: mov r11d,dword ptr [r10+58h]25 0x00000000029312cd: mov rax,r1126 0x00000000029312d0: shl rax,3h ;*getstatic instance27 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@16 (line 17)28 0x00000000029312d4: add rsp,10h29 0x00000000029312d8: pop rbp30 0x00000000029312d9: test dword ptr [330000h],eax ; {poll_return}31 0x00000000029312df: ret32 0x00000000029312e0: mov rax,qword ptr [r15+60h]33 0x00000000029312e4: mov r10,rax34 0x00000000029312e7: add r10,10h35 0x00000000029312eb: cmp r10,qword ptr [r15+70h]36 0x00000000029312ef: jnb 293135bh37 0x00000000029312f1: mov qword ptr [r15+60h],r1038 0x00000000029312f5: prefetchnta byte ptr [r10+0c0h]39 0x00000000029312fd: mov r11d,0e07d00b2h ; {oop('org/xrq/test/design/singleton/LazySingleton')}40 0x0000000002931303: mov r10,qword ptr [r12+r11*8+0b0h]41 0x000000000293130b: mov qword ptr [rax],r1042 0x000000000293130e: mov dword ptr [rax+8h],0e07d00b2h43 ; {oop('org/xrq/test/design/singleton/LazySingleton')}44 0x0000000002931315: mov dword ptr [rax+0ch],r12d45 0x0000000002931319: mov rbp,rax ;*new ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14)46 0x000000000293131c: mov rdx,rbp47 0x000000000293131f: call 2907c60h ; OopMap{rbp=Oop off=132}48 ;*invokespecial <init>49 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@10 (line 14)50 ; {optimized virtual_call}51 0x0000000002931324: mov r10,rbp52 0x0000000002931327: shr r10,3h53 0x000000000293132b: mov r11,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}54 0x0000000002931335: mov dword ptr [r11+58h],r10d55 0x0000000002931339: mov r10,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}56 0x0000000002931343: shr r10,9h57 0x0000000002931347: mov r11d,20b2000h58 0x000000000293134d: mov byte ptr [r11+r10],r12l59 0x0000000002931351: lock add dword ptr [rsp],0h ;*putstatic instance60 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)61 0x0000000002931356: jmp 29312bfh62 0x000000000293135b: mov rdx,703e80590h ; {oop('org/xrq/test/design/singleton/LazySingleton')}63 0x0000000002931365: nop64 0x0000000002931367: call 292fbe0h ; OopMap{off=204}65 ;*new ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14)66 ; {runtime_call}67 0x000000000293136c: jmp 2931319h68 0x000000000293136e: mov rdx,rax69 0x0000000002931371: jmp 2931376h70 0x0000000002931373: mov rdx,rax ;*new ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14)71 0x0000000002931376: add rsp,10h72 0x000000000293137a: pop rbp73 0x000000000293137b: jmp 2932b20h ; {runtime_call}74 [Stub Code]75 0x0000000002931380: mov rbx,0h ; {no_reloc}76 0x000000000293138a: jmp 293138ah ; {runtime_call}77 [Exception Handler]78 0x000000000293138f: jmp 292fca0h ; {runtime_call}79 [Deopt Handler Code]80 0x0000000002931394: call 2931399h81 0x0000000002931399: sub qword ptr [rsp],5h82 0x000000000293139e: jmp 2909000h ; {runtime_call}83 0x00000000029313a3: hlt84 0x00000000029313a4: hlt85 0x00000000029313a5: hlt86 0x00000000029313a6: hlt87 0x00000000029313a7: hlt
這麼長長的彙編程式碼,可能大家不知道CPU在哪裡做了手腳,沒事不難,定位到59、60兩行:
0x0000000002931351: lock add dword ptr [rsp],0h ;*putstatic instance; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
之所以定位到这两行是因为这里结尾写明了line 14,line 14即volatile变量instance赋值的地方。后面的add dword ptr [rsp],0h都是正常的汇编语句,意思是将双字节的栈指针寄存器+0,这里的关键就是add前面的lock指令,后面详细分析一下lock指令的作用和为什么加上lock指令后就能保证volatile关键字的内存可见性。
lock指令做了什么
之前有说过IA-32架构,关于CPU架构的问题大家有兴趣的可以自己查询一下,这里查询一下IA-32手册关于lock指令的描述,没有IA-32手册的可以去这个地址下载IA-32手册下载地址,是个中文版本的手册。
我摘抄一下IA-32手册中关于lock指令作用的一些描述(因为lock指令的作用在手册中散落在各处,并不是在某一章或者某一节专门讲):
在修改内存操作时,使用LOCK前缀去调用加锁的读-修改-写操作,这种机制用于多处理器系统中处理器之间进行可靠的通讯,具体描述如下: (1)在Pentium和早期的IA-32处理器中,LOCK前缀会使处理器执行当前指令时产生一个LOCK#信号,这种总是引起显式总线锁定出现 (2)在Pentium4、Inter Xeon和P6系列处理器中,加锁操作是由高速缓存锁或总线锁来处理。如果内存访问有高速缓存且只影响一个单独的高速缓存行,那么操作中就会调用高速缓存锁,而系统总线和系统内存中的实际区域内不会被锁定。同时,这条总线上的其它Pentium4、Intel Xeon或者P6系列处理器就回写所有已修改的数据并使它们的高速缓存失效,以保证系统内存的一致性。如果内存访问没有高速缓存且/或它跨越了高速缓存行的边界,那么这个处理器就会产生LOCK#信号,并在锁定操作期间不会响应总线控制请求
32位IA-32处理器支持对系统内存中的某个区域进行加锁的原子操作。这些操作常用来管理共享的数据结构(如信号量、段描述符、系统段或页表),两个或多个处理器可能同时会修改这些数据结构中的同一数据域或标志。处理器使用三个相互依赖的机制来实现加锁的原子操作:1、保证原子操作2、总线加锁,使用LOCK#信号和LOCK指令前缀3、高速缓存相干性协议,确保对高速缓存中的数据结构执行原子操作(高速缓存锁)。这种机制存在于Pentium4、Intel Xeon和P6系列处理器中
IA-32处理器提供有一个LOCK#信号,会在某些关键内存操作期间被自动激活,去锁定系统总线。当这个输出信号发出的时候,来自其他处理器或总线代理的控制请求将被阻塞。软件能够通过预先在指令前添加LOCK前缀来指定需要LOCK语义的其它场合。 在Intel386、Intel486、Pentium处理器中,明确地对指令加锁会导致LOCK#信号的产生。由硬件设计人员来保证系统硬件中LOCK#信号的可用性,以控制处理器间的内存访问。 对于Pentinum4、Intel Xeon以及P6系列处理器,如果被访问的内存区域是在处理器内部进行高速缓存的,那么通常不发出LOCK#信号;相反,加锁只应用于处理器的高速缓存。
<span style="color: #000000">为显式地强制执行LOCK语义,软件可以在下列指令修改内存区域时使用LOCK前缀。当LOCK前缀被置于其它指令之前或者指令没有对内存进行写操作(也就是说目标操作数在寄存器中)时,会产生一个非法操作码异常(#UD)。 【</span><span style="color: #800080">1</span><span style="color: #000000">】位测试和修改指令(BTS、BTR、BTC) 【</span><span style="color: #800080">2</span><span style="color: #000000">】交换指令(XADD、CMPXCHG、CMPXCHG8B) 【</span><span style="color: #800080">3</span><span style="color: #000000">】自动假设有LOCK前缀的XCHG指令<br>【4】下列单操作数的算数和逻辑指令:INC、DEC、NOT、NEG<br>【5】下列双操作数的算数和逻辑指令:ADD、ADC、SUB、SBB、AND、OR、XOR<br>一个加锁的指令会保证对目标操作数所在的内存区域加锁,但是系统可能会将锁定区域解释得稍大一些。<br>软件应该使用相同的地址和操作数长度来访问信号量(用作处理器之间发送信号的共享内存)。例如,如果一个处理器使用一个字来访问信号量,其它处理器就不应该使用一个字节来访问这个信号量。<br>总线锁的完整性不收内存区域对齐的影响。加锁语义会一直持续,以满足更新整个操作数所需的总线周期个数。但是,建议加锁访问应该对齐在它们的自然边界上,以提升系统性能:<br>【1】任何8位访问的边界(加锁或不加锁)<br>【2】锁定的字访问的16位边界<br>【3】锁定的双字访问的32位边界<br>【4】锁定的四字访问的64位边界<br>对所有其它的内存操作和所有可见的外部事件来说,加锁的操作都是原子的。所有取指令和页表操作能够越过加锁的指令。加锁的指令可用于同步一个处理器写数据而另一个处理器读数据的操作。</span>
IA-32架构提供了几种机制用来强化或弱化内存排序模型,以处理特殊的编程情形。这些机制包括: 【1】I/O指令、加锁指令、LOCK前缀以及串行化指令等,强制在处理器上进行较强的排序 【2】SFENCE指令(在Pentium III中引入)和LFENCE指令、MFENCE指令(在Pentium4和Intel Xeon处理器中引入)提供了某些特殊类型内存操作的排序和串行化功能 ...(这里还有两条就不写了) 这些机制可以通过下面的方式使用。 总线上的内存映射设备和其它I/O设备通常对向它们缓冲区写操作的顺序很敏感,I/O指令(IN指令和OUT指令)以下面的方式对这种访问执行强写操作的排序。在执行了一条I/O指令之前,处理器等待之前的所有指令执行完毕以及所有的缓冲区都被都被写入了内存。只有取指令和页表查询能够越过I/O指令,后续指令要等到I/O指令执行完毕才开始执行。
反复思考IA-32手册对lock指令作用的这几段描述,可以得出lock指令的几个作用:
锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存
lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据
不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序
(1)中写了由于效率问题,实际后来的处理器都采用锁缓存来替代锁总线,这种场景下多缓存的数据一致是通过缓存一致性协议来保证的,我们来看一下什么是缓存一致性协议。
缓存一致性协议
讲缓存一致性之前,先说一下缓存行的概念:
缓存是分段(line)的,一个段对应一块存储空间,我们称之为缓存行,它是CPU缓存中可分配的最小存储单元,大小32字节、64字节、128字节不等,这与CPU架构有关。当CPU看到一条读取内存的指令时,它会把内存地址传递给一级数据缓存,一级数据缓存会检查它是否有这个内存地址对应的缓存段,如果没有就把整个缓存段从内存(或更高一级的缓存)中加载进来。注意,这里说的是一次加载整个缓存段,这就是上面提过的局部性原理
上面说了,LOCK#会锁总线,实际上这不现实,因为锁总线效率太低了。因此最好能做到:使用多组缓存,但是它们的行为看起来只有一组缓存那样。缓存一致性协议就是为了做到这一点而设计的,就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致。
缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于"嗅探(snooping)"协议,它的基本思想是:
<span style="color: #000000">所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。<br>CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。</span>
MESI協定是目前最主流的快取一致性協議,在MESI協定中,每個快取行有4個狀態,可用2個bit表示,它們分別是:
#這裡的I、S和M狀態已經有了對應的概念:失效/未載入、乾淨、髒的快取段。所以這裡新的知識點只有E狀態,代表獨佔式訪問,這個狀態解決了"在我們開始修改某塊內存之前,我們需要告訴其它處理器"這一問題:只有當緩存行處於E或M狀態時,處理器才能去寫它,也就是說只有在這兩種狀態下,處理器是獨佔這個快取行的。當處理器想寫某個快取行時,如果它沒有獨佔權,它必須先發送一條"我要獨佔權"的請求給總線,這會通知其它處理器把它們擁有的同一快取段的拷貝失效(如果有)。只有在獲得獨佔權後,處理器才能開始修改資料----並且此時這個處理器知道,這個快取行只有一份拷貝,在我自己的快取裡,所以不會有任何衝突。
反之,如果有其它處理器想讀取這個快取行(馬上能知道,因為一直在嗅探匯流排),獨佔或已修改的快取行必須先回到"共享"狀態。如果是已修改的快取行,那麼也要先把內容回寫到記憶體中。
由lock指令回看volatile變數讀寫
相信有了上面對於lock的解釋,volatile關鍵字的實現原理應該是一目了然了。首先來看一張圖:
#工作記憶體Work Memory其實就是CPU暫存器和快取的抽象,或者說每個執行緒的工作記憶體也可以簡單理解為CPU暫存器和快取。
那麼當寫兩個執行緒Thread-A與Threab-B同時操作主記憶體中的一個volatile變數i時,Thread-A寫了變數i,那麼:
Thread-A發出LOCK#指令
#所發出的LOCK#指令鎖總線(或鎖快取行),同時讓Thread-B快取中的快取行內容失效
#Thread-A向主記憶體回寫最新修改的i
Thread-B讀取變數i,那麼:
#Thread-B發現對應位址的快取行被鎖了,等待鎖的釋放,快取一致性協定會保證它讀取到最新的值
#由此可以看出,volatile關鍵字的讀和普通變數的讀取相比基本上沒差別,差異主要還是在變數的寫入操作上。
後記
#之前對於volatile關鍵字的作用我個人還有一些會混淆的誤區,在深入理解volatile關鍵字的作用之後,感覺對volatile的理解深了許多。相信看到文章這裡的你,只要肯想、肯研究,一定會和我一樣有恍然大悟、茅塞頓開的感覺^_^
參考資料
《IA-32架構軟體開發人員手冊第3卷:系統編程指南》
《Java並發編程的藝術》
《深入理解Java虛擬機:JVM高階特性與最佳實務》
PrintAssembly查看volatile彙編程式碼小記
快取一致性(Cache Coherency)入門
聊高並發(三十四)Java記憶體模型那些事(二)理解CPU快取的工作原理
以上是Java中volatile怎麼使用?的詳細內容。更多資訊請關注PHP中文網其他相關文章!