貌似兩個多月沒寫博客,不知道年前這段時間都去忙了什麼。
好久以前寫過一次和volatile相關的博客,感覺沒寫的那麼深入吧,這次我們繼續說我們的volatile關鍵字。
複習:
先來簡單的複習一遍以前寫過的東西,上次我們說了內存一致性協議M(修改)E(獨佔)S(共享)I(失效)四種狀態,還有我們併發編程的三大特性原子性、一致性和可見性。再就是簡單的提到了我們的volatile關鍵字,他可以保證我們的可見性,也就是說被volatile關鍵字修飾的變量如果產生了變化,可以馬上刷到主存當中去。我們接下來看一下我們這次博客的內容吧。
線程:
何爲線程呢?這也是我們面試當中經常問到的。按照官方的說法是:現代操作系統在運行一個程序時,會爲其創建一個進程。例如,啓動一個Java程序,操作 系統就會創建一個Java進程。現代操作系統調度CPU的最小單元是線程。比如我們啓動QQ,就是我們啓動了一個進程,我們發起了QQ語音,這個動作就是一個線程。
在這裏多提一句的就是線程分爲內核級線程和用戶級線程,我們在java虛擬機內的線程一般都爲用戶級線程,也就是由我們的jvm虛擬機來調用我們的CPU來申請時間片來完成我們的線程操作的。而我們的內核級線程是由我們的系統來調度CPU來完成的,爲了保證安全性,一般的線程都是由虛擬機來控制的。
上下文切換:
上面我們說過,線程是由我們的虛擬機去CPU來申請時間片來完成我們的操作的,但是不一定馬上執行完成,這時就產生了上下文切換。大致就是這樣的:
線程A沒有運行完成,但是時間片已經結束了,我們需要掛起我們的線程A,CPU該去執行線程B了,運行完線程B,才能繼續運行我們的線程A,這時就涉及到一個上下文的切換,我們把這個暫時掛起到再次運行的過程,可以理解爲上下文切換(最簡單的理解方式)。
可見性:
用volatile關鍵字修飾過的變量,可以保證可見性,也就是volatile變量被修改了,會立即刷到主內存內,讓其他線程感知到變量已經修改,我們來看一個事例
public class VolatileVisibilitySample { private volatile boolean initFlag = false; public void refresh(){ this.initFlag = true; String threadname = Thread.currentThread().getName(); System.out.println("線程:"+threadname+":修改共享變量initFlag"); } public void load(){ String threadname = Thread.currentThread().getName(); int i = 0; while (!initFlag){ } System.out.println("線程:"+threadname+"當前線程嗅探到initFlag的狀態的改變"+i); } public static void main(String[] args){ VolatileVisibilitySample sample = new VolatileVisibilitySample(); Thread threadA = new Thread(()->{ sample.refresh(); },"threadA"); Thread threadB = new Thread(()->{ sample.load(); },"threadB"); threadB.start(); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } threadA.start(); } }
我們想創建一個全局的由volatile修飾的boolean變量,refresh方法是修改我們的全局變量,load方法是無限循環去檢查我們全局volatile修飾過的變量,我們開啓兩個線程,開始運行,我們會看到如下結果。
也就是說,我們的變量被修改以後,我們的另外一個線程會感知到我們的變量已經發生了改變,也就是我們的可行性,立即刷回主內存。
有序性:
說到有序性,不得不提到幾個知識點,指令重排,as-if-serial語義和happens-before 原則。
指令重排:java語言規範規定JVM線程內部維持順序化語義。即只要程序的最終結果與它順序化情況的結果相等,那麼指令的執行順序可以與代碼順序不一致,此過程叫指令的重排序。指令重排序的意義是什麼?JVM能根據處理器特性(CPU多級緩存系統、多核處理器等)適當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性能。
指令重排一般發生在class翻譯爲字節碼文件和字節碼文件被CPU執行這兩個階段。
as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器爲了提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。 爲了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操作做重排序,因 爲這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關係,這些操作就可能被 編譯器和處理器重排序。
happens-before 原則內容如下
1. 程序順序原則,即在一個線程內必須保證語義串行性,也就是說按照代碼順序執行。
2. 鎖規則 解鎖(unlock)操作必然發生在後續的同一個鎖的加鎖(lock)之前,也就是說,如果對於一個鎖解鎖後,再加鎖,那麼加鎖的動作必須在解鎖動作之後(同一個鎖)。
3. volatile規則 volatile變量的寫,先發生於讀,這保證了volatile變量的可見性,簡單的理解就是,volatile變量在每次被線程訪問時,都強迫從主內存中讀該變量的值,而當該變量發生變化時,又會強迫將最新的值刷新到主內存,任何時刻,不同的線程總是能夠看到該變量的最新值。
4. 線程啓動規則 線程的start()方法先於它的每一個動作,即如果線程A在執行線程B的start方法之前修改了共享變量的值,那麼當線程B執行start方法時,線程A對共享變量的修改對線程B可見
5. 傳遞性A先於B ,B先於C,那麼A必然先於C
6. 線程終止規則 線程的所有操作先於線程的終結,Thread.join()方法的作用是等待當前執行的線程終止。假設在線程B終止之前,修改了共享變量,線程A從線程B的join方法成功返回後,線程B對共享變量的修改將對線程A可見。
7. 線程中斷規則 對線程 interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否中斷。
8. 對象終結規則 對象的構造函數執行,結束先於finalize()方法。
上一段代碼看看指令重排的問題。
public class VolatileReOrderSample { private static int x = 0, y = 0; private static int a = 0, b = 0; public static void main(String[] args) throws InterruptedException { int i = 0; for (; ; ) { i++; x = 0; y = 0; a = 0; b = 0; Thread t1 = new Thread(new Runnable() { public void run() { a = 1; x = b; } }); Thread t2 = new Thread(new Runnable() { public void run() { b = 1; y = a; } }); t1.start(); t2.start(); t1.join(); t2.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if (x == 0 && y == 0) { System.err.println(result); break; } else { System.out.println(result); } } } }
我們來分析一下上面的代碼
情況1:假設我們的線程1開始執行,線程2還沒開始,這時a = 1 ,x = b = 0,因爲b的初始值是0,然後開始執行線程2,b = 1,y = a = 1,得到結論x = 0 ,y = 1.
情況2:假設線程1開始執行,將a賦值爲1,開始執行線程2,b賦值爲1,並且y = a = 1,這時繼續運行線程1,x = b = 1,得到結論 x = 1,y = 1.
情況3:線程2優先執行,這時b = 1,y = a = 0,然後運行線程1,a = 1,x = b = 1,得到結論 x = 1,y = 0。
不管怎麼誰先誰後,我們都是隻有這三種答案,不會產生x = 0且y = 0的情況,我們在下面寫出來了x = 0 且 y = 0 跳出循環。我們來測試一下。
運行到第72874次結果了0,0的情況產生了,也就是說,我們t1中的a = 1;x = b;和t2中的b = 1;y = a;代碼發生了改變,只有變爲
Thread t1 = new Thread(new Runnable() { public void run() { x = b; a = 1; } }); Thread t2 = new Thread(new Runnable() { public void run() { y = a; b = 1; } });
這種情況纔可以產生0,0的情況,我們可以把代碼改爲
private static volatile int a = 0, b = 0;
繼續來測試,我們發現無論我們運行多久都不會發生我們的指令重排現象,也就是說我們volatile關鍵字可以保證我們的有序性
至少我這裏570萬次還沒有發生0,0的情況。
就是我上次博客給予的表格
Required barriers | 2nd operation | |||
1st operation | Normal Load | Normal Store | Volatile Load | Volatile Store |
Normal Load | LoadStore | |||
Normal Store | StoreStore | |||
Volatile Load | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile Store | StoreLoad | StoreStore |
我們來分析一下代碼
線程1的。
public void run() { a = 1; x = b; }
a = 1;是將a這個變量賦值爲1,因爲a被volatile修飾過了,我們成爲volatile寫,就是對應表格的Volatile Store,接下來我們來看第二步,x = b,字面意思是將b的值賦值給x,但是這步操作不是一個原子操作,其中包含了兩個步驟,先取得變量b,被volatile修飾過,就成爲volatile load,然後將b的值賦給x,x沒有被volatile修飾,成爲普通寫。也就是說,這兩行代碼做了三個動作,分別是Volatile Store,volatile load和Store寫讀寫,查表格我們看到volatile修飾的變量Volatile Store,volatile load之間是給予了StoreLoad這樣的屏障,是不允許指令重排的,所以達到了有序性的目的。
擴展:
我們再來看一個方法,不用volatile修飾也可以防止指令重排,因爲上面我們說過,volatile可以保證有序性,就是增加內存屏障,防止了指令重排,我們可以採用手動加屏障的方式也可以阻止指令重排。我們來看一下事例。
public class VolatileReOrderSample { private static int x = 0, y = 0; private static int a = 0, b =0; public static void main(String[] args) throws InterruptedException { int i = 0; for (;;){ i++; x = 0; y = 0; a = 0; b = 0; Thread t1 = new Thread(new Runnable() { public void run() { a = 1; UnsafeInstance.reflectGetUnsafe().storeFence(); x = b; } }); Thread t2 = new Thread(new Runnable() { public void run() { b = 1; UnsafeInstance.reflectGetUnsafe().storeFence(); y = a; } }); t1.start(); t2.start(); t1.join(); t2.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if(x == 0 && y == 0) { System.err.println(result); break; } else { System.out.println(result); } } } }
storeFence就是一個有java底層來提供的內存屏障,有興趣的可以自己去看一下unsafe類,一共有三個屏障
UnsafeInstance.reflectGetUnsafe().storeFence();//寫屏障 UnsafeInstance.reflectGetUnsafe().loadFence();//讀屏障 UnsafeInstance.reflectGetUnsafe().fullFence();//讀寫屏障
通過unsafe的反射來調用,涉及安全問題,jvm是不允許直接調用的。手寫單例模式時在超高併發記得加volatile修飾,不然產生指令重排,會造成空對象的行爲。後面我會科普這個玩意。
最進弄了一個公衆號,小菜技術,歡迎大家的加入