2019獨角獸企業重金招聘Python工程師標準>>>
首先并發編程有三大特性:?可見性,有序性,原子性。volatile關鍵字實現了前面兩個特性。那么它是如何實現這兩個特性的呢?
首先是可見性。可見性主要是讓緩存,直接寫穿透到主存中。然后另外的cpu?通過底層的硬件層面的嗅探,可以發現自己cpu本地的緩存已經失效。然后到主存中直接讀取。現在讓我們來看看,cpu里面的緩存具體和主存如何交互。
說道這里首先需要了解,計算機內部存儲器結構。每個核中都有自己的通用寄存器,比如eax,ebx,edx,esi,esp.訪問這些寄存器里面的內容,只要一個機器周期,就夠了,通常小于1ns..然后是L1,L2的本地core的緩存。?通常在10個機器周期左右。大約10ns. L3級緩存是多core共享的。
? ? ? 以我們常見的X86芯片為例,Cache的結構下圖所示:整個Cache被分為S個組,每個組是又由E行個最小的存儲單元——Cache Line所組成,而一個Cache Line中有B(B=64)個字節用來存儲數據,即每個Cache Line能存儲64個字節的數據,每個Cache Line又額外包含一個有效位(valid bit)、t個標記位(tag bit),其中valid bit用來表示該緩存行是否有效;tag bit用來協助尋址,唯一標識存儲在CacheLine中的塊;而Cache Line里的64個字節其實是對應內存地址中的數據拷貝。根據Cache的結構題,我們可以推算出每一級Cache的大小為B×E×S。 ??
1級大概是32k?或者32K X2? ,2級大概是 256K或者256KX2 ,L3一般是3M左右。當多線程并發訪問一段代碼的時候,讀取變量到本地的core進行計算,然后把數據寫入到緩存中,假如沒有volatile關鍵字的話,緩存采用的是write back?策略,直接寫到緩存,看如下代碼,雖然啟用了10個線程進行計數,但是打印出來的count值是0.即使sleep(100),100ms,等所有線程都起來了,也是得到的結果都是不定的,因為無法確定緩存什么時候,換出寫到主存中。
1 public class VolatileTest {
2
3???? private int count ;
4???? public void increase() {
5???????? count++;
6???? }
7???? public void? getCount(){
8???????? System.out.println(count);
9???? }
10???? public static void main(String[] args) throws InterruptedException{
11???????? VolatileTest test =? new VolatileTest();
12???????? for(int i=0;i<10;i++){
13???????????? new Thread(){
14???????????????? @Override
15???????????????? public void run() {
16???????????????????? for(int j=0;j<1000;j++)
17???????????????????????? test.increase();
18???????????????? }
19???????????? }.start();
20???????? }
21???????? Thread.sleep(100);
22???????? test.getCount();
23???? }
24 }
???? volatile?作用1?就是一個線程改變了共享變量的值,其它線程馬上能看見,就是可見性。比如下面的這段代碼。
?1?import?java.util.concurrent.CountDownLatch;?2??3?public?class?VolatileTest2?{?4??5?????private?static?volatile?boolean?status=false?;?6??7?????private?static?CountDownLatch?start??=?new?CountDownLatch(1);?8??9?????public?void?setStatusTrue(){10?????????status?=true;11?????}12?????public?void??getStatus(){13?????????System.out.println(status);14?????}15?????public?static?void?main(String[]?args)?throws?InterruptedException{16?????????VolatileTest2?test?=??new?VolatileTest2();17?????????new?Thread(new?Task2(start,test)).start();18?????????for(int?i=0;i<10;i++){19?????????????new?Thread(new?Task1(start,test)).start();20?????????}21?????}22?}23?24?class?Task1?implements?Runnable{25?????private?CountDownLatch?latch;26?????private?VolatileTest2?test?;27?????public?Task1(CountDownLatch?start,VolatileTest2?test){28?????????this.latch?=?start;29?????????this.test?=?test;30?????}31?????@Override32?????public?void?run()?{33?????????try{34????????????latch.await();35?????????}catch?(Exception?e){36?????????}37?????????????test.getStatus();38?????}39?}40?41?/**42??*?這個線程吧狀態設置成true,然后同步計數器馬上變成0.之后,就其它線程馬上就能看到status狀態為true43??*/44?class?Task2?implements?Runnable{45?????private?CountDownLatch?latch;46?????private?VolatileTest2?test?;47?????public?Task2(CountDownLatch?start,VolatileTest2?test){48?????????this.latch?=?start;49?????????this.test?=?test;50?????}51?????@Override52?????public?void?run()?{53?????????test.setStatusTrue();54?????????latch.countDown();55?????????System.out.println("countDown===");56?????}57?}
? ? ?? 具體的原理,這里涉及到緩存一致性原理,MESI 協議
? ? ?? 失效(Invalid)緩存段,要么已經不在緩存中,要么它的內容已經過時。為了達到緩存的目的,這種狀態的段將會被忽略。一旦緩存段被標記為失效,那效果就等同于它從來沒被加載到緩存中。
?????? 共享(Shared)緩存段,它是和主內存內容保持一致的一份拷貝,在這種狀態下的緩存段只能被讀取,不能被寫入。多組緩存可以同時擁有針對同一內存地址的共享緩存段,這就是名稱的由來。
?????? 獨占(Exclusive)緩存段,和S狀態一樣,也是和主內存內容保持一致的一份拷貝。區別在于,如果一個處理器持有了某個E狀態的緩存段,那其他處理器就不能同時持有它,所以叫“獨占”。這意味著,如果其他處理器原本也持有同一緩存段,那么它會馬上變成“失效”狀態。
?????? 已修改(Modified)緩存段,屬于臟段,它們已經被所屬的處理器修改了。如果一個段處于已修改狀態,那么它在其他處理器緩存中的拷貝馬上會變成失效狀態,這個規律和E狀態一樣。此外,已修改緩存段如果被丟棄或標記為失效,那么先要把它的內容回寫到內存中——這和回寫模式下常規的臟段處理方式一樣。
?????? 在寫入時鎖定緩存,稱為Exclusive狀態,然后同時寫入緩存和主存,當讀取數據的時候,強行,從主存中讀取,并且申請緩存行填充。
2?有序性,這個又如何保證呢?
?????? 《深入理解Java虛擬機》中有這句話“”“觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的匯編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令”“”,lock前綴指令實際上相當于一個內存屏障(也成內存柵欄)它確保指令重排序時不會把其后面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的后面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;至于什么是內存屏障,不做深入了解。只需要知道是CPU Out-of-order execution 和 compiler reordering optimizations。用于對內存操作的順序限制。
Memory access instructions, such as loads and stores, typically take longer to execute than other instructions. Therefore, compilers use registers to hold frequently used values and processors use high speed caches to hold the most frequently used memory locations. Another common optimization is for compilers and processors to rearrange the order that instructions are executed so that the processor does not have to wait for memory accesses to complete. This can result in memory being accessed in a different order than specified in the source code. While this typically will not cause a problem in a single thread of execution, it can cause a problem if the location can also be accessed from another processor or device.