一、volatile簡介
在單線程環(huán)境中,我們幾乎用不到這個關(guān)鍵詞,但是多線程環(huán)境中,這個關(guān)鍵詞隨處可見。而且也是面試的?????偟膩碚f,volatile有以下三個特性:
下面就來詳細的說說這三個特性。
二、保證可見性
1、什么是可見性?
在說volatile保證可見性之前,先來說說什么叫可見性。談到可見性,又不得不說JMM(java memory model)內(nèi)存模型。JMM內(nèi)存模型是邏輯上的劃分,并不是真實存在。Java線程之間的通信就由JMM控制。JMM的抽象示意圖如下:
JMM內(nèi)存模型如上圖所示,我們定義的共享變量,是存儲在主內(nèi)存中的,也就是計算機的內(nèi)存條中。線程A去操作共享變量的時候,并不能直接操作主內(nèi)存中的值,而是將主內(nèi)存中的值拷貝回自己的工作內(nèi)存中,在工作內(nèi)存中做修改。修改好后,再將值刷回到主內(nèi)存中。
假設(shè)現(xiàn)在new 一個 student , age為 18,這個18是存儲在主內(nèi)存中的。現(xiàn)在兩個線程先將18拷貝回自己的工作內(nèi)存中。這時,A線程將18改為了20,刷回到主內(nèi)存中。也就是說,現(xiàn)在主內(nèi)存中的值變?yōu)榱?0??墒?,B線程并不知道現(xiàn)在主內(nèi)存中的值變了,因為A線程所做的操作對B是不可見的。我們需要一種機制,即一旦主內(nèi)存中的值發(fā)生改變,就及時地通知所有的線程,保證他們對這個變化可見。這就是可見性。我們通常用happen - before(先行發(fā)生原則),來闡述操作之間內(nèi)存的可見性。也就是前一個的操作結(jié)果對后一個操作可見,那么這兩個操作就存在 happen - before 規(guī)則。
2、為什么volatile能保證可見性?
先來說一說內(nèi)存屏障(memory barrier),這是一條CPU指令,可以影響數(shù)據(jù)的可見性。當變量用volatile修飾時,將會在寫操作的后面加一條屏障指令,在讀操作的前面加一條屏障指令。這樣的話,一旦你寫入完成,可以保證其他線程讀到最新值,也就保證了可見性。
3、驗證volatile保證可見性。
驗證volatile可見性和不保證原子性的代碼:
1// 驗證可見性
2class MyData {
3 //int number = 0; // 沒加volatile關(guān)鍵字
4 volatile int number = 0;
5 int changeNumber() {
6 return this.number = 60;
7 }
8}
9
10public class VolatileTest {
11 // 驗證可見性
12 public static void main(String[] args) {
13 MyData myData = new MyData();
14 new Thread("AAA") {
15 public void run() {
16 try {
17 Thread.sleep(3000);
18 // 睡3秒后調(diào)用changeNumber方法將number改為60
19 System.err.println(Thread.currentThread().getName()
20 + " update number to " + myData.changeNumber());
21 } catch (InterruptedException e) {
22 e.printStackTrace();
23 }
24 };
25 }.start();
26 // 主線程
27 while (myData.number == 0) {
28 }
29 // 如果主線程讀取到的一直都是最開始的0,
30 //將造成死循環(huán),這句話將無法輸出
31 System.err.println(Thread.currentThread().getName()
32 + " get number value is " + myData.number);
33 }
34}
上面這段代碼很簡單,定義了一個MyData類,初始一個number,值為0。然后在main方法中創(chuàng)建另一個線程,將其值改為60。但是,這個線程對number所作的操作對main線程是不可見的,所以main線程以為number還是0,因此,將會造成死循環(huán)。如果number加了volatile修飾,main線程就可以獲取到主內(nèi)存中的最新值,就不會死循環(huán)。這就驗證了volatile可以保證可見性。
三、不保證原子性
1、什么叫原子性?
所謂原子性,就是說一個操作不可被分割或加塞,要么全部執(zhí)行,要么全不執(zhí)行。
2、volatile不保證原子性解析
java程序在運行時,JVM將java文件編譯成了class文件。我們使用javap命令對class文件進行反匯編,就可以查看到j(luò)ava編譯器生成的字節(jié)碼。最常見的 i++ 問題,其實 反匯編后是分三步進行的。
我們知道線程的執(zhí)行具有隨機性,假設(shè)現(xiàn)在i的初始值為0,有A和B兩個線程對其進行++操作。首先兩個線程將0拷貝到自己工作內(nèi)存,當線程A在自己工作內(nèi)存中進行了自增變成了1,還沒來得及把1刷回到主內(nèi)存,這是B線程搶到CPU執(zhí)行權(quán)了。B將自己工作內(nèi)存中的0進行自增,也變成了1。然后線程A將1刷回主內(nèi)存,主內(nèi)存此時變成了1,然后B也將1刷回主內(nèi)存,主內(nèi)存中的值還是1。本來A和B都對i進行了一次自增,此時主內(nèi)存中的值應(yīng)該是2,而結(jié)果是1,出現(xiàn)了寫丟失的情況。這是因為i++本應(yīng)該是一個原子操作,但是卻被加塞了其他操作。所以說volatile不保證原子性。
3、volatile不保證原子性驗證
1 // 驗證volatile不保證原子性
2 void addPlusPlus() {
3 this.number++;
4 }
5 // 驗證volatile不保證原子性
6 public static void main(String[] args) {
7 MyData mydata2 = new MyData();
8 for(int i = 0; i < 20; i ++ ) { // 創(chuàng)建20個線程
9 new Thread("線程" + i) {
10 public void run() {
11 try {
12 for(int j = 0; j < 1000; j++) {
13 mydata2.addPlusPlus();// 每個線程執(zhí)行1000次number++
14 }
15 } catch (Exception e) {
16 e.printStackTrace();
17 }
18 };
19 }.start();
20 }
21 // 保證上面的線程執(zhí)行完main線程再輸出結(jié)果。 大于2,因為默認有main線程和gc線程
22 while(Thread.activeCount() > 2) {
23 Thread.yield();
24 }
25 System.err.println(Thread.currentThread().getName() + " obtain the number is " + mydata2.number);
26 }
同樣是上面的MyData類,有一個volatile修飾的number變量初始值為0?,F(xiàn)在有20個線程,每個線程對其執(zhí)行1000次++操作。理論上執(zhí)行完后,main線程輸出的結(jié)果是20000,但是運行之后會發(fā)現(xiàn),每次的運行結(jié)果都會小于20000,這就是因為出現(xiàn)了寫丟失的情況。
解決辦法:
第一種辦法不太好,因為synchronized太重量級了,整個操作都加鎖了。第二種辦法更好。但是為什么AtomicInteger就可以保證原子性呢?因為它使用了CAS算法。什么是CAS?后續(xù)我再專門寫一篇介紹CAS的文章。
三、禁止指令重排
1、什么叫指令重排?
上面說了,使用javap命令可以對class文件進行反匯編,查看到程序底層到底是如何執(zhí)行的。像 i++ 這樣一個簡單的操作,底層就分三步執(zhí)行。在多線程情況下,計算機為了提高執(zhí)行效率,就會對這些步驟進行重排序,這就叫指令重排。比如現(xiàn)有如下代碼:
1int x = 1;
2int y = 2;
3x = x + 3;
4y = x - 4;
這四條語句,正常的執(zhí)行順序是從上往下1234這樣執(zhí)行,x的結(jié)果應(yīng)該是4,y的結(jié)果應(yīng)該是0。但是在多線程環(huán)境中,編譯器指令重排后,執(zhí)行順序可能就變成了1243,這樣得出的x就是4,y就是-3,這結(jié)果顯然就不正確了。不過編譯器在重排的時候也會考慮數(shù)據(jù)的依賴性,比如執(zhí)行順序不可能為2413,因為第4條語句的執(zhí)行是依賴x的。使用volatile修飾,就可以禁止指令重排。
四、你在哪些地方使用過volatile?
最經(jīng)典的就是單例模式。
1public class SingletonDemo {
2 private static SingletonDemo singletonDemo = null;
3 private SingletonDemo(){
4 System.err.println("構(gòu)造方法被執(zhí)行");
5 }
6 public static SingletonDemo getInstance(){
7 if (singletonDemo == null){
8 singletonDemo = new SingletonDemo();
9 }
10 return singletonDemo;
11 }
12}
這是我們最開始學(xué)的時候?qū)懙膯卫J?。看似很完美。其實多線程環(huán)境中就會出問題。測試一下:
1public static void main(String[] args){
2 for (int i = 0; i <= 10; i++){
3 new Thread(() -> SingletonDemo.getInstance()).start();
4 }
5}
10個線程去執(zhí)行這個單例,看結(jié)果:
運行結(jié)果
構(gòu)造方法被執(zhí)行這句話打印了兩次,說明創(chuàng)建了兩次對象。所以在多線程環(huán)境中這個單例模式是有問題的??梢栽趃etInstance方法上加synchronized,但是,這樣就把一整個方法都鎖了,這樣不太好。下面介紹另一種方式。 1public class SingletonDemo {
2 private static SingletonDemo singletonDemo = null;
3 private SingletonDemo(){
4 System.err.println("構(gòu)造方法被執(zhí)行");
5 }
6 public static SingletonDemo getInstance(){
7 if (singletonDemo == null){ // 第一次check
8 synchronized (SingletonDemo.class){
9 if (singletonDemo == null) // 第二次check
10 singletonDemo = new SingletonDemo();
11 }
12 }
13 return singletonDemo;
14 }
15 }
用synchronized只鎖住創(chuàng)建實例那部分代碼,而不是整個方法。在加鎖前和加鎖后都進行判斷,這就叫雙端檢索機制。經(jīng)測試,這樣確實只創(chuàng)建了一個對象。但是,這也并非絕對安全。new 一個對象也是分三步的:
步驟二和步驟三不存在數(shù)據(jù)依賴,因此編譯器優(yōu)化時允許這兩句顛倒順序。當指令重拍后,多線程去訪問也會出問題。所以便有了如下的最終版單例模式。
1public class SingletonDemo {
2 private static volatile SingletonDemo singletonDemo = null;
3 private SingletonDemo(){
4 System.err.println("構(gòu)造方法被執(zhí)行");
5 }
6 public static SingletonDemo getInstance(){
7 if (singletonDemo == null){ // 第一次check
8 synchronized (SingletonDemo.class){
9 if (singletonDemo == null) // 第二次check
10 singletonDemo = new SingletonDemo();
11 }
12 }
13 return singletonDemo;
14 }
15}
總結(jié):
1、volatile特性:
2、volatile的應(yīng)用:
最經(jīng)典的就是單例模式。