線程池和ReentrantLock背后的最強支柱:volatile
一、前言
在前幾篇文章中,我們在分析線程池和ReentrantLock的時候,其內部實現大量用到了volatile關鍵字來修飾變量,前面我們也簡單分析過使用volatile是為了用它的內存可見性。除了內存可見性,它還有哪些能力呢?這篇文章來詳細告訴你。
二、大象裝進冰箱的case
給你一臺足夠大的冰箱,把大象塞進去至少需要三步,第一步打開冰箱門,第二步將大象搬進去,第三步將冰箱門關上。我們來假設一個場景:冰箱只有一臺且同一時刻只能放入一只大象,但在某一時刻有5只大象都要進入冰箱降暑,那么在大象裝進冰箱這件事情的整個過程中,中間任一步驟失敗就會直接導致整件事情的失敗。如果不想存在中間過程中出現失敗的可能,只有一個辦法這件事件的三個步驟合三為一,使其成為一個整體,從外部看就像只有一個“將大象塞進冰箱”動作。我們在多線程環境下對一個變量進行操作時,會經常遇到這種問題,下面我們來看看如何完美解決。
二、Java內存模型
想要完美解決多線程下對同一變量進行安全操作,我們得先要了解清楚Java內存模型,內存模型如下圖所示
圖片
- Java內存模型規定了所有的變量都必須存儲在主內存中,而每條工作線程有自己的工作內存,工作內存中存儲的的是該線程執行過程中臨時用到的變量信息,這些信息都是從主內存中拷貝的副本,另外線程對變量的所有操作行為都必須在工作內存完成,而不能直接操作主內存中的變量信息。
- 不同線程之間也無法直接訪問對方工作內存中的變量,線程間變量的傳遞均需通過自己的工作內存和主內存之間進行數據交互,然后再傳遞到別的線程工作內存中完成信息的交互。
小結:JMM(Java Memory Model)是一種規范,目的是解決由于多線程通過共享內存進行通信時,存儲在工作內存的數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題。
三、volatile三大屬性
2.1 原子性
2.1.1 volatile為什么不能保證原子性
/**
* @author 程序反思錄 <程序反思錄@xxx.com>
* Created on 2024-09-29
*/
public class MultiThreadCount {
private volatile int salesCount = 0;
public void addSalesCount() {
salesCount++;
}
public static void main(String[] args) {
MultiThreadCount multiThreadCount = new MultiThreadCount();
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
multiThreadCount.addSalesCount();
}
}).start();
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
multiThreadCount.addSalesCount();
}
}).start();
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
multiThreadCount.addSalesCount();
}
}).start();
System.out.println(multiThreadCount.salesCount);
}
}
運行上面這段代碼,在不同的機器上得到的結果大概率都不一樣且結果值都不是3000。
現在我們再回過頭來分析上面的那段示例代碼,剛開始3個線程分別從主內存copy salesCount=0到各自的工作內存中去,然后分別執行自增操作,完后后將各自的值刷回到主內存,一次salesCount自增操作會涉及三步操作(就像將大象放入冰箱的case一樣),多個線程同時多次執行這三步操作勢必會造成主內存中值被覆蓋情況,這也就解釋了volatile沒能保證原子性的原因。
2.1.2 如何實現原子性
解決上面的問題很容易,只需要將salesCount的修飾由volatile改成就可以了,代碼如下
private AtomicInteger salesCount = new AtomicInteger(0);
public void addSalesCount() {
salesCount.incrementAndGet();
}
有同學就會好奇了,為什么AtomicInteger就可以解決數據被刷回到主內存后數據被覆蓋的問題呢?點開AtomicInteger的源碼會有有兩個關鍵的動作:
- AtomicInteger內部維護的value屬性是用volatile修飾的,利用其內存可見性的特性使得值被修改后,別的線程能夠及時感知到(后面分析內存可見性的時候再展開)
- 使用了CAS特性加死循環來保證值不會被覆蓋,并將當前最新值累加上去刷回到主內存,我們稍微展開分析一下具體實現
// 調用該方法對計數器進行+1操作
public final int incrementAndGet() {
// 通過unsafe類實現原子加+1操作
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// 1. 首先通過CAS嘗試將+1后的數據寫入到工作線程,
// 然后回寫到主內存(這里會通過lock指令強制將修改后的值回寫到主內存,
// 下面分析可見性的時候在展開)。
// 2. 如果CAS操作失敗了,通過while死循環不斷自旋,直到最新值被成功回寫到主內存,
// 說點題外話,相信看過線程池和ReentrantLock文章的同學會有感覺,
// 一般CAS出現的地方,會伴隨著死循環的身影出現。
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
2.2 內存可見性
2.2.1 什么是內存可見性
內存可見性(Memory Visibility)是指在一個線程中修改了某個變量的值之后,這些修改能夠被其他線程立即看到。在多線程環境中,由于每個線程可能有自己的工作內存(緩存),而不是直接操作主內存,因此會出現內存可見性問題。
2.2.2 volatile是如何解決內存可見性的問題
當對volatile修飾的變量進行修改時,JVM會向處理器發送一條lock前綴的指令,將當前處理器中緩存的最新值強制寫回到主存中,所有處理器都需要遵守緩存一致性協議,當其他處理器發現自己緩存的數據已經被修改,則會從主存中拉取最新的值緩存到自己的緩存內,從而實現了可見性的特性。 緩存一致性協議:每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是否已過期,當處理器發現自己緩存行的內存地址被修改,就會將當前處理器的緩存設置成無效狀態,當處理器要對這個數據進行修改操作時,會強制從主存讀取最新數據寫入到處理器緩存中。
2.2.3 解決內存可見性問題的替代方案
i) 通過鎖來解決同一時刻只有一個線程可以修改值
- 使用synchroized關鍵字,保證多個線程操作時,只有搶到鎖的線程才可以執行修改操作
- 使用Atomic類,通過CAS+死循環的方式
ii) 使用final關鍵字修飾,使得變量不能被修改,從而避開了內存可見性問題的發生
2.3 指令重排
2.3.1 什么是指令重排
指令重排是指編譯器、運行時系統或處理器為了優化性能,對程序中的指令順序進行調整的過程。
2.3.2 指令重排有什么好處
i) 編譯器優化:編譯器可能會對代碼進行重排序,以減少寄存器的使用、提高指令流水線的效率等。
ii) 運行時系統優化:運行時系統可能會對字節碼進行優化,以提高執行效率。
iii) 處理器優化:現代處理器具有復雜的流水線和多級緩存,可能會對指令進行重排序以提高性能。
2.3.3 為什么volatile禁止指令重排
大多數情況下指令重排這種優化操作是透明的,但在多線程環境中,指令重排可能會導致一些問題 i) 內存可見性問題:由于指令執行順序被重排,使得修改操作被延遲觸發,最終導致一個線程對變量的修改可能不會理解對其他線程可見。ii) 競態條件:指令重排可能導致兩個線程之間的操作順序不符合預期,從而引發競態條件。
2.3.4 禁止指令重排是如何實現的
禁止指令重排序是通過內存屏障來實現的。內存屏障是一種特殊的指令,它可以確保某些操作在屏障前后按照特定的順序執行,從而防止編譯器、運行時系統和處理器對這些操作進行重排序。內存屏障分為兩種:i) 寫屏障:在寫操作之后插入一個寫屏障,確保所有之前的寫操作都已完成并回寫到主內存中。ii) 讀屏障:在讀操作之前插入一個讀屏障,確保所有后續的讀操作都從主內存中讀取最新的值。
四、后續
本篇文章從volatile的特性展開,介紹到了Java的JMM(Java內存模型)模型,有些同學這個時候心里就要開始迷糊了,我聽過Java對象模型、JVM內存模型,那它們又是干什么用的呢?我知道你很急,但是你不用急,下篇文章接著解答的疑惑。