作者: 雅各布·詹科夫
原文: http://tutorials.jenkov.com/j...
翻譯: 潘深練個人網站 如您有更好的翻譯版本,歡迎 ❤️ 提交 issue 或投稿哦~
更新: 2022-02-24
Java的volatile
關鍵字用於將Java變數標記為“儲存在主記憶體中”。更準確地說,每次對volatile
變數的讀取都將從計算機主記憶體中讀取,而不是從CPU快取中讀取,並且每次對volatile
變數的寫入都將寫入主記憶體,而不僅僅寫在CPU快取。
事實上,自從 Java5 開始,volatile
關鍵字就不僅僅被用來保證 volatile
變數讀寫主記憶體。我將在以下內容解釋這一點。
Java volatile 教程視訊
如果你喜歡視訊,我在這裡有這個 Java volatile
教程的視訊版本:
Java volatile 教程視訊
變數可見性問題
Java的volatile
關鍵字在多執行緒處理中保證了共享變數的“可見性”。這聽起來可能有點抽象,所以讓我詳細說明。
在多執行緒應用程式中,如果多個執行緒對同一個無宣告volatile
關鍵詞的變數進行操作,出於效能原因,每個執行緒可以在處理變數時將變數從主記憶體複製到CPU快取中。如果你的計算機擁有多CPU,則每個執行緒可能在不同的CPU上執行。這就意味著,每個執行緒都可以將變數複製在不同CPU的CPU快取上。這在此處進行了說明:
對於無宣告volatile
關鍵詞的變數而言,無法保證Java虛擬機器(JVM)何時將資料從主記憶體讀取到CPU快取,或者將資料從CPU快取寫入主記憶體。這就可能會導致幾個問題,我將在以下部分內容解釋這些問題。
想象一個場景,多個執行緒訪問一個共享物件,該物件包含一個宣告如下的計數器(counter)變數:
public class SharedObject {
public int counter = 0;
}
假設只有執行緒1會增加計數器(counter)變數的值,但是執行緒1和執行緒2會不時的讀取這個計數器變數。
如果計數器(counter)變數沒有宣告volatile
關鍵詞,則無法保證計數器變數的值何時從CPU快取寫回主記憶體。這就意味著,每個CPU快取上的計數器變數值和主記憶體中的變數值可能不一致。這種情況如下所示:
一個執行緒的寫操作還沒有寫回主記憶體(每個執行緒都有本地快取,即CPU快取,一般寫入成功會從cpu快取重新整理至主記憶體),其他執行緒看不到變數的最新值,這就是“可見性”問題,即一個執行緒的更新對其他執行緒是不可見的。
Java volatile 可見性保證
Java的volatile
關鍵字就是為了解決變數的可見性問題。通過對計數器(counter)變數宣告volatile
關鍵字,所有執行緒對該變數的寫入都會被立即同步到主記憶體中,並且,所有執行緒對該變數的讀取都會直接從主記憶體讀取。
以下是計數器(counter)變數宣告瞭關鍵字volatile
的用法:
public class SharedObject {
public volatile int counter = 0;
}
因此,宣告瞭volatile
關鍵字的變數,保證了其他執行緒對該變數的寫入可見性。
在以上給出的場景中,一個執行緒(T1)修改了計數器變數,而另一個執行緒(T2)讀取計數器變數(但是沒有進行修改),這種場景下如果給計數器(counter)變數宣告volatile
關鍵字,就能夠保證計數器(counter)變數的寫入對執行緒(T2)是可見的。
但是如果執行緒(T1)和執行緒(T2)都對計數器(counter)變數進行了修改,那麼給計數器(counter)變數宣告volatile
關鍵字是無法保證可見性的,稍後討論。
volatile 全域性可見性保證
實際上,Java的volatile
關鍵字可見性保證超過了volatile
變數本身的可見性,可見性保證如下:
- 如果執行緒A寫入一個
volatile
變數,而執行緒B隨後讀取了同一個volatile
變數,那麼所有變數的可見性,線上程A寫入volatile
變數之前對執行緒A可見,線上程B讀取volatile
變數之後對執行緒B同樣可見。 - 如果執行緒A讀取一個
volatile
變數,那麼讀取volatile
變數時,對執行緒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;
}
}
udpate()
方法寫入三個變數,其中只有變數days宣告為volatile
。
volatile
關鍵字宣告的變數,被寫入時會直接從本地執行緒快取重新整理到主記憶體。
volatile
的全域性可見性保證,指的是當一個值被寫入days
時,所有對當前寫入執行緒可見的變數也都會被寫入到主記憶體。意思就是當一個值被寫入days
變數時,year
變數和months
變數也會被寫入到主記憶體。
在讀years
,months
和days
的值時,你可以這樣做:
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;
}
}
注意,totalDays()
方法會首先讀取days
變數的值到total變數中,當程式讀取days
變數時,也會從主記憶體讀取month
變數和years
變數的值。因此你可以通過以上的讀取順序,來保證讀取到三個變數days
,months
和years
最新的值。
指令重排序的挑戰
為了提高效能,一般允許 JVM 和 CPU 在保證程式語義不變的情況下對程式中的指令進行重新排序。例如:
int a = 1;
int b = 2;
a++;
b++;
這些指令可以重新排序為以下順序,而不會丟失程式的語義含義:
int a = 1;
a++;
int b = 2;
b++;
然而,當其中一個變數是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變數的最新值也會被寫入到主記憶體當中。但是,如果Java虛擬機器對指令進行重排,例如這樣:
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
當修改days
變數時,仍然會將months
變數和years
變數的值寫入主記憶體,但是這個節點是發生在新值寫入months
變數和years
變數之前。因此months
變數和years
變數的最新值不可能正確地對其他執行緒可見。這種重排指令會導致語義發生改變。
針對這個問題Java提供了一個解決方案,我們往下看。
Java volatile Happens-Before 規則
為了解決指令重新排序的挑戰,除了可見性保證之外,Java的volatile
關鍵字還提供了Happens-Before規則。Happens-Before規則保證:
如果其他變數的讀寫操作原先就發生在
volatile
變數的寫操作之前,那麼其他變數的讀寫指令不能被重排序到volatile變數的寫指令之後;- 在
volatile
變數寫入之前,發生的其他變數的讀寫,Happens-Before 於volatile
變數的寫入。
- 在
注意:例如在volatile
變數寫入之後的其他變數讀寫,仍然可能被重排到volatile
變數寫入之前。只不過不能反著來,允許後面的讀寫重排到前面,但不允許前面的讀寫重排到後面。
- 如果其他變數的讀寫操作原先就發生在
volatile
變數讀操作之後,那麼其他變數的讀寫指令不能被重排序到volatile變數的讀指令之前;
注意:例如在volatile
變數讀之前的其他變數讀取,可能被重排到volatile
變數的讀之後。只不過不能反著來,允許前面的讀取重排到後面,但不允許後面的讀取重排到前面。
上述的Happens-Before規則,確保了volatile
關鍵字的可見性保證會被強制要求。
僅宣告 volatile 不足以保證執行緒安全
即使volatile
關鍵字保證直接從主記憶體讀取volatile
變數,並且所有對volatile
變數的寫入都直接寫入主記憶體,在某些情況下僅僅宣告變數volatile
是不足以保證執行緒安全的。
在前面解釋的情況中,只有執行緒1寫入共享計數器變數,宣告計數器變數volatile足以確保執行緒2始終看到最新的寫入值。
事實上,如果寫入變數的新值不需要依賴之前的值,那多個執行緒可以同時對一個volatile
共享變數進行寫入操作,並且在主記憶體中仍然儲存正確的值。換而言之,如果一個執行緒僅對一個volatile
共享變數進行寫入操作,那並不需要先讀取出這個變數的值,再通過計算得到下一個值。
一旦執行緒需要首先讀取出volatile
變數的值,再基於該值為volatile
共享變數生成新值,那volatile
變數就不再足以保證正確的可見性。在讀取volatile
變數和寫入新值之間的短暫時間會產生資源競爭,存在多個執行緒同時來讀取volatile
變數並得到相同的值,且都為變數賦予新值,然後將值都寫回主記憶體中,從而會覆蓋掉彼此的值。
多個執行緒遞增同個計數器(counter)變數的情況,導致volatile
變數不夠保證執行緒安全性。 以下部分更詳細地解釋了這種情況:
想象一下,如果執行緒1將值為0的共享計數器(counter)變數讀入其CPU快取記憶體,則將其遞增為1並且還未將更改的值寫回主記憶體。 同時間執行緒2也可以從主記憶體中讀取到相同的計數器變數,其中變數的值仍為0,存進其自己的CPU快取記憶體。 然後,執行緒2也可以將計數器(counter)遞增到1,也還未將其寫回主記憶體。 這種情況如下圖所示:
執行緒1和執行緒2現在幾乎不同步。共享計數器(counter)變數的實際值應該是2,但每個執行緒在其CPU快取中的變數值為1,在主記憶體中該值仍然為0。真是一團糟!即使執行緒最終將其共享計數器變數的值寫回主記憶體,該值也將是錯誤的。
volatile 何時是執行緒安全的
正如我前面提到的,如果兩個執行緒都在讀取和寫入共享變數,那麼使用volatile
關鍵字是不足以保證執行緒安全的。一般這種情況下,您需要使用synchronized
來保證變數的讀取和寫入是原子性的。讀取或寫入volatile
變數不會阻塞其他執行緒讀取或寫入。為此,您必須在關鍵部分周圍使用synchronized
關鍵字。
作為synchronized
塊的替代方案,您可以選擇使用java.util.concurrent
併發包中的原子資料型別。 例如,AtomicLong
或AtomicReference
或其它之一。
如果只有一個執行緒讀取和寫入volatile
變數的值,而其他執行緒只讀取變數,那麼讀取執行緒將保證看到寫入volatile
變數的最新值。 如果不使變數變為volatile
,則無法保證。
volatile
關鍵字保證適用於32位和64位。
volatile 的效能注意事項
讀寫volatile
變數都會直接從主記憶體讀寫,比從CPU快取讀寫要花更多的開銷,但訪問volatile
變數可以阻止指令重排,這是一項正常的效能增強技術。因此,除非確實需要強制實施變數的可見性,否則其他情況減少使用volatile
變數。
(本篇完)
原文: http://tutorials.jenkov.com/j...
翻譯: 潘深練個人網站 如您有更好的翻譯版本,歡迎 ❤️ 提交 issue 或投稿哦~