您好,登錄后才能下訂單哦!
這篇文章主要介紹“Java基礎之volatile應用實例分析”,在日常操作中,相信很多人在Java基礎之volatile應用實例分析問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”Java基礎之volatile應用實例分析”的疑惑有所幫助!接下來,請跟著小編一起來學習吧!
問:請談談你對volatile的理解?
答:volatile是Java虛擬機提供的輕量級的同步機制,它有3個特性:
1)保證可見性
2)不保證原子性
3)禁止指令重排
剛學完java基礎,如果有人問你什么是volatile?它有什么作用的話,相信一定非常懵逼…
可能看了答案,也完全不明白,什么是同步機制?什么是可見性?什么是原子性?什么是指令重排?
要想理解什么是可見性,首先要先理解JMM。
JMM(Java內存模型,Java Memory Model)本身是一種抽象的概念,并不真實存在。它描述的是一組規則或規范,通過這組規范,定了程序中各個變量的訪問方法。JMM關于同步的規定:
1)線程解鎖前,必須把共享變量的值刷新回主內存;
2)線程加鎖前,必須讀取主內存的最新值到自己的工作內存;
3)加鎖解鎖是同一把鎖;
由于JVM運行程序的實體是線程,創建每個線程時,JMM會為其創建一個工作內存(有些地方稱為棧空間),工作內存是每個線程的私有數據區域。
Java內存模型規定所有變量都存儲在主內存,主內存是共享內存區域,所有線程都可以訪問。
但線程對變量的操作(讀取、賦值等)必須在工作內存中進行。因此首先要將變量從主內存拷貝到自己的工作內存,然后對變量進行操作,操作完成后再將變量寫會主內存中。
看了上面對JMM的介紹,可能還是優點懵,接下來用一個賣票系統來進行舉例:
1)如下圖,此時賣票系統后端只剩下1張票,并已讀入主內存中:ticketNum=1。
2)此時網絡上有多個用戶都在搶票,那么此時就有多個線程同時都在進行買票服務,假設此時有3個線程都讀入了目前的票數:ticketNum=1,那么接著就會買票。
3)假設線程1先搶占到cpu的資源,先買好票,并在自己的工作內存中將ticketNum的值改為0:ticketNum=0,然后再寫回到主內存中。
此時,線程1的用戶已經買到票了,那么線程2,線程3此時應該不能再繼續買票了,因此需要系統通知線程2,線程3,ticketNum此時已經等于0了:ticketNum=0。如果有這樣的通知操作,你就可以理解為就具有可見性。
通過上面對JMM的介紹和舉例,可以簡單總結下。
JMM內存模型的可見性是指,多線程訪問主內存的某一個資源時,如果某一個線程在自己的工作內存中修改了該資源,并寫回主內存,那么JMM內存模型應該要通知其他線程來從新獲取最新的資源,來保證最新資源的可見性。
在1.1中,已經基本理解了可見性的含義,接下來用代碼來驗證一下,volatile確實可以保證可見性。
首先先驗證下,不使用volatile,是不是就是沒有可見性。
package com.koping.test;import java.util.concurrent.TimeUnit;class MyData{ int number = 0; public void add10() { this.number += 10; }}public class VolatileVisibilityDemo { public static void main(String[] args) { MyData myData = new MyData(); // 啟動一個線程修改myData的number,將number的值加10 new Thread( () -> { System.out.println("線程" + Thread.currentThread().getName()+"\t 正在執行"); try{ TimeUnit.SECONDS.sleep(3); } catch (Exception e) { e.printStackTrace(); } myData.add10(); System.out.println("線程" + Thread.currentThread().getName()+"\t 更新后,number的值為" + myData.number); } ).start(); // 看一下主線程能否保持可見性 while (myData.number == 0) { // 當上面的線程將number加10后,如果有可見性的話,那么就會跳出循環; // 如果沒有可見性的話,就會一直在循環里執行 } System.out.println("具有可見性!"); }}
運行結果如下圖,可以看到雖然線程0已經將number的值改為了10,但是主線程還是在循環中,因為此時number不具有可見性,系統不會主動通知。
在上面代碼的第7行給變量number添加volatile后再次測試,如下圖,此時主線程成功退出了循環,因為JMM主動通知了主線程更新number的值了,number已經不為0了。
理解了上面說的可見性之后,再來理解下什么叫原子性?
原子性是指不可分隔,完整性,即某個線程正在做某個業務時,中間不能被分割。要么同時成功,要么同時失敗。
還是有點抽象,接下來舉個例子。
如下圖,創建了一個測試原子性的類:TestPragma。在add方法中將n加1,通過查看編譯后的代碼可以看到,n++被拆分為3個指令進行執行。
因此可能存在線程1正在執行第1個指令,緊接著線程2也正在執行第1個指令,這樣當線程1和線程2都執行完3個指令之后,很容易理解,此時n的值只加了1,而實際是有2個線程加了2次,因此這種情況就是不保證原子性。
在2.1中已經進行了舉例,可能存在2個線程執行n++的操作,但是最終n的值卻只加了1的情況,接下來對這種情況再用代碼進行演示下。
首先給MyData類添加一個add方法
package com.koping.test;class MyData { volatile int number = 0; public void add() { number++; }}
然后創建測試原子性的類:TestPragmaDemo。測試下20個線程給number各加1000次之后,number的值是否是20000。
package com.koping.test;public class TestPragmaDemo { public static void main(String[] args) { MyData myData = new MyData(); // 啟動20個線程,每個線程將myData的number值加1000次,那么理論上number值最終是20000 for (int i=0; i<20; i++) { new Thread(() -> { for (int j=0; j<1000; j++) { myData.add(); } }).start(); } // 程序運行時,模型會有主線程和守護線程。如果超過2個,那就說明上面的20個線程還有沒執行完的,就需要等待 while (Thread.activeCount()>2){ Thread.yield(); } System.out.println("number值加了20000次,此時number的實際值是:" + myData.number); }}
運行結果如下圖,最終number的值僅為18410。
可以看到即使加了volatile,依然不保證有原子性。
上面介紹并證明了volatile不保證原子性,那如果希望保證原子性,怎么辦呢?以下提供了2種方法
方法1是在add方法上添加synchronized,這樣每次只有1個線程能執行add方法。
結果如下圖,最終確實可以使number的值為20000,保證了原子性。
但是,實際業務邏輯方法中不可能只有只有number++這1行代碼,上面可能還有n行代碼邏輯。現在為了保證number的值是20000,就把整個方法都加鎖了(其實另外那n行代碼,完全可以由多線程同時執行的)。所以就優點殺雞用牛刀,高射炮打蚊子,小題大做了。
package com.koping.test;class MyData { volatile int number = 0; public synchronized void add() { // 在n++上面可能還有n行代碼進行邏輯處理 number++; }}
給MyData新曾一個原子整型類型的變量num,初始值為0。
package com.koping.test;import java.util.concurrent.atomic.AtomicInteger;class MyData { volatile int number = 0; volatile AtomicInteger num = new AtomicInteger(); public void add() { // 在n++上面可能還有n行代碼進行邏輯處理 number++; num.getAndIncrement(); }}
讓num也同步加20000次。結果如下圖,可以看到,使用原子整型的num可以保證原子性,也就是number++的時候不會被搶斷。
package com.koping.test;public class TestPragmaDemo { public static void main(String[] args) { MyData myData = new MyData(); // 啟動20個線程,每個線程將myData的number值加1000次,那么理論上number值最終是20000 for (int i=0; i<20; i++) { new Thread(() -> { for (int j=0; j<1000; j++) { myData.add(); } }).start(); } // 程序運行時,模型會有主線程和守護線程。如果超過2個,那就說明上面的20個線程還有沒執行完的,就需要等待 while (Thread.activeCount()>2){ Thread.yield(); } System.out.println("number值加了20000次,此時number的實際值是:" + myData.number); System.out.println("num值加了20000次,此時number的實際值是:" + myData.num); }}
在第2節中理解了什么是原子性,現在要理解下什么是指令重排?
計算機在執行程序時,為了提高性能,編譯器和處理器常常會對指令進行重排:
源代碼–>編譯器優化重排–>指令并行重排–>內存系統重排–>最終執行指令
處理器在進行重排時,必須要考慮指令之間的數據依賴性。
單線程環境中,可以確保最終執行結果和代碼順序執行的結果一致。
但是多線程環境中,線程交替執行,由于編譯器優化重排的存在,兩個線程使用的變量能否保持一致性是無法確定的,結果無法預測。
看了上面的文字性表達,然后看一個很簡單的例子。
比如下面的mySort方法,在系統指令重排后,可能存在以下3種語句的執行情況:
1)1234
2)2134
3)1324
以上這3種重排結果,對最后程序的結果都不會有影響,也考慮了指令之間的數據依賴性。
public void mySort() { int x = 1; // 語句1 int y = 2; // 語句2 x = x + 3; // 語句3 y = x * x; // 語句4}
看完指令重排的簡單介紹后,然后來看下單例模式的代碼。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "\t 執行構造方法SingletonDemo()"); } public static SingletonDemo getInstance() { if (instance == null) { instance = new SingletonDemo(); } return instance; } public static void main(String[] args) { // 單線程測試 System.out.println("單線程的情況測試開始"); System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance()); System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance()); System.out.println("單線程的情況測試結束\n"); }}
首先是在單線程情況下進行測試,結果如下圖。可以看到,構造方法只執行了一次,是沒有問題的。
接下來在多線程情況下進行測試,代碼如下。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "\t 執行構造方法SingletonDemo()"); } public static SingletonDemo getInstance() { if (instance == null) { instance = new SingletonDemo(); } // DCL(Double Check Lock雙端檢索機制)// if (instance == null) {// synchronized (SingletonDemo.class) {// if (instance == null) {// instance = new SingletonDemo();// }// }// } return instance; } public static void main(String[] args) { // 單線程測試// System.out.println("單線程的情況測試開始");// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println("單線程的情況測試結束\n"); // 多線程測試 System.out.println("多線程的情況測試開始"); for (int i=1; i<=10; i++) { new Thread(() -> { SingletonDemo.getInstance(); }, String.valueOf(i)).start(); } }}
在多線程情況下的運行結果如下圖。可以看到,多線程情況下,出現了構造方法執行了2次的情況。
在3.3中的多線程單里模式下,構造方法執行了兩次,因此需要進行改進,這里使用雙端檢鎖機制:Double Check Lock, DCL。即加鎖之前和之后都進行檢查。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "\t 執行構造方法SingletonDemo()"); } public static SingletonDemo getInstance() {// if (instance == null) {// instance = new SingletonDemo();// } // DCL(Double Check Lock雙端檢鎖機制) if (instance == null) { // a行 synchronized (SingletonDemo.class) { if (instance == null) { // b行 instance = new SingletonDemo(); // c行 } } } return instance; } public static void main(String[] args) { // 單線程測試// System.out.println("單線程的情況測試開始");// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println("單線程的情況測試結束\n"); // 多線程測試 System.out.println("多線程的情況測試開始"); for (int i=1; i<=10; i++) { new Thread(() -> { SingletonDemo.getInstance(); }, String.valueOf(i)).start(); } }}
在多次運行后,可以看到,在多線程情況下,此時構造方法也只執行1次了。
需要注意的是3.4中的DCL版的單例模式依然不是100%準確的!!!
是不是不太明白為什么3.4DCL版單例模式不是100%準確的原因?
是不是不太明白在3.1講完指令重排的簡單理解后,為什么突然要講多線程的單例模式?
因為3.4DCL版單例模式可能會由于指令重排而導致問題,雖然該問題出現的可能性可能是千萬分之一,但是該代碼依然不是100%準確的。如果要保證100%準確,那么需要添加volatile關鍵字,添加volatile可以禁止指令重排。
接下來分析下,為什么3.4DCL版單例模式不是100%準確?
查看instance = new SingletonDemo();編譯后的指令,可以分為以下3步:
1)分配對象內存空間:memory = allocate();
2)初始化對象:instance(memory);
3)設置instance指向分配的內存地址:instance = memory;
由于步驟2和步驟3不存在數據依賴關系,因此可能出現執行132步驟的情況。
比如線程1執行了步驟13,還沒有執行步驟2,此時instance!=null,但是對象還沒有初始化完成;
如果此時線程2搶占到cpu,然后發現instance!=null,然后直接返回使用,就會發現instance為空,就會出現異常。
這就是指令重排可能導致的問題,因此要想保證程序100%正確就需要加volatile禁止指令重排。
在3.1中簡單介紹了下執行重排的含義,然后通過3.2-3.5,借助單例模式來舉例說明多線程情況下,為什么要使用volatile的原因,因為可能存在指令重排導致程序異常。
接下來就介紹下volatile能保證禁止指令重排的原理。
首先要了解一個概念:內存屏障(Memory Barrier),又稱為內存柵欄。它是一個CPU指令,有2個作用:
1)保證特定操作的執行順序;
2)保證某些變量的內存可見性;
由于編譯器和處理器都能執行指令重排。如果在指令之間插入一條Memory Barrier則會告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說,通過插入內存屏障,禁止在內存屏障前后的指令執行重排需優化。
內存屏障的另一個作用是強制刷出各種CPU的緩存數據,因此任何CPU上的線程都能讀取到這些數據的最新版本。
到此,關于“Java基礎之volatile應用實例分析”的學習就結束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學習,快去試試吧!若想繼續學習更多相關知識,請繼續關注億速云網站,小編會繼續努力為大家帶來更多實用的文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。