本篇文章給大家?guī)?lái)了關(guān)于java的相關(guān)知識(shí),其中主要整理了volatile的相關(guān)問(wèn)題,包括了volatile保證可見(jiàn)性、volatile不保證原子性、volatile禁止指令重排等等內(nèi)容,下面一起來(lái)看一下,希望對(duì)大家有幫助。
推薦學(xué)習(xí):《java視頻教程》
問(wèn):請(qǐng)談?wù)勀銓?duì)volatile的理解?
答:volatile是Java虛擬機(jī)提供的輕量級(jí)的同步機(jī)制,它有3個(gè)特性:
1)保證可見(jiàn)性
2)不保證原子性
3)禁止指令重排
剛學(xué)完java基礎(chǔ),如果有人問(wèn)你什么是volatile?它有什么作用的話,相信一定非常懵逼…
可能看了答案,也完全不明白,什么是同步機(jī)制?什么是可見(jiàn)性?什么是原子性?什么是指令重排?
1、volatile保證可見(jiàn)性
1.1、什么是JMM模型?
要想理解什么是可見(jiàn)性,首先要先理解JMM。
JMM(Java內(nèi)存模型,Java Memory Model)本身是一種抽象的概念,并不真實(shí)存在。它描述的是一組規(guī)則或規(guī)范,通過(guò)這組規(guī)范,定了程序中各個(gè)變量的訪問(wèn)方法。JMM關(guān)于同步的規(guī)定:
1)線程解鎖前,必須把共享變量的值刷新回主內(nèi)存;
2)線程加鎖前,必須讀取主內(nèi)存的最新值到自己的工作內(nèi)存;
3)加鎖解鎖是同一把鎖;
由于JVM運(yùn)行程序的實(shí)體是線程,創(chuàng)建每個(gè)線程時(shí),JMM會(huì)為其創(chuàng)建一個(gè)工作內(nèi)存(有些地方稱為??臻g),工作內(nèi)存是每個(gè)線程的私有數(shù)據(jù)區(qū)域。
Java內(nèi)存模型規(guī)定所有變量都存儲(chǔ)在主內(nèi)存,主內(nèi)存是共享內(nèi)存區(qū)域,所有線程都可以訪問(wèn)。
但線程對(duì)變量的操作(讀取、賦值等)必須在工作內(nèi)存中進(jìn)行。因此首先要將變量從主內(nèi)存拷貝到自己的工作內(nèi)存,然后對(duì)變量進(jìn)行操作,操作完成后再將變量寫(xiě)會(huì)主內(nèi)存中。
看了上面對(duì)JMM的介紹,可能還是優(yōu)點(diǎn)懵,接下來(lái)用一個(gè)賣票系統(tǒng)來(lái)進(jìn)行舉例:
1)如下圖,此時(shí)賣票系統(tǒng)后端只剩下1張票,并已讀入主內(nèi)存中:ticketNum=1。
2)此時(shí)網(wǎng)絡(luò)上有多個(gè)用戶都在搶票,那么此時(shí)就有多個(gè)線程同時(shí)都在進(jìn)行買票服務(wù),假設(shè)此時(shí)有3個(gè)線程都讀入了目前的票數(shù):ticketNum=1,那么接著就會(huì)買票。
3)假設(shè)線程1先搶占到cpu的資源,先買好票,并在自己的工作內(nèi)存中將ticketNum的值改為0:ticketNum=0,然后再寫(xiě)回到主內(nèi)存中。
此時(shí),線程1的用戶已經(jīng)買到票了,那么線程2,線程3此時(shí)應(yīng)該不能再繼續(xù)買票了,因此需要系統(tǒng)通知線程2,線程3,ticketNum此時(shí)已經(jīng)等于0了:ticketNum=0。如果有這樣的通知操作,你就可以理解為就具有可見(jiàn)性。
通過(guò)上面對(duì)JMM的介紹和舉例,可以簡(jiǎn)單總結(jié)下。
JMM內(nèi)存模型的可見(jiàn)性是指,多線程訪問(wèn)主內(nèi)存的某一個(gè)資源時(shí),如果某一個(gè)線程在自己的工作內(nèi)存中修改了該資源,并寫(xiě)回主內(nèi)存,那么JMM內(nèi)存模型應(yīng)該要通知其他線程來(lái)從新獲取最新的資源,來(lái)保證最新資源的可見(jiàn)性。
1.2、volatile保證可見(jiàn)性的代碼驗(yàn)證
在1.1中,已經(jīng)基本理解了可見(jiàn)性的含義,接下來(lái)用代碼來(lái)驗(yàn)證一下,volatile確實(shí)可以保證可見(jiàn)性。
1.2.1、無(wú)可見(jiàn)性代碼驗(yàn)證
首先先驗(yàn)證下,不使用volatile,是不是就是沒(méi)有可見(jiàn)性。
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(); // 啟動(dòng)一個(gè)線程修改myData的number,將number的值加10 new Thread( () -> { System.out.println("線程" + Thread.currentThread().getName()+"t 正在執(zhí)行"); try{ TimeUnit.SECONDS.sleep(3); } catch (Exception e) { e.printStackTrace(); } myData.add10(); System.out.println("線程" + Thread.currentThread().getName()+"t 更新后,number的值為" + myData.number); } ).start(); // 看一下主線程能否保持可見(jiàn)性 while (myData.number == 0) { // 當(dāng)上面的線程將number加10后,如果有可見(jiàn)性的話,那么就會(huì)跳出循環(huán); // 如果沒(méi)有可見(jiàn)性的話,就會(huì)一直在循環(huán)里執(zhí)行 } System.out.println("具有可見(jiàn)性!"); }}
運(yùn)行結(jié)果如下圖,可以看到雖然線程0已經(jīng)將number的值改為了10,但是主線程還是在循環(huán)中,因?yàn)榇藭r(shí)number不具有可見(jiàn)性,系統(tǒng)不會(huì)主動(dòng)通知。
1.2.1、volatile保證可見(jiàn)性驗(yàn)證
在上面代碼的第7行給變量number添加volatile后再次測(cè)試,如下圖,此時(shí)主線程成功退出了循環(huán),因?yàn)镴MM主動(dòng)通知了主線程更新number的值了,number已經(jīng)不為0了。
2、volatile不保證原子性
2.1 什么是原子性?
理解了上面說(shuō)的可見(jiàn)性之后,再來(lái)理解下什么叫原子性?
原子性是指不可分隔,完整性,即某個(gè)線程正在做某個(gè)業(yè)務(wù)時(shí),中間不能被分割。要么同時(shí)成功,要么同時(shí)失敗。
還是有點(diǎn)抽象,接下來(lái)舉個(gè)例子。
如下圖,創(chuàng)建了一個(gè)測(cè)試原子性的類:TestPragma。在add方法中將n加1,通過(guò)查看編譯后的代碼可以看到,n++被拆分為3個(gè)指令進(jìn)行執(zhí)行。
因此可能存在線程1正在執(zhí)行第1個(gè)指令,緊接著線程2也正在執(zhí)行第1個(gè)指令,這樣當(dāng)線程1和線程2都執(zhí)行完3個(gè)指令之后,很容易理解,此時(shí)n的值只加了1,而實(shí)際是有2個(gè)線程加了2次,因此這種情況就是不保證原子性。
2.2 不保證原子性的代碼驗(yàn)證
在2.1中已經(jīng)進(jìn)行了舉例,可能存在2個(gè)線程執(zhí)行n++的操作,但是最終n的值卻只加了1的情況,接下來(lái)對(duì)這種情況再用代碼進(jìn)行演示下。
首先給MyData類添加一個(gè)add方法
package com.koping.test;class MyData { volatile int number = 0; public void add() { number++; }}
然后創(chuàng)建測(cè)試原子性的類:TestPragmaDemo。測(cè)試下20個(gè)線程給number各加1000次之后,number的值是否是20000。
package com.koping.test;public class TestPragmaDemo { public static void main(String[] args) { MyData myData = new MyData(); // 啟動(dòng)20個(gè)線程,每個(gè)線程將myData的number值加1000次,那么理論上number值最終是20000 for (int i=0; i<20; i++) { new Thread(() -> { for (int j=0; j<1000; j++) { myData.add(); } }).start(); } // 程序運(yùn)行時(shí),模型會(huì)有主線程和守護(hù)線程。如果超過(guò)2個(gè),那就說(shuō)明上面的20個(gè)線程還有沒(méi)執(zhí)行完的,就需要等待 while (Thread.activeCount()>2){ Thread.yield(); } System.out.println("number值加了20000次,此時(shí)number的實(shí)際值是:" + myData.number); }}
運(yùn)行結(jié)果如下圖,最終number的值僅為18410。
可以看到即使加了volatile,依然不保證有原子性。
2.3 volatile不保證原子性的解決方法
上面介紹并證明了volatile不保證原子性,那如果希望保證原子性,怎么辦呢?以下提供了2種方法
2.3.1 方法1:使用synchronized
方法1是在add方法上添加synchronized,這樣每次只有1個(gè)線程能執(zhí)行add方法。
結(jié)果如下圖,最終確實(shí)可以使number的值為20000,保證了原子性。
但是,實(shí)際業(yè)務(wù)邏輯方法中不可能只有只有number++這1行代碼,上面可能還有n行代碼邏輯?,F(xiàn)在為了保證number的值是20000,就把整個(gè)方法都加鎖了(其實(shí)另外那n行代碼,完全可以由多線程同時(shí)執(zhí)行的)。所以就優(yōu)點(diǎn)殺雞用牛刀,高射炮打蚊子,小題大做了。
package com.koping.test;class MyData { volatile int number = 0; public synchronized void add() { // 在n++上面可能還有n行代碼進(jìn)行邏輯處理 number++; }}
2.3.2 方法1:使用JUC包下的AtomicInteger
給MyData新曾一個(gè)原子整型類型的變量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行代碼進(jìn)行邏輯處理 number++; num.getAndIncrement(); }}
讓num也同步加20000次。結(jié)果如下圖,可以看到,使用原子整型的num可以保證原子性,也就是number++的時(shí)候不會(huì)被搶斷。
package com.koping.test;public class TestPragmaDemo { public static void main(String[] args) { MyData myData = new MyData(); // 啟動(dòng)20個(gè)線程,每個(gè)線程將myData的number值加1000次,那么理論上number值最終是20000 for (int i=0; i<20; i++) { new Thread(() -> { for (int j=0; j<1000; j++) { myData.add(); } }).start(); } // 程序運(yùn)行時(shí),模型會(huì)有主線程和守護(hù)線程。如果超過(guò)2個(gè),那就說(shuō)明上面的20個(gè)線程還有沒(méi)執(zhí)行完的,就需要等待 while (Thread.activeCount()>2){ Thread.yield(); } System.out.println("number值加了20000次,此時(shí)number的實(shí)際值是:" + myData.number); System.out.println("num值加了20000次,此時(shí)number的實(shí)際值是:" + myData.num); }}
3、volatile禁止指令重排
3.1 什么是指令重排?
在第2節(jié)中理解了什么是原子性,現(xiàn)在要理解下什么是指令重排?
計(jì)算機(jī)在執(zhí)行程序時(shí),為了提高性能,編譯器和處理器常常會(huì)對(duì)指令進(jìn)行重排:
源代碼–>編譯器優(yōu)化重排–>指令并行重排–>內(nèi)存系統(tǒng)重排–>最終執(zhí)行指令
處理器在進(jìn)行重排時(shí),必須要考慮指令之間的數(shù)據(jù)依賴性。
單線程環(huán)境中,可以確保最終執(zhí)行結(jié)果和代碼順序執(zhí)行的結(jié)果一致。
但是多線程環(huán)境中,線程交替執(zhí)行,由于編譯器優(yōu)化重排的存在,兩個(gè)線程使用的變量能否保持一致性是無(wú)法確定的,結(jié)果無(wú)法預(yù)測(cè)。
看了上面的文字性表達(dá),然后看一個(gè)很簡(jiǎn)單的例子。
比如下面的mySort方法,在系統(tǒng)指令重排后,可能存在以下3種語(yǔ)句的執(zhí)行情況:
1)1234
2)2134
3)1324
以上這3種重排結(jié)果,對(duì)最后程序的結(jié)果都不會(huì)有影響,也考慮了指令之間的數(shù)據(jù)依賴性。
public void mySort() { int x = 1; // 語(yǔ)句1 int y = 2; // 語(yǔ)句2 x = x + 3; // 語(yǔ)句3 y = x * x; // 語(yǔ)句4}
3.2 單線程單例模式
看完指令重排的簡(jiǎn)單介紹后,然后來(lái)看下單例模式的代碼。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "t 執(zhí)行構(gòu)造方法SingletonDemo()"); } public static SingletonDemo getInstance() { if (instance == null) { instance = new SingletonDemo(); } return instance; } public static void main(String[] args) { // 單線程測(cè)試 System.out.println("單線程的情況測(cè)試開(kāi)始"); System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance()); System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance()); System.out.println("單線程的情況測(cè)試結(jié)束n"); }}
首先是在單線程情況下進(jìn)行測(cè)試,結(jié)果如下圖。可以看到,構(gòu)造方法只執(zhí)行了一次,是沒(méi)有問(wèn)題的。
3.3 多線程單例模式
接下來(lái)在多線程情況下進(jìn)行測(cè)試,代碼如下。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "t 執(zhí)行構(gòu)造方法SingletonDemo()"); } public static SingletonDemo getInstance() { if (instance == null) { instance = new SingletonDemo(); } // DCL(Double Check Lock雙端檢索機(jī)制)// if (instance == null) {// synchronized (SingletonDemo.class) {// if (instance == null) {// instance = new SingletonDemo();// }// }// } return instance; } public static void main(String[] args) { // 單線程測(cè)試// System.out.println("單線程的情況測(cè)試開(kāi)始");// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println("單線程的情況測(cè)試結(jié)束n"); // 多線程測(cè)試 System.out.println("多線程的情況測(cè)試開(kāi)始"); for (int i=1; i<=10; i++) { new Thread(() -> { SingletonDemo.getInstance(); }, String.valueOf(i)).start(); } }}
在多線程情況下的運(yùn)行結(jié)果如下圖??梢钥吹剑嗑€程情況下,出現(xiàn)了構(gòu)造方法執(zhí)行了2次的情況。
3.4 多線程單例模式改進(jìn):DCL
在3.3中的多線程單里模式下,構(gòu)造方法執(zhí)行了兩次,因此需要進(jìn)行改進(jìn),這里使用雙端檢鎖機(jī)制:Double Check Lock, DCL。即加鎖之前和之后都進(jìn)行檢查。
package com.koping.test;public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo() { System.out.println(Thread.currentThread().getName() + "t 執(zhí)行構(gòu)造方法SingletonDemo()"); } public static SingletonDemo getInstance() {// if (instance == null) {// instance = new SingletonDemo();// } // DCL(Double Check Lock雙端檢鎖機(jī)制) if (instance == null) { // a行 synchronized (SingletonDemo.class) { if (instance == null) { // b行 instance = new SingletonDemo(); // c行 } } } return instance; } public static void main(String[] args) { // 單線程測(cè)試// System.out.println("單線程的情況測(cè)試開(kāi)始");// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());// System.out.println("單線程的情況測(cè)試結(jié)束n"); // 多線程測(cè)試 System.out.println("多線程的情況測(cè)試開(kāi)始"); for (int i=1; i<=10; i++) { new Thread(() -> { SingletonDemo.getInstance(); }, String.valueOf(i)).start(); } }}
在多次運(yùn)行后,可以看到,在多線程情況下,此時(shí)構(gòu)造方法也只執(zhí)行1次了。
3.5 多線程單例模式改進(jìn),DCL版存在的問(wèn)題
需要注意的是3.4中的DCL版的單例模式依然不是100%準(zhǔn)確的?。。?/p>
是不是不太明白為什么3.4DCL版單例模式不是100%準(zhǔn)確的原因?
是不是不太明白在3.1講完指令重排的簡(jiǎn)單理解后,為什么突然要講多線程的單例模式?
因?yàn)?.4DCL版單例模式可能會(huì)由于指令重排而導(dǎo)致問(wèn)題,雖然該問(wèn)題出現(xiàn)的可能性可能是千萬(wàn)分之一,但是該代碼依然不是100%準(zhǔn)確的。如果要保證100%準(zhǔn)確,那么需要添加volatile關(guān)鍵字,添加volatile可以禁止指令重排。
接下來(lái)分析下,為什么3.4DCL版單例模式不是100%準(zhǔn)確?
查看instance = new SingletonDemo();編譯后的指令,可以分為以下3步:
1)分配對(duì)象內(nèi)存空間:memory = allocate();
2)初始化對(duì)象:instance(memory);
3)設(shè)置instance指向分配的內(nèi)存地址:instance = memory;
由于步驟2和步驟3不存在數(shù)據(jù)依賴關(guān)系,因此可能出現(xiàn)執(zhí)行132步驟的情況。
比如線程1執(zhí)行了步驟13,還沒(méi)有執(zhí)行步驟2,此時(shí)instance!=null,但是對(duì)象還沒(méi)有初始化完成;
如果此時(shí)線程2搶占到cpu,然后發(fā)現(xiàn)instance!=null,然后直接返回使用,就會(huì)發(fā)現(xiàn)instance為空,就會(huì)出現(xiàn)異常。
這就是指令重排可能導(dǎo)致的問(wèn)題,因此要想保證程序100%正確就需要加volatile禁止指令重排。
3.6 volatile保證禁止指令重排的原理
在3.1中簡(jiǎn)單介紹了下執(zhí)行重排的含義,然后通過(guò)3.2-3.5,借助單例模式來(lái)舉例說(shuō)明多線程情況下,為什么要使用volatile的原因,因?yàn)榭赡艽嬖谥噶钪嘏艑?dǎo)致程序異常。
接下來(lái)就介紹下volatile能保證禁止指令重排的原理。
首先要了解一個(gè)概念:內(nèi)存屏障(Memory Barrier),又稱為內(nèi)存柵欄。它是一個(gè)CPU指令,有2個(gè)作用:
1)保證特定操作的執(zhí)行順序;
2)保證某些變量的內(nèi)存可見(jiàn)性;
由于編譯器和處理器都能執(zhí)行指令重排。如果在指令之間插入一條Memory Barrier則會(huì)告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說(shuō),通過(guò)插入內(nèi)存屏障,禁止在內(nèi)存屏障前后的指令執(zhí)行重排需優(yōu)化。
內(nèi)存屏障的另一個(gè)作用是強(qiáng)制刷出各種CPU的緩存數(shù)據(jù),因此任何CPU上的線程都能讀取到這些數(shù)據(jù)的最新版本。
推薦學(xué)習(xí):《java視頻教程》