使用 volatile 關鍵字保證變量可見性和禁止指令重排序



volatile 概述

volatile 是 Java 提供的一種輕量級的同步機制。相比於傳統的 synchronize,雖然 volatile 能實現的同步性要差一些,但開銷更低,因為它不會引起頻繁的線程上下文切換和調度。


為了更好的理解 volatile 的作用,首先要了解一下 Java 內存模型與並發編程三要素


Java 內存模型

Java 虛擬機規范中定義了 Java 內存模型(Java Memory Model,JMM),用於屏蔽各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平台下都能達到一致的並發效果。

JMM 規定了 Java 虛擬機與計算機內存如何協同工作:一個線程如何和何時可以看到由其他線程修改過后的共享變量的值,以及在必須時如何同步的訪問共享變量。注意這里的變量是指實例字段,靜態字段,構成數組對象的元素,不包括局部變量和方法參數(因為這是線程私有的),可以簡單理解為主內存是 Java 虛擬機內存區域中的堆,局部變量和方法參數是在虛擬機棧中定義的。如果堆中的變量在多線程中都被使用,就涉及到了堆和不同虛擬機棧中變量的值的一致性問題了。

Java 內存模型中涉及到的概念有:

  • 主內存

    Java 虛擬機規定所有的變量都必須在主內存中產生,該內存是線程公有的,為了方便理解,可以認為是堆區。

  • 工作內存

    Java 虛擬機中每個線程都有自己的工作內存,該內存是線程私有的,為了方便理解,可以認為是虛擬機棧。

Java 虛擬機規定,線程對主內存變量的修改必須在線程的工作內存中進行,不能直接讀寫主內存中的變量。不同的線程之間也不能相互訪問對方的工作內存。如果線程之間需要傳遞變量的值,必須通過主內存來作為中介進行傳遞。


並發編程三要素

在並發編程中,以下三要素是我們經常需要考慮的:

  • 原子性

    原子是世界上最小的單位,具有不可分割性。同理,將一個操作或多個操作視為一個整體,它們是不可再分的,並且要么全部成功,要么全部失敗,那么這個操作就具有原子性。

    int a = 10; //1
    a++; //2
    int b = a; //3
    a = a + 1; //4
    

    上面這四個語句中只有第 1 個語句是原子操作,將 10 賦值給線程工作內存的變量 a,而語句2(a++),實際上包含了三個操作:

    1. 讀取變量 a 的值
    2. 對 a 進行加一的操作
    3. 將計算后的值再賦值給變量 a,而這三個操作無法構成原子操作

    對語句 3,4 的分析同理可得這兩條語句不具備原子性。

  • 可見性

    指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。舉個簡單的例子:

    // 線程 1 執行的代碼
    int i = 0;
    i = 10;
    //線程 2 執行的代碼
    j = i;
    

    之前在 Java 內存模型已經講過,線程 1 執行 i = 10 時,會先把 i 的初始值加載到自己的工作內存,然后賦值為 10,卻沒有立即寫入到主存當中。此時線程 2 執行 j = i,它會先去主存讀取 i 的值並加載到自己的工作內存中,注意此時內存當中 i 的值還是 0,那么就會使得 j 的值為 0,而不是 10。

    這就是可見性問題,線程 1 對變量 i 修改了之后,線程 2 沒有立即看到線程 1 修改的值。

  • 有序性

    程序的執行順序按照代碼的先后順序執行。有序性從不同的角度來看是不同的,單純從單線程的角度來看,所有操作都是有序的,但到了多線程就不一樣了。可以這么說:如果在本線程內部觀察,所有操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。


volatile 保證變量可見性

假如有 A、B 兩個線程,主內存有變量 i = 0,A 線程將主內存中的 i 拷貝一份到自己的工作內存,並修改為 i = 1,但並沒有立即寫回到主內存,什么時候寫回主存是不確定的。此時 B 線程也將主內存中的 i 拷貝一份到自己的工作內存,而主內存中的 i 還是 0,並不是預想中的 1,這就可能導致一些問題。

volatile 的一個重要作用就是實現了變量可見性。當一個共享變量被 volatile 修飾,它會保證修改的值會立即更新到主存,當其他線程需要讀取時,它會去內存中讀取新值。


volatile 不保證原子性

假如有 A、B 兩個線程,同時對初始值為 0 的變量 i 做加 1 操作,我們希望最終的結果是 i = 2,但有可能並非如此,假設:

  • 線程 A 將共享內存 i = 0 拷貝到自己的工作內存,此時 A 的本地內存中 i = 1,但共享內存的 i 還是 0
  • 線程 B 將共享內存 i = 0 拷貝到自己的工作內存,此時 B 的本地內存中 i = 1,但共享內存的 i 還是 0
  • 線程 A 完成加 1 操作,此時 A 的本地內存中 i = 1,但共享內存的 i 還是 0,線程 A 將 i = 1 寫回到內存
  • 線程 B 完成加 1 操作,此時 B 的本地內存中 i = 1,共享內存的 i 已經是 1,線程 B 將 i = 1 寫回到內存
  • 最終共享內存中 i = 1,並不是我們預期的 i = 2

出現上述問題的原因是 i++ 並不是一個原子性的操作,Java 內存模型只保證了基本讀取和賦值是原子性操作。不同線程之間的操作交互執行,可能會出現漏洞。所以使用 volatile 必須具備以下兩個條件:

  • 對變量的寫操作不依賴於當前值
  • 該變量沒有包含在具有其他變量的不變式中

上述兩個條件其實就是要保證操作是原子性的。如果希望實現更大范圍操作的原子性,可以通過 synchronized 和 Lock 來實現。synchronized 和 Lock 能保證任一時刻只有一個線程執行該代碼塊,自然就不存在原子性問題。

volatile 禁止指令重排序

所謂指令重排序,是指計算機在執行程序時,為了提高性能,編譯器和處理器常常會對指令進行重排。指令重排必須保證最終執行結果和代碼順序執行結果一致。

public void mySort() {
	int x = 11;	// 1
	int y = 12; // 2
	x = x + 5;  // 3
	y = x * x;  // 4
}

正常的執行順序是 1、2、3、4,如果發生指令重排,就有可能會是 2、1、3、4,或者是 1、3、2、4 等等,但不會出現 4、3、2、1 這樣的情況,因為處理器在進行重排時,必須考慮到指令之間的數據依賴性。

在單線程下指令重排是沒有問題的,但如果是多線程就不一定了,假設主存中有 a,b,x,y 四個變量(保證了可見性),初始值都是 0,有 A、B 兩個線程,它們各自順序執行時操作如下:

  • 線程 A
    • x = a
    • b = 1
  • 線程 B
    • y = b
    • a = 2

無論兩個線程之間的操作如何交錯,最終結果都是 x = 0,y = 0(不考慮線程 A 走完再到線程 B 的情況,因為這樣就和單線程沒有差異了)。可如果發生了指令重排,此時它們各自的操作執行順序可能變為:

  • 線程 A
    • b = 1
    • x = a
  • 線程 B
    • a = 2
    • y = b

這樣造成的結果就是 x = 2,y = 1,和上面的不一致了。因此為了防止這種情況,volatile 規定禁止指令重排,從而保證數據的一致性。



免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM