最近在重新梳理多線程,同步相關的知識點。關于 volatile 關鍵字閱讀了好多博客文章,發現質量高適合小白的不多,最終找到一篇英文的非常通俗易懂。所以學習過程中順手翻譯下來,一方面鞏固知識,一方面希望能幫到有需要的伙伴。該文章并非完全逐字翻譯,英文不錯的可以選擇閱讀原文:Java Volatile Keyword
基本用法
JAVA 語言里的 volatile 關鍵字是用來修飾變量的,方式如下入所示。表示:該變量需要直接存儲到主內存中。
public class SharedClass {
public volatile int counter = 0;
}
被 volatile 關鍵字修飾的 int counter 變量會直接存儲到主內存中。并且所有關于該變量的讀操作,都會直接從主內存中讀取,而不是從 CPU 緩存。(關于主內存和CPU緩存的區別,如果不理解也不用擔心,下面會詳細介紹)
這么做解決什么問題呢?主要是兩個問題:
- 多線程間可見性的問題,
- CPU 指令重排序的問題
注:為了描述方便,我們接下來會把 volatile 修飾的變量簡稱為“volatile 變量”,把沒有用 volatile 修飾的變量建成為“non-volatile”變量。
理解 volatile 關鍵字
變量可見性問題(Variable Visibility Problem)
Volatile 可以保證變量變化在多線程間的可見性。
在一個多線程應用中,出于計算性能的考慮,每個線程默認是從主內存將該變量拷貝到線程所在CPU的緩存中,然后進行讀寫操作的。現在電腦基本都是多核CPU,不同的線程可能運行的不同的核上,而每個核都會有自己的緩存空間。如下圖所示(圖中的 CPU 1,CPU 2 大家可以直接理解成兩個核):
這里存在一個問題,JVM 既不會保證什么時候把 CPU 緩存里的數據寫到主內存,也不會保證什么時候從主內存讀數據到 CPU 緩存。也就是說,不同 CPU 上的線程,對同一個變量可能讀取到的值是不一致的,這也就是我們通常說的:線程間的不可見問題。比如下圖,Thread 1 修改的 counter = 7 只在 CPU 1 的緩存內可見,Thread 2 在自己所在的 CPU 2 緩存上讀取 counter 變量時,得到的變量 counter 的值依然是 0。
而 volatile 出現的用意之一,就是要解決線程間不可見性,通過 volatile 修飾的變量,都會變得線程間可見。
其解決方式就是文章開頭提到的:
通過 volatile 修飾的變量,所有關于該變量的讀操作,都會直接從主內存中讀取,而不是 CPU 自己的緩存。而所有該變量的寫操都會寫到主內存上。
因為主內存是所有 CPU 共享的,理所當然即使是不同 CPU 上的線程也能看到其他線程對該變量的修改了。
volatile 不僅僅只保證 volatile 變量的可見性
volatile 在可見性上所做的工作,實際上比保證 volatile 變量的可見性更多:
- 當 Thread A 修改了某個被 volatile 變量 V,另一個 Thread B 立馬去讀該變量 V。一旦 Thread B 讀取了變量 V 后,不僅僅是變量 V 對 Thread B 可見, 所有在 Thread A 修改變量 V 之前 Thread A 可見的變量,都將對 Thread B 可見。
- 當 Thread A 讀取一個 volatile 變量 V 時,所有對于 Thread A 可見的其他變量也都會從主內存中被讀取。
初次讀這兩句話可能會有些繞口,這里舉個例子:
public class MyClass {
private int years;
private int months
private volatile int days;
public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}
這個 MyClass 類中有一個 update 方法,會更新該類的所有3個變量:years,months,days。其中僅 days 是 volatile 變量。當 this.days = days 執行時,也就是當 days 變量的修改被寫到主內存時,所有該 Thread 可見的其他變量 years,months 也都會被寫到主內存中。換句話說,當 days 被修改后,years 和 months 的修改也會被其他線程可見。
再看一個關于讀的例子:
public class MyClass {
private int years;
private int months
private volatile int days;
public int totalDays() {
int total = this.days;
total += months * 30;
total += years * 365;
return total;
}
public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}
如果我們用另一個 Thread 調用同一個 MyClass 對象的 totalDays() ,在 int total = this.days 這一行被執行時,因為 days 是 volatile 變量,我們會從主內存中去讀取 days 的值,同時,所有對于該 Thread 可見的其他變量 months 和 years 也都會從主內存中被讀出。換句話說,該線程能夠獲取到最新的 days,months,years 的值。
以上就是關于 volatile 解決可見性問題的內容。
指令重排序挑戰
出于計算性能的考慮,JVM 和 CPU 允許在保證程序語義一致的范圍類,對程序內的指令進行重排序。舉個例子:
int a = 1;
int b = 2;
a++;
b++;
該代碼在經過重排序后可能會變成:
int a = 1;
a++;
int b = 2;
b++;
這兩段代碼的只能順序雖然不一樣,但是語義是相同的——都是定義兩個變量(int a = 1 和 int b = 2),然后分別 +1。乍一看,這種重排序沒有任何問題,但其實如果咱們把其中一個變量定義為 volatile 變量,此時我們再結合前面提到的可見性的延伸問題來看,大家可能會發現端倪。
還是以可見性問題中的 MyClass 類為例:
public class MyClass {
private int years;
private int months
private volatile int days;
public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}
在可見性的部分我們說過,這里的 update() 方法中,執行到修改 days 這一行時,關于 years 和 months 的修改也會同時被寫到主內存中。但如果 JVM 對此處的指令進行了重排序會發生什么?假設指令重排序后的 update() 執行過程如下:
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
days 的修改被提到了最前面,此時,months 和 years 的修改還沒有做,換句話說,此處 months 和 years 的修改并不能保證對其他線程可見。這么一來 volatile 關于可見性保證的延伸是不是就失效了?關于這一問題我們在實際使用 volatile 時并不會碰到,因為 JAVA 已經有解決方案:Happens-Before 規則。
Java volatile Happens-Before 規則
面對指令重排序對可見性的調整,volatile 采用 Happens-Before 規則解決:
- 任何原始執行順序中,在 volatile 變量寫指令之前的其他變量讀寫指令,在重新排序后,不可以被放到 volatile 寫指令之后。
所有原本就應該 volatile 變量寫指令前發生的其他變量讀寫指令,必須依然在其之前發生(Happens-Before)。 - 任何原始執行順序中,在 volatile 變量讀指令之后的其他變量讀寫指令,在重新排序后,不可以被放到 volatile 讀指令之前。
有了以上兩條 Happens-Before 規則,我們就避免了指令重排序對 volatile 可見性的影響。
volatile 不能保證原子性
多線程并發中我們經常提到的“三性”:可見性,有序性,原子性。雖然 volatile 可以保證可見性,有序性,但其并不能保證原子性。
當兩個線程 Thread 1 和 Thread 2 同時修改統一對象下的 volatile 變量 counter 時,比如同時執行 counter++。此時兩個線程讀取到的 counter 值可能都是 0,經過各線程的計算,他們認為 counter + 1 后的結果都是 1。最終雖然我們分別用兩個線程對 counter 變量做了 + 1 操作,可最終結果不是 2 而是 1。因此我們說 volatile 并不能保證該變量讀寫操作的原子性。
如果希望避免該問題,我們需要使用 synchronized 關鍵字。用 synchronized 關鍵字來修飾我們對變量讀寫操作(counter++)的方法/代碼塊,保證該讀寫操作的原子性。
除了 synchronized 關鍵字,我們還可以直接只用 AtomicInterger 類型定義 counter 變量。AtomicInteger 提供了針對 Integer 的原子操作。類似的類還有 AtomicBoolean 和 AtomicLong。
synchronized 和 AtomicXXX 類都可以保證原子性,前者是基于鎖的原理實現的原子性(悲觀鎖),而后者則是基于 CAS 原則(樂觀鎖)。
什么場景下我們只需要 volatile 就足夠呢?比如:當某個變量只會被一個線程修改,其他并行線程只會執行讀操作時,我們使用 volatile 就足以。
關于 Volatile 的性能問題
如果大家了解 CPU 的多級緩存機制,(不了解應該也能猜到),從主內存讀取數據的效率一定比從 CPU 緩存中讀取的效率低很多。包括指令重排序的目的也是為了提高計算效率,當重排序機制被限制時,計算效率也會相應收到影響。因此,我們應該只在需要保證變量可見性和有序性時,才使用 volatile 關鍵字。