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