volatile關鍵字和Java內存模型學習

1.Java內存模型(JMM)

  • Java內存模型的主要目標是定義程序中各個變量的訪問規則,所謂的變量訪問規則我們可以簡單理解爲Java程序在工作過程中,對變量處理的方式。而基於JMM的訪問規則主要是爲了保證多線程併發的時候數據會產生安全性的問題。這些訪問規則都是抽象的概念,我們需要通過編程的手段來保證。

    • 當然要強調一下這裏所說的變量不包含局部變量,即使在併發的場景下局部變量也是私有的,只有當前線程可見。
  • Java內存模型規定所有的變量存儲在主內存中,每條線程有自己的工作內存,線程的工作內存中保存了被該線程使用到的變量和主內存副本拷貝,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中完成,不能直接讀寫主內存中的變量(下面要說的volatile也不例外)。線程之間也無法直接訪問對方工作內存中的變量,傳遞需要通過主內存完成。
    在這裏插入圖片描述

  • 訪問規則中規定,在併發場景中,需要保證三個特性:

    • 可見性

    • 有序性

    • 原子性

2.volatile 講解

2.1 volatile 保證內存可見性

我們先來看一段代碼:

/**
 * 商品
 */
public class Goods {
    //變量stock代表商品庫存
    private Integer stock = 1;

    //執行庫存減一操作
    public void decrStock() {
        this.stock = this.stock - 1;
        System.out.println(Thread.currentThread().getName()+"\t剩餘庫存:"+stock);
    }

    public Integer getStock() {
        return this.stock;
    }
}
public class VolatileDemo {
    public static void main(String[] args) {
        final Goods goods = new Goods();

        new Thread("A") {
            public void run() {
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                goods.decrStock();
            }
        }.start();

        new Thread("B") {
            public void run() {
                // 如果庫存大於0則一直死等
                while (goods.getStock() > 0) {
                }
                System.out.println(Thread.currentThread().getName() + "\t 現有庫存: " + goods.getStock());
            }
        }.start();
		//讓出CPU時間片
        Thread.yield();

    }
}
  • 上面的案例中A、B兩個線程共同操作goods的stock, 爲了演示效果,我這裏讓main線程主動讓出CPU時間片, 主要分析A、B線程的執行。
    • 我們的目的是測試在A線程執行了減庫存操作後,B線程能不能知道內存變量stock值的改變,當我們運行這段程序會發現,A線程執行減庫存操作後,B線程會一直處於死等狀態,說明B線程認爲stock的值仍是大於0的,並沒有及時的知道A線程已經減庫存爲0的事。 這便是內存可見性的問題場景。
    • 假設在併發的場景下出現這種情況,會發生什麼問題呢?
      • 如果A和B都是執行的購買操作,而當前庫存只剩下1個,後果就是庫存會產生負值,顯然這是不允許的。接下來我們將Goods稍作修改:
/**
 * 商品
 */
public class Goods {
    //變量stock代表商品庫存
    private volatile Integer stock = 1;

    //執行庫存減一操作
    public void decrStock() {
        this.stock = this.stock - 1;
        System.out.println(Thread.currentThread().getName()+"\t剩餘庫存:"+stock);
    }

    public Integer getStock() {
        return this.stock;
    }
}
  • 我們給stock使用了volatile進行修飾:
private volatile Integer stock = 1;
  • 再次運行測試程序,B線程可以及時知道變量stock被修改了。

小結:這便是volatile的第一個作用,保證內存的可見性。

