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


























