從一個案例徹底理解volatile關鍵字
前幾天奈飛網站我看到這個片段給我整懵了,雖然英語不是拔尖的我,但是這.....不至于不至于 please let us know now, 很明顯就是“請馬上告訴我們”,其實《魷魚游戲》劇中英語翻譯有些并不總是與角色的對話相匹配,不必在意的。
扯遠了哈,寫 volatile 的文章非常多,本人也看過許多相關文章,但是始終感覺有哪里不對勁,但是又說不上來為什么,可能是太過分追求實現原理,老想問一個為什么吧。但是,原理還是要說,代碼還是要寫。
而寫這篇文章的目的很簡單,就是覺得應該更多的從工作實踐為出發點,這樣才有意義些,記得牢。好東西當然要拿出來分享,于是就有了這篇文章。
一,Volatile怎么念?
看到這個單詞一直不知道怎么發音,額........ 那 Java 中 volatile 有啥用呢?
二,它有啥用呢?
volatile是 JVM 提供的 輕量級 的同步機制(有三大特性)
保證可見性
不保證原子性
禁止指令重排
好漢你別激動,同步機制說白了就是 syhconized,這種機制你可以這樣理解就是在一段時間內有序的發生操作。假如一個線程對某個共享資源加鎖后,其他想要獲取共享資源的線程必須進行等待,很顯然,這種同步機制效率很低的,怎么用就不多說了,但synchronized是其他并發容器實現的基礎,對它的理解也會讓你提升對并發編程的感覺,從功利的角度來說,這也是面試高頻的考點。
CPU緩存這有啥用
最初的 CPU 是沒有緩存的,CPU 它只負責去讀寫內存。這時候有人就發現不對勁啊?CPU的運行效率與讀寫內存的效率差距是上百倍量級以上的。總不能 CPU 執行1個寫操作耗時1個時鐘周期吧,我就等著你內存執行一百多個時鐘周期吧,那CPU就是閑人。怎么可能,于是出現下面存儲系統的結構所示: 所以中間加了緩存(平時見到的 cache 就是它),特點一句話總結:臨時,高速,優化數據用的。它存儲的話肯定也沒寄存器快,這就像 Mysql 出現瓶頸時,我們會考慮通過緩存數據來提高性能是類似的道理。
現在主流CPU通常采用三層緩存:
一級緩存(L1 Cache):主要分數據緩存 和 指令緩存,它們兩個是分開的哈。L1是距離CPU最近的,因此它會比L2, L3的讀寫速度都快,存儲空間都小。好比我們大腦的短期記憶,而長期記憶就好比L2/L3 Cache。它是作為核心獨享的,說白了一個核就有一個L1;
二級緩存(L2 Cache):二級緩存的指令和數據是共享的,二級緩存的容量直接影響CPU的性能,嚇得我馬上看了掏出看了下:
三級緩存(L3 Cache):作用是進一步降低內存的延遲,同時提升海量數據計算的性能。三級緩存屬于核心共享的,因此只有1個。 經過上述細分,可以將上圖進一步細化:
這里再補充一個概念:緩存行(Cache-line),它是CPU緩存存儲數據的最小單位,后面會用到。上面的CPU緩存,CPU緩存你也可以叫它高速緩存。也就是說高速緩存里面有很多的緩存行。
引入緩存之后,每個CPU的處理過程為:先將計算所需數據緩存在高速緩存中,當CPU進行計算時,直接從高速緩存讀取數據,計算完成再寫入緩存中。當整個運算過程完成之后,再把緩存中的數據同步到主內存中。
如果是單核CPU這樣處理沒有什么問題。但在多核系統中,每個CPU都可能將同一份數據緩存到自己的高速緩存中,這就出現了緩存數據一致性問題了。
CPU層提供了兩種解決方案:總線鎖和緩存一致性。
總線鎖
從上面小節得出,加入緩存是為了CPU于物理內存之間加快你CPU的處理速度。現在假設一臺PC上只有一個CPU和一份內部緩存,那么所有進程和線程看到的數都是緩存的數,不會存在問題;但實際情況不會如此的。現在的服務器一般是多CPU,更普遍的是,每塊CPU里有多個內核,每個內核維護了自己的緩存,那么這時候多線程并發導致緩存不一致性,這個就是我們下面要講的。回來我們再看看總線鎖,那你得先了解什么是CPU總線?
CPU總線你可以叫它前端總線,作為PC系統最快的總線,是給CPU用的,與CPU相關的總線都統稱為CPU總線。它用在與高速緩存,主存和北橋之間傳送信息。這大的概念個根據具體功能又分數據總線,地址總線,控制總線。它的位置在哪里呢?處于芯片組和CPU之間,負責CPU與外界所有部件的通信。
緩存一致性協議
要搞清楚三大特性,前提是你要知道Java內存模型(JMM),那JMM又是個什么東東?大家興中可能留下這張圖了吧。
JMM(Java內存模型)
它是抽象的(不真實存在),描述的是一組規范。通過這組規范定義了程序中各個變量(實例字段,靜態字段和構成對象的元素)的訪問方式。
前提要點
在多線程中稍微不注意就會出現線程安全問題,那么什么是線程安全問題?我的認識是,在多線程下代碼執行的結果與預期正確的結果不一致,這種就是,否則它是線程安全的。雖然這種回答似乎不能獲取什么內容, 可以google下,在<<深入理解Java虛擬機>>中看到的定義。原文如下: 當多個線程訪問同一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替運行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行為都可以獲取正確的結果,那這個對象是線程安全的。
關于定義的理解這是一個仁者見仁智者見智的事情。出現線程安全的問題一般是因為主內存和工作內存數據不一致性和重排序導致的,而解決線程安全的問題最重要的就是理解這兩種問題是怎么來的,那么,理解它們的核心在于理解java內存模型(JMM)。
在多線程條件下,多個線程肯定會相互協作完成一件事情,一般來說就會涉及到多個線程間相互通信告知彼此的狀態以及當前的執行結果等,另外,為了性能優化,還會涉及到編譯器指令重排序和處理器指令重排序。下面會一一來聊聊這些知識。
線程間協作通信
線程間協作通信可以類比人與人之間的協作的方式,在現實生活中,之前網上有個流行語“你媽喊你回家吃飯了”,就以這個生活場景為例,小明在外面玩耍,小明媽媽在家里做飯,做晚飯后準備叫小明回家吃飯,那么就存在兩種方式:-
小明媽媽要去上班了十分緊急這個時候手機又沒有電了,于是就在桌子上貼了一張紙條“飯做好了,放在...”小明回家后看到紙條如愿吃到媽媽做的飯菜,那么,如果將小明媽媽和小明作為兩個線程,那么這張紙條就是這兩個線程間通信的共享變量,通過讀寫共享變量實現兩個線程間協作;
還有一種方式就是,媽媽的手機還有電,媽媽在趕去坐公交的路上給小明打了個電話,這種方式就是通知機制來完成協作。同樣,可以引申到線程間通信機制。
通過上面這個例子,應該有些認識。在并發編程中主要需要解決兩個問題:1. 線程之間如何通信;2.線程之間如何完成同步(這里的線程指的是并發執行的活動實體)。通信是指線程之間以何種機制來交換信息,主要有兩種:共享內存和消息傳遞。這里,可以分別類比上面的兩個舉例。java內存模型是共享內存的并發模型,線程之間主要通過讀-寫共享變量來完成隱式通信。如果程序員不能理解Java的共享內存模型在編寫并發程序時一定會遇到各種各樣關于內存可見性的問題。
.哪些是共享變量
在java程序中所有實例域,靜態域和數組元素都是放在堆內存中(所有線程均可訪問到,是可以共享的),而局部變量,方法定義參數和異常處理器參數不會在線程間共享。共享數據會出現線程安全的問題,而非共享數據不會出現線程安全的問題。
JMM關于同步的規定:
1 線程解鎖前,必須把共享變量的值刷新回主內存; 2 線程加鎖前,必須讀取主內存的最新值到自己的工作內存; 3 加鎖解鎖的是同一把鎖;
由于 JVM 運行程序的實體是線程,它就對應上圖線程A和B, 而每個線程創建是 JVM 都會為其創建一個屬于線程的本地內存, 它是每個線程的私有數據區域。 而Java內存模型中規定所有變量都存儲在主內存(也就是內存條), 主內存是共享內存區域,所有線程都是可以去訪問的, 但是線程對變量的操作(讀取或賦值等)必須在本地內存中來操作, 首先要將變量從主內存拷貝到自己線程的本地內存上, 然后對變量進行操作,操作完成以后把變量再寫回到主內存, 你現在不能直接去操作主內存中的變量, 各個線程中的本地內存中存儲主內存的變量副本, 因此不同的線程間無法訪問對方的工作內存, 線程間的通信(傳值)必須通過主內存來完成;
編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序;
指令級并行的重排序。現代處理器采用了指令級并行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序;
內存系統的重排序。由于處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行的。
如圖,1屬于編譯器重排序,而2和3統稱為處理器重排序。這些重排序會導致線程安全的問題,一個很經典的例子就是DCL問題,這個在以后的文章中會具體去聊。針對編譯器重排序,JMM的編譯器重排序規則會禁止一些特定類型的編譯器重排序;針對處理器重排序,編譯器在生成指令序列的時候會通過插入內存屏障指令來禁止某些特殊的處理器重排序。
那么什么情況下,不能進行重排序了?下面就來說說數據依賴性。有如下代碼:
double pi = 3.14 //A double r = 1.0 //B double area = pi * r * r //C
這是一個計算圓面積的代碼,由于A,B之間沒有任何關系,對最終結果也不會存在關系,它們之間執行順序可以重排序。因此可以執行順序可以是A->B->C或者B->A->C執行最終結果都是3.14,即A和B之間沒有數據依賴性。具體的定義為:如果兩個操作訪問同一個變量,且這兩個操作有一個為寫操作,此時這兩個操作就存在數據依賴性這里就存在三種情況:1. 讀后寫;2.寫后寫;3. 寫后讀,者三種操作都是存在數據依賴性的,如果重排序會對最終執行結果會存在影響。編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴性關系的兩個操作的執行順序
另外,還有一個比較有意思的就是as-if-serial語義。
as-if-serial
as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提供并行度),(單線程)程序的執行結果不能被改變。編譯器,runtime和處理器都必須遵守as-if-serial語義。as-if-serial語義把單線程程序保護了起來,遵守as-if-serial語義的編譯器,runtime和處理器共同為編寫單線程程序的程序員創建了一個幻覺:單線程程序是按程序的順序來執行的。比如上面計算圓面積的代碼,在單線程中,會讓人感覺代碼是一行一行順序執行上,實際上A,B兩行不存在數據依賴性可能會進行重排序,即A,B不是順序執行的。as-if-serial語義使程序員不必擔心單線程中重排序的問題干擾他們,也無需擔心內存可見性問題。
happens-before定義
happens-before的概念最初由Leslie Lamport在其一篇影響深遠的論文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出,有興趣的可以google一下。JSR-133使用happens-before的概念來指定兩個操作之間的執行順序。由于這兩個操作可以在一個線程之內,也可以是在不同線程之間。因此,JMM可以通過happens-before關系向程序員提供跨線程的內存可見性保證(如果A線程的寫操作a與B線程的讀操作b之間存在happens-before關系,盡管a操作和b操作在不同的線程中執行,但JMM向程序員保證a操作將對b操作可見)。具體的定義為:
1)如果一個操作happens-before另一個操作,那么第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
2)兩個操作之間存在happens-before關系,并不意味著Java平臺的具體實現必須要按照happens-before關系指定的順序來執行。如果重排序之后的執行結果,與按happens-before關系來執行的結果一致,那么這種重排序并不非法(也就是說,JMM允許這種重排序)。
上面的1)是JMM對程序員的承諾。從程序員的角度來說,可以這樣理解happens-before關系:如果A happens-before B,那么Java內存模型將向程序員保證——A操作的結果將對B可見,且A的執行順序排在B之前。注意,這只是Java內存模型向程序員做出的保證!
上面的2)是JMM對編譯器和處理器重排序的約束原則。正如前面所言,JMM其實是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎么優化都行。JMM這么做的原因是:程序員對于這兩個操作是否真的被重排序并不關心,程序員關心的是程序執行時的語義不能被改變(即執行結果不能被改變)。因此,happens-before關系本質上和as-if-serial語義是一回事。
下面來比較一下as-if-serial和happens-before:
as-if-serial VS happens-before
as-if-serial語義保證單線程內程序的執行結果不被改變,happens-before關系保證正確同步的多線程程序的執行結果不被改變。
as-if-serial語義給編寫單線程程序的程序員創造了一個幻境:單線程程序是按程序的順序來執行的。happens-before關系給編寫正確同步的多線程程序的程序員創造了一個幻境:正確同步的多線程程序是按happens-before指定的順序來執行的。
as-if-serial語義和happens-before這么做的目的,都是為了在不改變程序執行結果的前提下,盡可能地提高程序執行的并行度。
4.2 具體規則 具體的一共有六項規則:
程序順序規則:一個線程中的每個操作,happens-before于該線程中的任意后續操作。
監視器鎖規則:對一個鎖的解鎖,happens-before于隨后對這個鎖的加鎖。
volatile變量規則:對一個volatile域的寫,happens-before于任意后續對這個volatile域的讀。
傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C。
start()規則:如果線程A執行操作ThreadB.start()(啟動線程B),那么A線程的ThreadB.start()操作happens-before于線程B中的任意操作。
join()規則:如果線程A執行操作ThreadB.join()并成功返回,那么線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功返回。
程序中斷規則:對線程interrupted()方法的調用先行于被中斷線程的代碼檢測到中斷時間的發生。
對象finalize規則:一個對象的初始化完成(構造函數執行結束)先行于發生它的finalize()方法的開始。
下面以一個具體的例子來講下如何使用這些規則進行推論:
依舊以上面計算圓面積的進行描述。利用程序順序規則(規則1)存在三個happens-before關系:1. A happens-before B;2. B happens-before C;3. A happens-before C。這里的第三個關系是利用傳遞性進行推論的。A happens-before B,定義1要求A執行結果對B可見,并且A操作的執行順序在B操作之前,但與此同時利用定義中的第二條,A,B操作彼此不存在數據依賴性,兩個操作的執行順序對最終結果都不會產生影響,在不改變最終結果的前提下,允許A,B兩個操作重排序,即happens-before關系并不代表了最終的執行順序。
舉例子
假如主內存有個student對象, 其屬性age=25; 第一步首先把25復制到線程A,線程B的本地內存A,本地內存B, 線程A在本地內存通過計算得到age=37,那么此時寫回到主內存上的age=37了, 但是此時線程B還不知道線程A本地內存的age=37;要讓他們通信該咋辦呢?這個就是代表我們的主題 線程的可見性volatile, 通過主內存來讓他們來通信, 但是請你注意volatile不保證原子性的哦,可以有序性(也就是上面的指令重排)。
Demo
class MyData { /*主內存*/ /*volatile*/ int number = 0; //共享變量(是放在主內存上的) public void addTO60() { this.number = 60;//(假如線程A調用此方法,會把60賦值到共享的主內存里面去,) } } /* 1 驗證volatile的可見性 1)假如 int number = 0; number變量之前沒有添加volatile關鍵字修飾,沒有可見性(及時通知機制) 2) */ public class volatileDemo { public static void main(String[] args) { //main是一切方法的運行入口 MyData myData = new MyData(); //資源類 //實現了runnable接口的lomda表達式 new Thread() -> { System.out.println(Thread.currentThread().getName()+"\t come in"); //暫停一會線程:(只要A線程進入調到number的值,他就得等一會大概 3秒鐘,別的線程已經讀取了變量了。) try { TimeUnit.SECONDS.SLEEP(3);} catch {InterruptedException e} { e.printStackTrace(); } //3秒鐘之后。我把number改為60 myData.addTO60(); System.out.println(Thread.currentThread().getName()+"\t updated number value: "+myData.number); //如果3秒后MyData確定把number變成60, MyData它自己肯定知道, //那mydata.number的值已經從0變成60了啊 },"AAA").start(); //AAA線程(AAA線程要操作這個資源類MyData) //第二個線程就是我們的main線程(但是一開始進來main線程讀到的值是初始值0,) while(myData.number == 0) { //main線程就一直在這里等待循環,直到number值不在等于0 } System.out.println(Thread.crurrentThread().getName()+"\t mission is over, ,main get number value: "+myData.number); //如果這個值是0, //number在main線程的while一直死循環,這是在number前沒有添加volatile關鍵字,他會一直不會 //輸出該句,你現在去改下 number 前面加volatile,那本句話可以打出來,說明main線程已經感知到變成60,**可見性觸發**; } }
結果一: 結果二:
這就有可能存在一個線程A修改了共享變量number,是不是把它變成了60,但是60還沒寫回主內存的時喉,另外一個線程B又對主內存中同一個共享變量number進行操作,但此時A線程工作內存中共享變量X對線程B來說并不是可見的,這種工作內存與主內存同步延遲現象就造成了可見性問題
1 可見性(一種及時通知機制)
2 不保證原子性
原子性:一個操作(不可分割,完整性)要么同時成功,要么同時失敗。既是某個線程正在做某個具體業務時,中間是不可以被加塞或者分割。
class MyData { volatile int number = 0; //請注意,number前面是加了volatile關鍵字修飾的,volatile不保證原子性 public void addPlusPlus() { number++; } } public class VolatileDemo { public static void main(String[] args) {//main是一切方法的運行入口 MyData myData = new myData(); for (int i=1; i<=20; i++) { //for創建20個Thread 并且number最后其值為20000 new Thread(() -> { for(int j=0; j<1000; j++) { myData.addPlusPlus(); } },String.valueOf(i).start(); } //需要等待上面20個線程全部計算完成后,再用main線程取得最終的結果值是多少? //暫停5秒鐘(假如只運行1.5秒,5秒是不是給多了呀,所以我們得重寫) /*try { TimeUnit.SECONDS.sleep(5); } catch(InterruptedException e) { e.printStackTrace(); }*/ while(Thread.activeCount() > 2)//等待上面20個線程全部執行完,其用了多少秒就開始返回結果,那你有可能就問我問啥它就是2? 由于默認后臺有兩個線程,1是main線程,2是后臺GC線程;大于2 { Thread.yield();//main線程退下來,不執行,讓其他20個線程更好的執行完, //算完了是多少就是多少,這個時候main線程再去拿值再打印出來 } System.out.println(Thread.currentThread().getName()+"\t finally number values: "+MyDta.number); //main線程拿到MyDta類的number的值 } }
結果一:
結果二:
不對呀
運行好幾次都不是20000呀?這是怎么回事呢,就是這么回事,為啥volatile它是不能保證原子性的。我們就來講講它是為啥!
分析下
addPlusPlus()方法是沒有加synchronized,也沒有加lock之類的鎖,那說白了多線程來訪問這個方法是不安全,在這種情況下沒有安全機制,添加了volatile關鍵字,是不會保證原子性,所以它會丟失數據,永遠到不了2萬。 現在addPlusPlus()添加synchronized關鍵字,說明只能有一個線程去調用addPlusPlus(),一個線程加到
2000,29個線程完全可以跑到20000,如下;
寫到這里我們能不能用synchronized關鍵字呢,可是可以,其實這里簡直高射炮打蚊子,殺雞用牛刀呀,你現在為了解決number+的問題,synchronized它太重了。
我們現在的問題是它為什么不能保證原子性? number++我們都明白,number++在干嗎,根據開篇講的 JMM緩存模型 它底層被分解為3個操作:
1)各個線程它們從左到右先獲得number值,在主物理內存時number是0,它調用一次addPlusPlus()number的值在線程的本地內存上都變成了number=1,那根據JMM緩存模型,number的值就刷新回到主物理內存的number=1。這個時候出現什么情況呢?假設某個時間段t1線程和t2線程同時堵到了快照,這個時候t1和t2線程的number都是0。正常情況下,t1線程先寫回去,主內存的number變成1,那t2線程拿到主物理內存的number=1,這個時候又調用addPlusPlus(),那t2線程拿到的1再加個1,number就變成2。但是非常的抱歉,由于多線程的調度關系,注意聽哈非常重要,某一時間段t1和t2線程讀到的都是0,它們都addPlusPlus()之后,各自在各自工作空間加個1,準備把這個1寫回主物理內存去,將會出現在某一時間段,t1線程寫這個1的時候突然被掛起了,好,t2線程寫回到主物理內存,number=1,然后呢t2通知其他線程,我們是用volatile修飾的number,但是非常的抱歉t1線程它太快了,我一通知其他線程來改number,它就已經把number=1又改為number=1,相當于重寫。導致什么,你本來兩個1加兩次就變成2,結果你還是原來的1,這就是volatile的不保證原子性的關鍵所在。所以這里數據丟失,在不使用volatile的情況下,你永遠再怎么運行,也不會number=20000。
解決volatile不保證原子性
直接使用JUC下AtomicInteger(原子整型類),它所對應的對象天生就是完整不可分割,而且number和atomicInteger都是默認為0的,只不過它是原子屬性,每次加1都會執行完畢,才能執行下一個。
package com.atlong.basic; import java.util.concurrent.atomic.AtomicInteger; class MyData { volatile int number = 0; public void addTO60() { this.number = 60; } //請注意,number前面是加了volatile關鍵字修飾的,volatile不保證原子性 public void addPlusPlus()//這個類沒有加synchronized,lock之類的 { number++; } AtomicInteger atomicInteger =new AtomicInteger();//創建一個新的原子類伴隨著初始值()里面什么都不寫默認初始值為0, // 我們的number值也是初始為0,相當于atomicInteger就是我們的number;也就是帶原子性的number++ public void addMyAtomic()//帶有原子性的加入方法 { atomicInteger.getAndIncrement();//得到了以后在+1 } } class VolatileDemo { public static void main(String[] args) {//main是一切方法的運行入口 MyData myData = new MyData(); for (int i=1; i<=20; i++) { //for創建20個Thread 并且number最后其值為20000 new Thread(() -> { for(int j=0; j<1000; j++) { myData.addPlusPlus(); myData.addMyAtomic();//這個方法是自己寫的 } },String.valueOf(i)).start(); } //需要等待上面20個線程全部計算完成后,再用main線程取得最終的結果值是多少? //暫停5秒鐘(假如只運行1.5秒,5秒是不是給多了呀,所以我們得重寫) /*try { TimeUnit.SECONDS.sleep(5); } catch(InterruptedException e) { e.printStackTrace(); }*/ while(Thread.activeCount() > 2)//等待上面20個線程全部執行完,其用了多少秒就開始返回結果,那你有可能就問我問啥它就是2? 由于默認后臺有兩個線程,1是main線程,2是后臺GC線程;大于2 { Thread.yield();//main線程退下來,不執行,讓其他20個線程更好的執行完, //算完了是多少就是多少,這個時候main線程再去拿值再打印出來 } System.out.println(Thread.currentThread().getName()+"\t int type, finally number values: "+myData.number); //main線程拿到MyDta類的number的值 這個不保證原子性 //原子整型類的對象對應的number應該等于多少 這個保證原子性 System.out.println(Thread.currentThread().getName()+"\t atomic type, finally number values: "+myData.atomicInteger); } }
Java 任務調度 多線程 架構設計
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。