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++),實際上包含了三個操作:
- 讀取變量 a 的值
- 對 a 進行加一的操作
- 將計算后的值再賦值給變量 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 規定禁止指令重排,從而保證數據的一致性。