2.2 volatile 保證有序性 - 防止指令重排

  • 爲了便於理解我們以生活中的考試爲例來說明什麼是指令重排: 考試的過程中,我們並不一定會完全按照試卷出題的順序進行答題,爲了保證考試的效果,通常我們會調整答題的順序,比如把一些較難的題放到最後做,這是一種優化考試答題的手段。

  • 同樣計算機在執行程序時,爲了提高性能,編譯器和處理器常常會對指令的執行做重排序。

    • 不管是Java程序還是計算機中任何的應用程序,最終都要轉成計算機可以直接執行的指令,這些指令就相當於一個個的考試題,而計算機也"比較聰明",他在執行的過程中會調整指令執行的順序,保證更有效的利用CPU資源,保證程序執行性能。
  • 指令重排的依賴性(哪些情況不允許指令重排):

    • 數據依賴性,舉個例子:
    名稱       代碼示例         說明  
    寫後讀     a = 1;b = a;    寫一個變量之後,再讀這個位置。  
    寫後寫     a = 1;a = 2;    寫一個變量之後,再寫這個變量。  
    讀後寫     a = b;b = 1;    讀一個變量之後,再寫這個變量。
    

    上面的每組指令中都有寫操作,這個寫操作的位置是不允許變化的,否則將帶來不一樣的執行結果,此時編譯器將不會對存在數據依賴性的程序指令進行重排,這裏的依賴性僅僅指單線程情況下的數據依賴性;多線程併發情況下,此規則將失效。

    • as-if-serial語義:不管怎麼重排序,必須保證單線程程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守as-if-serial語義。

    此處參考:https://www.cnblogs.com/tuhooo/p/7921651.html

  • 這裏我們主要說Java程序編譯器中也會存在指令重排的現象,比如:

public class SingletonDemo {
    //用於防止指令重排
    private static volatile SingletonDemo singletonDemo = null;
    private SingletonDemo() {
        System.out.println("invoke SingletonDemo Constructor");
    }
    public static SingletonDemo getInstance() {
        // 採用雙鎖檢測機制
        if (singletonDemo == null) {
            synchronized (SingletonDemo.class) {
                if (singletonDemo == null) {
                    singletonDemo = new SingletonDemo();
                }
            }
        }
        return singletonDemo;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread() {
                @Override
                public void run() {
                    SingletonDemo.getInstance();
                }
            }.start();
        }

    }

}
  • 上面的程序是單例設計模式的實現,在變量singletonDemo使用了volatile修飾,目的是防止指令重排產生的問題,下面我們分析一下爲什麼要在singletonDemo上使用volatile:
    • 首先我們看一下,一個對象的創建過程到給引用賦值的順序:
      • 初始化空間
      • 創建對象
      • 將對象的空間地址賦值給變量
    • 上面的執行順序,在存在指令重排的場景下,會變爲:
      • 初始化空間
      • 將對象的空間地址賦值給變量
      • 創建對象
    • 如果在併發的場景下,按照上面重排後的順序執行,就會產生將一個指向空的內存空間地址的引用返回,影響程序最終的執行結果,嚴格來講這是不允許發生的。所以我們使用volatile來防止指令重排產生的問題。

關於有序性,這裏我們主要了解程序在執行過程中存在指令重排的機率,而這種機率會給程序帶來不安全的風險,而使用volatile可以避免這種風險就可以。關於更底層的實現感興趣的碼友鑽研一下吧!

2.3 volatile 能保證原子性嗎?

  • 答案: 不能
  • 案例1:
class Account {
    private volatile int num = 0;
    public int getNum() {
        return n;
    }
    public void addOne() {
        n++; // n = n+1;
    }
}
public class VolatileTest2 {
    public static void main(String[] args) {
        final Account account = new Account();
        for (int i = 0; i < 10; i++) {
            new Thread(){
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        account2.addOne();
                    }
                }
            }.start();
        }
        //確保上面的10個線程執行完畢後 ,打印n的結果
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(account.getNum());
    }
}

多次運行結果是:

第一次:75103
第二次:69025
第三次:80708

數據丟失了!(思考爲什麼呢?)

  • 案例繼續:
class Account {
    private volatile int num = 0;
    public int getNum() {
        return n;
    }
    public synchronized void addOne() {
        n++; // n = n+1;
    }
}

加上synchronized, 問題解決!

  • 因爲i++操作並不是原子性的,執行是需要先讀取變量值到操作數棧、執行運算、寫回主內存。 而volatile是無法保證原子操作的,所以第一段程序的結果是不對的。而synchronized是可以保證原子性的。

總結:volatile的主要作用是保證可見性、有序性。

本文參考:《深入理解Java虛擬機——JVM高級特性與最佳實踐(第2版)》 共同學習,不當之處請批評指正!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章