Java并發(fā)編程變量可見性避免指令重排使用詳解
引言
上一篇文章講的是線程本地存儲(chǔ) ThreadLocal,講究的是讓每個(gè)線程持有一份數(shù)據(jù)副本,大家各自訪問各自的,就不用爭(zhēng)搶了。
那怎么保證程序里一個(gè)線程對(duì)共享變量的修改能立馬被其他線程看到了?這時(shí)候有人會(huì)說了,加鎖呀,前面不就是因?yàn)榧渔i成本太高才使用的 ThreadLocal的嗎?怎么又說回去了?
其實(shí)CPU每個(gè)核心也都是有緩存的,今天要講的volatile能保證變量在多線程間的可見性,本文我們會(huì)對(duì)變量可見性、指令重排、Happens Before 原則以及 Volatile 對(duì)這些特性提供的支持和在程序里的使用進(jìn)行講解,本文大綱如下:
變量的可見性
一個(gè)線程對(duì)共享變量的修改,另外一個(gè)線程能夠立刻看到,稱為變量的可見性。
在單核系統(tǒng)中,所有的線程都是在一顆 CPU 上執(zhí)行,CPU 緩存與內(nèi)存的數(shù)據(jù)一致性容易解決。但是多核系統(tǒng)中,每顆 CPU 都有自己的緩存,這時(shí) CPU 緩存與內(nèi)存的數(shù)據(jù)一致性就沒那么容易解決了,當(dāng)多個(gè)線程在不同的 CPU 上執(zhí)行時(shí),這些線程操作的是不同的 CPU 緩存。
比如下圖中,線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存,很明顯,這個(gè)時(shí)候線程 A 對(duì)變量 V 的操作對(duì)于線程 B 而言不具備可見性。
Java 里可以使用 volatile 關(guān)鍵字修飾成員變量,來保證成員在線程間的可見性。讀取 volatile 修飾的變量時(shí),線程將不會(huì)從所在CPU的緩存,而是直接從系統(tǒng)的主存中讀取變量值。同理,向一個(gè) volatile 修飾的變量寫入值的時(shí)候,也是直接寫入到主存。
下面我們?cè)賮砜匆幌?,?dāng)不使用 volatile 時(shí),多線程使用共享變量時(shí)的可見性問題。
Java 變量的可見性問題
Java 的 volatile 關(guān)鍵字能夠保證變量更改的跨線程可見,在一個(gè)多線程應(yīng)用程序中,為了提高性能,線程會(huì)把變量從主存拷貝到線程所在CPU信息的緩存上再操作。如果程序運(yùn)行在多核機(jī)器上,多個(gè)線程可能會(huì)運(yùn)行在不同的CPU 上,也就意味著不同的線程可能會(huì)把變量拷貝到不同的 CPU 緩存上。
因?yàn)镃PU緩存的讀寫速度遠(yuǎn)高于主存,所以線程會(huì)把數(shù)據(jù)從主存讀到 CPU 緩存,數(shù)據(jù)的更新也是是先更新CPU 緩存中的副本,再刷回主存,除非有(匯編指令)強(qiáng)制要求否則不會(huì)每次更新都把數(shù)據(jù)刷回主存。
對(duì)于非 volatile 修飾的變量,Java 無法保證 JVM 何時(shí)會(huì)把數(shù)據(jù)從主存讀取到 CPU 緩存,或?qū)?shù)據(jù)從 CPU 緩存寫入主內(nèi)存。
這在多線程環(huán)境下可能會(huì)導(dǎo)致問題,想象一下這樣一種情況,有多個(gè)線程可以訪問一個(gè)共享對(duì)象,該對(duì)象包含一個(gè)聲明如下的計(jì)數(shù)器變量。
public class SharedObject { public volatile int counter = 0; }
假設(shè)在我們的例子中只有線程1 會(huì)更新計(jì)數(shù)器 counter 的值,線程1 和線程2 都會(huì)時(shí)不時(shí)的讀取 counter 的值。 如果 counter 未被聲明為 volatile 的,則無法保證變量 counter 的值何時(shí)會(huì)從 CPU 緩存寫回主存。這意味著,CPU 緩存中的計(jì)數(shù)器變量值可能與主內(nèi)存中的不同。比如像下圖這樣:
線程2 訪問 counter 的值的結(jié)果是 0 ,沒有看到變量 counter 最新的值。這是因?yàn)?counter 它最新的值還在CPU1 的緩存中,還沒有被線程1 寫回到主內(nèi)。
上面這個(gè)例子描述的情況,就是所謂“可見性”問題:一個(gè)線程的更新對(duì)其他線程是不可見的。
Volatile 的可見性保證
Java 的 volatile 關(guān)鍵字旨在解決變量可見性問題。通過將上面例子中的 counter 變量聲明為 volatile的,所有對(duì)counter 變量的寫入都將立即寫回主存,所以對(duì) counter 變量的讀取都會(huì)先將變量從主存讀到CPU緩存 (相當(dāng)于每次都從主存讀取)。
把 counter 變量聲明成 volatile 只需要在定義中加上 volatile 關(guān)鍵字即可
public class SharedObject { public volatile int counter = 0; }
完整的 volatile 可見性保證
實(shí)際上,volatile 的可見性保證超出了 volatile 修飾的變量本身。它的可見性保證規(guī)則如下:
- 如果線程 A 寫入一個(gè) volatile 變量,而線程 B 隨后讀取了同一個(gè) volatile 變量,那么線程 A 在寫入 volatile 變量之前,對(duì)線程 A 可見(更新可見)的所有變量,在線程 B 讀取 volatile 變量之后也將對(duì)線程 B 可見。
- 如果線程 A 讀取一個(gè) volatile 變量,那么在讀取 volatile 變量時(shí),線程 A 可見的所有變量也將從主存中重新讀取。
我們通過例程解釋一下這兩個(gè)規(guī)則。
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() 方法寫入三個(gè)變量,其中只有變量 days 是 volatile 的。 完整的 volatile 可見性保證意味著,當(dāng)一個(gè)新值被寫入到變量 days 時(shí),該線程可見的所有變量也會(huì)被寫入主內(nèi)。這意味著,當(dāng)一個(gè)新值被寫入變量 days 時(shí),years 和 months 的值也會(huì)被寫入主存。
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; } }
而對(duì)于 volatile 變量的讀取來說,在上面例程的 totalDays 方法中,當(dāng)讀取 days 變量的值的時(shí)候,除了會(huì)從主存中重新讀取變量 days 的值外,其他兩個(gè)未被 volatile 修飾的變量 years 和 months 也會(huì)被從主存中重新讀取到CPU緩存。通過上述讀取順序,可以確??吹?days、months 和 years 的最新值。
指令重排
在指定的語義保持不變的情況下,出于性能原因,JVM 和 CPU 可能會(huì)對(duì)程序中的指令進(jìn)行重新排序。比如說,下面這幾個(gè)指令:
int a = 1; int b = 2; a++; b++;
這些指令可以重新排序?yàn)橐韵滦蛄?/p>
int a = 1; a++; int b = 2; b++;
然而,對(duì)于存在被聲明為 volatile 的變量的程序而言,我們傳統(tǒng)理解的指令重排會(huì)導(dǎo)致嚴(yán)重的問題,還以上面使用過的例程來描述一下這個(gè)問題。
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; } }
一旦當(dāng) update() 方法將新值寫入 days 變量時(shí),新寫入的 years 和 months 的值也將被寫入主存。但是,如果 JVM 重排指令,把程序變成下面這樣會(huì)怎樣呢?
public void update(int years, int months, int days){ this.days = days; this.months = months; this.years = years; }
重排后變成了先對(duì) days 進(jìn)行賦值,根于完整可見性的第一條規(guī)則,當(dāng)寫入 days 變量時(shí),months 和 years 變量的值也會(huì)被寫入主存。但是指令重排后,變量 days 的賦值這一次是在新值寫入 months 和 years 之前發(fā)生的。因此,它們的新值不會(huì)正確地對(duì)其他線程可見。
顯然,重新排序的指令的語義已經(jīng)改變,不過 Java 內(nèi)部會(huì)有解決方案防止此類問題的發(fā)生。
volatile 的 Happens Before 保證
為了解決上面例子里指令重排導(dǎo)致的問題,除了可見性保證之外,Java 的 volatile 關(guān)鍵字還提供了“happens-before”保證。
- 如果原來位于寫 volatile 變量之前的非 volatile 變量的讀寫,在指令重排時(shí),不允許這些指令出現(xiàn)在 volatile 變量的寫入指令之后。但是原來在 volatile 變量寫入之后的對(duì)其他變量的讀寫指令,在重排時(shí),是允許出現(xiàn)在寫 volatile 變量之前的--即從后變前允許,從前變后不行。
- 如果原來位于讀 volatile 變量之后的對(duì)非 volatile 變量的讀寫,在指令重排時(shí),不允許出現(xiàn)在讀 volatile 變量之前。
上面的 Happens-Before 保證確保了 volatile 在程序發(fā)生指令重排時(shí)也能提供正確的可見性保證。
volatile 不能保證原子性
雖然 volatile 關(guān)鍵字保證了對(duì) volatile 修飾的變量的所有讀取都直接從主存中讀取,對(duì) volatile 變量的所有寫入都會(huì)寫入到主存中,但 volatile 不能保證原子性。
在前面共享計(jì)數(shù)器的例子中,我們?cè)O(shè)置了一個(gè)前提--只有線程1 會(huì)更新計(jì)數(shù)器 counter 的值,線程1 和線程2 會(huì)時(shí)不時(shí)的讀取 counter 的值。在這個(gè)前提下,把 counter 變量聲明成 volatile 的足以確保線程 2 始終能看到線程1最新寫入的值。
事實(shí)上,當(dāng)寫入變量的新值不依賴先前的值(比如累加)的時(shí)候,多個(gè)線程都向同一個(gè) volatile 變量寫入時(shí),是能保證向主存中寫入的是正確的值的。但是,如果需要首先讀取 volatile 變量的值,并基于該值為 volatile 變量生成一個(gè)新值,那么 volatile 就不能保證變量正確的可見性了。讀取 volatile 變量和寫入新值之間的這個(gè)短短的時(shí)間間隔,在多線程并發(fā)寫入的情況下也是會(huì)產(chǎn)生 Data Racing 的。
想象一下,如果線程 1 將值為 0 的 counter 變量讀取到運(yùn)行它的 CPU 的緩存中,將其遞增到 1,在線程1把 counter 的值寫回主存之前,線程 2 可能正好也從主內(nèi)存中把 counter 變量讀到了運(yùn)行它的 CPU 緩存中,讀取到的 counter 變量的值也是 0,然后線程 2 也對(duì) counter 變量進(jìn)行遞增的操作。
線程 1 和線程 2 現(xiàn)在實(shí)際上已經(jīng)不同步了。理論上 counter 變量從 0 經(jīng)過兩次遞增應(yīng)該變成 2,但實(shí)際上每個(gè)線程在其 CPU 緩存中的 counter 變量的值為 1,即使線程最終將 counter 變量的值寫回主存,它的值也是不對(duì)的。
那么,如何做到線程安全呢?有兩種方案:
- volatile + synchronized
- 使用原子類替代 volatile
原子類后面到 J.U.C 相關(guān)的章節(jié)的時(shí)候再去學(xué)習(xí)。
什么時(shí)候適合使用 volatile
如果 volatile 修飾符使用恰當(dāng)?shù)脑?,它?synchronized 的使用和執(zhí)行成本更低,因?yàn)樗粫?huì)引起線程上下文的切換和調(diào)度。但是要注意 volatile 是無法替代 synchronized ,因?yàn)?volatile 無法保證操作的原子性。
通常來說,使用 volatile 必須具備以下 2 個(gè)條件:
- 對(duì)變量的寫操作不依賴于當(dāng)前值
- volatile 變量沒有包含在具有其他變量的表達(dá)式中
示例:雙重鎖實(shí)現(xiàn)線程安全的單例模式
class Singleton { private volatile static Singleton instance = null; private Singleton() {} public static Singleton getInstance() { if(instance==null) { synchronized (Singleton.class) { if(instance==null) instance = new Singleton(); } } return instance; } }
volatile 的原理
使用 volatile 關(guān)鍵字時(shí),程序?qū)?yīng)的匯編代碼在對(duì)應(yīng)位置會(huì)多出一個(gè) lock 前綴指令。lock 前綴指令實(shí)際上相當(dāng)于一個(gè)內(nèi)存屏障(也稱內(nèi)存柵欄),內(nèi)存屏障會(huì)提供 3 個(gè)功能:
- 它確保指令重排序時(shí)不會(huì)把其后面的指令排到內(nèi)存屏障之前的位置,也不會(huì)把前面的指令排到內(nèi)存屏障的后面;即在執(zhí)行到內(nèi)存屏障這句指令時(shí),在它前面的操作已經(jīng)全部完成;
- 它會(huì)強(qiáng)制將對(duì)緩存的修改操作立即寫入主存;
- 如果是寫操作,它會(huì)導(dǎo)致其他 CPU 中對(duì)應(yīng)的緩存行無效。
注意 volatile 的性能問題
讀取和寫入 volatile 變量都會(huì)直接訪問主存,讀寫主存比訪問 CPU 緩存更慢得多,不過使用 volatile 變量還可以防止指令重排,這是一種正常的性能增強(qiáng)技術(shù)。因此,我們只應(yīng)該在確實(shí)需要變量的可見性和防止指令重排時(shí),再使用 volatile 變量。
以上就是Java并發(fā)編程變量可見性避免指令重排使用詳解的詳細(xì)內(nèi)容,更多關(guān)于Java并發(fā)變量可見性避免指令重排的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
java.io.NotSerializableException異常的問題及解決
這篇文章主要介紹了java.io.NotSerializableException異常的問題及解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-12-12Java隨機(jī)生成姓名,手機(jī)號(hào),住址代碼示例
這篇文章主要介紹了Java隨機(jī)生成姓名,手機(jī)號(hào),住址代碼示例,屬于Java基礎(chǔ)方面的內(nèi)容,具有一定參考價(jià)值,需要的朋友可以了解下。2017-11-11Spring Cloud Netflix架構(gòu)淺析(小結(jié))
這篇文章主要介紹了Spring Cloud Netflix架構(gòu)淺析(小結(jié)),詳解的介紹了Spring Cloud Netflix的概念和組件等,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-01-01分享Spring?Cloud?OpenFeign?的五個(gè)優(yōu)化技巧
這篇文章主要分享的是Spring?Cloud?OpenFeign?的五個(gè)優(yōu)化技巧,OpenFeign?是?Spring?官方推出的一種聲明式服務(wù)調(diào)用和負(fù)載均衡組件,更多相關(guān)內(nèi)容需要的小伙伴可以參考一下2022-05-05基于Spring + Spring MVC + Mybatis 高性能web構(gòu)建實(shí)例詳解
這篇文章主要介紹了基于Spring + Spring MVC + Mybatis 高性能web構(gòu)建實(shí)例詳解,需要的朋友可以參考下2017-04-04gRPC實(shí)踐之proto及Maven插件概念及使用詳解
這篇文章主要為大家介紹了gRPC實(shí)踐之proto及Maven插件概念及使用詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-04-04