當前位置:首頁 » 文件管理 » volatile什麼時候鎖緩存行

volatile什麼時候鎖緩存行

發布時間: 2023-01-21 22:59:50

1. 匯流排鎖、緩存

操作系統必須要有一些機制和原語,以保證某些基本操作的原子性的兩種機制:
1.匯流排鎖定
2.緩存一致性

匯流排鎖定:
現在的CPU一般都有自己的內部緩存,根據一些規則將內存中的數據讀取到內部緩存中來,以加快頻繁讀取的速度。現在伺服器通常是多 CPU,更普遍的是,每塊CPU里有多個內核,而每個內核都維護了自己的緩存,那麼這時候多線程並發就會存在緩存不一致性,這會導致嚴重問題。

操作系統提供了匯流排鎖定的機制。前端匯流排(也叫CPU匯流排)是所有CPU與晶元組連接的主幹道,負責CPU與外界所有部件的通信,包括高速緩存、內存、北橋,其控制匯流排向各個部件發送控制信號、通過地址匯流排發送地址信號指定其要訪問的部件、通過數據匯流排雙向傳輸。在CPU1要操作共享變數的時候,其在匯流排上發出一個LOCK#信號,其他處理器就不能操作緩存了該共享變數內存地址的緩存,也就是阻塞了其他CPU,使該處理器可以獨享此共享內存。
匯流排鎖定把CPU和內存的通信給鎖住了,使得在鎖定期間,其他處理器不能操作其他內存地址的數據,從而開銷較大。

緩存一致性:
與volatile的原理一致,也是通過拷貝,偵測共享變數的值,當發現共享變數改變時,重新讀取。

2. volatile在i++情況下失效,volatile不是原子的

如果你對volatile不陌生的話,應該會知道volatile能夠保證共享變數對線程的可見性。
那為什麼volatile無法保證 i++ 操作的線程可見性呢?

假設i的初始值為0,現有兩個線程,分別為線程1和線程2進行 i++ 操作,我們來分析一下為什麼會出現錯誤。
首先,i++並不是原子操作,我們可以將這個操作拆分為3個步驟。
1、線程從主內存把遍歷載入到緩存。
2、線程執行i++操作。
3、線程將i的新值刷新到主內存。

那麼進行如下過程,則會發生線程安全問題。
1、線程1將變數載入到緩存。但是還沒有執行 i++ 操作。
2、線程2將變數載入到緩存,然後執行i++操作。
3、由於線程2緩存變數已經發生了變化,使得線程1的緩存行無效。
4、按我們以前的理解,由於線程1緩存行無效,那線程1應該主動去主內存load最新的值。而實際上並不是這樣的,volatile的作用並不是在變數改變的時候,讓其他線程重新載入主內存的變數值,而是置其他線程緩存內的變數值無效。也就是說,假如線程1的i值已經被載入到了寄存器,參與i++運算,那麼此時即便線程1的i值被置為無效,那線程1的計算結果也會把線程1從主內存刷新到的緩存值覆蓋,導致數據錯誤。

那麼為了解決volatile++這類復合操作的原子性,有什麼方案呢?其實方案也比較多的,這里提供兩種典型的:
1、使用synchronized關鍵字
2、使用AtomicInteger/AtomicLong原子類型

synchronized是比較原始的同步手段。它本質上是一個獨占的,可重入的鎖。當一個線程嘗試獲取它的時候,可能會被阻塞住,所以高並發的場景下性能存在一些問題。

在某些場景下,使用synchronized關鍵字和volatile是等價的:
1、寫入變數值時候不依賴變數的當前值,或者能夠保證只有一個線程修改變數值。
2、寫入的變數值不依賴其他變數的參與。
3、讀取變數值時候不能因為其他原因進行加鎖。
加鎖可以同時保證可見性和原子性,而volatile只保證變數值的可見性。

這類原子類型比鎖更加輕巧,比如AtomicInteger/AtomicLong分別就代表了整型變數和長整型變數。
在它們的實現中,實際上分別使用的volatile int/volatile long保存了真正的值。因此,也是通過volatile來保證對於單個變數的讀寫原子性的。
在此基礎之上,它們提供了原子性的自增自減操作。比如incrementAndGet方法, 這類方法相對於synchronized的好處是:它們不會導致線程的掛起和重新調度,因為在其內部使用的是CAS非阻塞演算法

所謂的CAS全程為CompareAndSet。直譯過來就是比較並設置。這個操作需要接受三個參數:
1、內存位置
2、舊的預期值
3、新值
這個操作的做法就是看指定內存位置的值符不符合舊的預期值,如果符合的話就將它替換成新值。它對應的是處理器提供的一個原子性指令 - CMPXCHG。
比如AtomicLong的自增操作:

我們考慮兩個線程T1和T2,同時執行到了上述Step 1處,都拿到了current值為1。然後通過Step 2之後,current在兩個線程中都被設置為2。

緊接著,來到Step 3。假設線程T1先執行,此時符合CompareAndSet的設置規則,因此內存位置對應的值被設置成2,線程T1設置成功。當線程T2執行的時候,由於它預期current為1,但是實際上已經變成了2,所以CompareAndSet執行不成功,進入到下一輪的for循環中,此時拿到最新的current值為2,如果沒有其它線程感染的話,再次執行CompareAndSet的時候就能夠通過,current值被更新為3。
所以不難發現,CAS的工作主要依賴於兩點:
1、無限循環,需要消耗部分CPU性能
2、CPU原子指令CompareAndSet
雖然它需要耗費一定的CPU Cycle,但是相比鎖而言還是有其優勢,比如它能夠避免線程阻塞引起的上下文切換和調度。這兩類操作的量級明顯是不一樣的,CAS更輕量一些。

我們說對於volatile變數的讀/寫操作是原子性的。因為從內存屏障的角度來看,對volatile變數的單純讀寫操作確實沒有任何疑問。
由於其中摻雜了一個自增的CPU內部操作,就造成這個復合操作不再保有原子性。
然後,討論了如何保證volatile++這類操作的原子性,比如使用synchronized或者AtomicInteger/AtomicLong原子類。

3. 看了這篇文章,你還敢說你了解volatile關鍵字嗎

想要理解volatile為什麼能確保可見性,就要先理解java中的內存模型是什麼樣的。

Java內存模型規定了 所有的變數都存儲在主內存中 每條線程中還有自己的工作內存,線程的工作內存中保存了被該線程所使用到的變數(這些變數是從主內存中拷貝而來) 線程對變數的所有操作(讀取,賦值)都必須在工作內存中進行。不同線程之間也無法直接訪問對方工作內存中的變數,線程間變數值的傳遞均需要通過主內存來完成

基於此種內存模型,便產生了多線程編程中的數據「臟讀」等問題。

舉個簡單的例子:在java中,執行下面這個語句:

i = 10;

執行線程必須先在自己的工作線程中對變數i所在的緩存行進行賦值操作,然後再寫入主存當中。而不是直接將數值10寫入主存當中。

比如同時有2個線程執行這段代碼,假如初始時i的值為10,那麼我們希望兩個線程執行完之後i的值變為12。但是事實會是這樣嗎?

可能存在下面一種情況:初始時,兩個線程分別讀取i的值存入各自所在的工作內存當中,然後線程1進行加1操作,然後把i的最新值11寫入到內存。此時線程2的工作內存當中i的值還是10,進行加1操作之後,i的值為11,然後線程2把i的值寫入內存。

最終結果i的值是11,而不是12。這就是著名的緩存一致性問題。通常稱這種被多個線程訪問的變數為共享變數。

那麼如何確保共享變數在多線程訪問時能夠正確輸出結果呢?

在解決這個問題之前,我們要先了解並發編程的三大概念: 原子性,有序性,可見性

1.定義

原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

2.實例

一個很經典的例子就是銀行賬戶轉賬問題:

比如從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。

試想一下,如果這2個操作不具備原子性,會造成什麼樣的後果。假如從賬戶A減去1000元之後,操作突然中止。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。

所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。

同樣地反映到並發編程中會出現什麼結果呢?

舉個最簡單的例子,大家想一下假如為一個32位的變數賦值過程不具備原子性的話,會發生什麼後果?

假若一個線程執行到這個語句時,我暫且假設為一個32位的變數賦值包括兩個過程:為低16位賦值,為高16位賦值。

那麼就可能發生一種情況:當將低16位數值寫入之後,突然被中斷,而此時又有一個線程去讀取i的值,那麼讀取到的就是錯誤的數據。

3.Java中的原子性

在Java中, 對基本數據類型的變數的讀取和賦值操作是原子性操作 ,即這些操作是不可被中斷的,要麼執行,要麼不執行。

上面一句話雖然看起來簡單,但是理解起來並不是那麼容易。看下面一個例子i:

請分析以下哪些操作是原子性操作:

x = 10; //語句1

y = x; //語句2

x++; //語句3

x = x + 1; //語句4

咋一看,可能會說上面的4個語句中的操作都是原子性操作。其實只有語句1是原子性操作,其他三個語句都不是原子性操作。

語句1是直接將數值10賦值給x,也就是說線程執行這個語句的會直接將數值10寫入到工作內存中。

語句2實際上包含2個操作,它先要去讀取x的值,再將x的值寫入工作內存 ,雖然讀取x的值以及 將x的值寫入工作內存 這2個操作都是原子性操作,但是合起來就不是原子性操作了。

同樣的, x++和 x = x+1包括3個操作:讀取x的值,進行加1操作,寫入新的值

所以上面4個語句只有語句1的操作具備原子性。

也就是說, 只有簡單的讀取、賦值(而且必須是將數字賦值給某個變數,變數之間的相互賦值不是原子操作)才是原子操作。

從上面可以看出,Java內存模型只保證了基本讀取和賦值是原子性操作, 如果要實現更大范圍操作的原子性,可以通過synchronized和Lock來實現。由於synchronized和Lock能夠保證任一時刻只有一個線程執行該代碼塊,那麼自然就不存在原子性問題了,從而保證了原子性。

1.定義

可見性是指當多個線程訪問同一個變數時,一個線程修改了這個變數的值,其他線程能夠立即看得到修改的值。

2.實例

舉個簡單的例子,看下面這段代碼:

//線程1執行的代碼

int i = 0;

i = 10;

//線程2執行的代碼

j = i;

由上面的分析可知,當線程1執行 i =10這句時,會先把i的初始值載入到工作內存中,然後賦值為10,那麼在線程1的工作內存當中i的值變為10了,卻沒有立即寫入到主存當中。

此時線程2執行 j = i,它會先去主存讀取i的值並載入到線程2的工作內存當中,注意此時內存當中i的值還是0,那麼就會使得j的值為0,而不是10.

這就是可見性問題,線程1對變數i修改了之後,線程2沒有立即看到線程1修改的值。

3.Java中的可見性

對於可見性,Java提供了volatile關鍵字來保證可見性。

當一個共享變數被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。

而普通的共享變數不能保證可見性, 因為普通共享變數被修改之後,什麼時候被寫入主存是不確定的,當其他線程去讀取時,此時內存中可能還是原來的舊值,因此無法保證可見性。

另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖然後執行同步代碼,並且 在釋放鎖之前會將對變數的修改刷新到主存當中 。因此可以保證可見性。

1.定義

有序性:即程序執行的順序按照代碼的先後順序執行。

2.實例

舉個簡單的例子,看下面這段代碼:

int i = 0;

boolean flag = false;

i = 1; //語句1

flag = true; //語句2

上面代碼定義了一個int型變數,定義了一個boolean類型變數,然後分別對兩個變數進行賦值操作。從代碼順序上看,語句1是在語句2前面的,那麼JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,為什麼呢?這里可能會發生指令重排序(Instruction Reorder)。

下面解釋一下什麼是指令重排序, 一般來說,處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先後順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。

比如上面的代碼中,語句1和語句2誰先執行對最終的程序結果並沒有影響,那麼就有可能在執行過程中,語句2先執行而語句1後執行。

但是要注意,雖然處理器會對指令進行重排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那麼它靠什麼保證的呢?再看下面一個例子:

int a = 10; //語句1

int r = 2; //語句2

a = a + 3; //語句3

r = a*a; //語句4

這段代碼有4個語句,那麼可能的一個執行順序是:

那麼可不可能是這個執行順序呢: 語句2 語句1 語句4 語句3

不可能,因為處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令Instruction 2必須用到Instruction 1的結果,那麼處理器會保證Instruction 1會在Instruction 2之前執行。

雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢?下面看一個例子:

上面代碼中,由於語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以為初始化工作已經完成,那麼就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context並沒有被初始化,就會導致程序出錯。

從上面可以看出, 指令重排序不會影響單個線程的執行,但是會影響到線程並發執行的正確性。

也就是說, 要想並發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。

3.Java中的有序性

在Java內存模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程並發執行的正確性。

在Java裡面,可以通過volatile關鍵字來保證一定的「有序性」。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當於是讓線程順序執行同步代碼,自然就保證了有序性。

另外,Java內存模型具備一些先天的「有序性」, 即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。

下面就來具體介紹下happens-before原則(先行發生原則):

①程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作

②鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作

③volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作

④傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C

⑤線程啟動規則:Thread對象的start()方法先行發生於此線程的每個一個動作

⑥線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生

⑦線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行

⑧對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始

這8條規則中,前4條規則是比較重要的,後4條規則都是顯而易見的。

下面我們來解釋一下前4條規則:

對於程序次序規則來說,就是一段程序代碼的執行 在單個線程中看起來是有序的 。注意,雖然這條規則中提到「書寫在前面的操作先行發生於書寫在後面的操作」,這個應該是程序看起來執行的順序是按照代碼順序執行的, 但是虛擬機可能會對程序代碼進行指令重排序 。雖然進行重排序,但是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。因此, 在單個線程中,程序執行看起來是有序執行的 ,這一點要注意理解。事實上, 這個規則是用來保證程序在單線程中執行結果的正確性,但無法保證程序在多線程中執行的正確性。

第二條規則也比較容易理解,也就是說無論在單線程中還是多線程中, 同一個鎖如果處於被鎖定的狀態,那麼必須先對鎖進行了釋放操作,後面才能繼續進行lock操作。

第三條規則是一條比較重要的規則。直觀地解釋就是, 如果一個線程先去寫一個變數,然後一個線程去進行讀取,那麼寫入操作肯定會先行發生於讀操作。

第四條規則實際上就是體現happens-before原則 具備傳遞性

1.volatile保證可見性

一旦一個共享變數(類的成員變數、類的靜態成員變數)被volatile修飾之後,那麼就具備了兩層語義:

1)保證了 不同線程對這個變數進行操作時的可見性 ,即一個線程修改了某個變數的值,這新值對其他線程來說是立即可見的。

2) 禁止進行指令重排序。

先看一段代碼,假如線程1先執行,線程2後執行:

這段代碼是很典型的一段代碼,很多人在中斷線程時可能都會採用這種標記辦法。但是事實上,這段代碼會完全運行正確么?即一定會將線程中斷么?不一定,也許在大多數時候,這個代碼能夠把線程中斷,但是也有可能會導致無法中斷線程(雖然這個可能性很小,但是只要一旦發生這種情況就會造成死循環了)。

下面解釋一下這段代碼為何有可能導致無法中斷線程。在前面已經解釋過,每個線程在運行過程中都有自己的工作內存,那麼線程1在運行的時候,會將stop變數的值拷貝一份放在自己的工作內存當中。

那麼當線程2更改了stop變數的值之後,但是還沒來得及寫入主存當中,線程2轉去做其他事情了,那麼線程1由於不知道線程2對stop變數的更改,因此還會一直循環下去。

但是用volatile修飾之後就變得不一樣了:

第一:使用volatile關鍵字會 強制將修改的值立即寫入主存

第二:使用volatile關鍵字的話,當線程2進行修改時, 會導致線程1的工作內存中緩存變數stop的緩存行無效 (反映到硬體層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);

第三:由於線程1的工作內存中緩存變數stop的緩存行無效,所以 線程1再次讀取變數stop的值時會去主存讀取

那麼在線程2修改stop值時(當然這里包括2個操作,修改線程2工作內存中的值,然後將修改後的值寫入內存),會使得線程1的工作內存中緩存變數stop的緩存行無效,然後線程1讀取時,發現自己的緩存行無效,它會等待緩存行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。

那麼線程1讀取到的就是最新的正確的值。

2.volatile不能確保原子性

下面看一個例子:

大家想一下這段程序的輸出結果是多少?也許有些朋友認為是10000。但是事實上運行它會發現每次運行結果都不一致,都是一個小於10000的數字。

可能有的朋友就會有疑問,不對啊,上面是對變數inc進行自增操作,由於volatile保證了可見性,那麼在每個線程中對inc自增完之後,在其他線程中都能看到修改後的值啊,所以有10個線程分別進行了1000次操作,那麼最終inc的值應該是1000*10=10000。

這裡面就有一個誤區了, volatile關鍵字能保證可見性沒有錯,但是上面的程序錯在沒能保證原子性。 可見性只能保證每次讀取的是最新的值,但是volatile沒辦法保證對變數的操作的原子性。

在前面已經提到過, 自增操作是不具備原子性的,它包括讀取變數的原始值、進行加1操作、寫入工作內存 。那麼就是說自增操作的三個子操作可能會分割開執行,就有可能導致下面這種情況出現:

假如某個時刻變數inc的值為10,

線程1對變數進行自增操作,線程1先讀取了變數inc的原始值,然後線程1被阻塞了

然後線程2對變數進行自增操作,線程2也去讀取變數inc的原始值, 由於線程1隻是對變數inc進行讀取操作,而沒有對變數進行修改操作,所以不會導致線程2的工作內存中緩存變數inc的緩存行無效,也不會導致主存中的值刷新, 所以線程2會直接去主存讀取inc的值,發現inc的值時10,然後進行加1操作,並把11寫入工作內存,最後寫入主存。

然後線程1接著進行加1操作,由於已經讀取了inc的值,注意此時在線程1的工作內存中inc的值仍然為10,所以線程1對inc進行加1操作後inc的值為11,然後將11寫入工作內存,最後寫入主存。

那麼兩個線程分別進行了一次自增操作後,inc只增加了1。

根源就在這里,自增操作不是原子性操作,而且volatile也無法保證對變數的任何操作都是原子性的。

解決方案:可以通過synchronized或lock,進行加鎖,來保證操作的原子性。也可以通過AtomicInteger。

在java 1.5的java.util.concurrent.atomic包下提供了一些 原子操作類 ,即對基本數據類型的 自增(加1操作),自減(減1操作)、以及加法操作(加一個數),減法操作(減一個數)進行了封裝,保證這些操作是原子性操作。 atomic是利用CAS來實現原子性操作的(Compare And Swap) ,CAS實際上是 利用處理器提供的CMPXCHG指令實現的,而處理器執行CMPXCHG指令是一個原子性操作。

3.volatile保證有序性

在前面提到volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。

volatile關鍵字禁止指令重排序有兩層意思:

1)當程序執行到volatile變數的讀操作或者寫操作時, 在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行

2)在進行指令優化時, 不能將在對volatile變數的讀操作或者寫操作的語句放在其後面執行,也不能把volatile變數後面的語句放到其前面執行。

可能上面說的比較繞,舉個簡單的例子:

由於 flag變數為volatile變數 ,那麼在進行指令重排序的過程的時候, 不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。

並且volatile關鍵字能保證, 執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。

那麼我們回到前面舉的一個例子:

//線程1:

context = loadContext(); //語句1

inited = true; //語句2

//線程2:

while(!inited ){

sleep()

}

doSomethingwithconfig(context);

前面舉這個例子的時候,提到有可能語句2會在語句1之前執行,那麼久可能導致context還沒被初始化,而線程2中就使用未初始化的context去進行操作,導致程序出錯。

這里如果用volatile關鍵字對inited變數進行修飾,就不會出現這種問題了, 因為當執行到語句2時,必定能保證context已經初始化完畢。

1.可見性

處理器為了提高處理速度,不直接和內存進行通訊,而是將系統內存的數據獨到內部緩存後再進行操作,但操作完後不知什麼時候會寫到內存。

2.有序性

Lock前綴指令實際上相當於一個內存屏障(也成內存柵欄),它確保 指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面; 即在執行到內存屏障這句指令時,在它前面的操作已經全部完成。

synchronized關鍵字是防止多個線程同時執行一段代碼,那麼就會很影響程序執行效率,而volatile關鍵字在某些情況下性能要優於synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個條件:

1)對變數的寫操作不依賴於當前值

2)該變數沒有包含在具有其他變數的不變式中

下面列舉幾個Java中使用volatile的幾個場景。

①.狀態標記量

volatile boolean flag = false;

//線程1

while(!flag){

doSomething();

}

//線程2

public void setFlag() {

flag = true;

}

根據狀態標記,終止線程。

②.單例模式中的double check

為什麼要使用volatile 修飾instance?

主要在於instance = new Singleton()這句,這並非是一個原子操作,事實上在 JVM 中這句話大概做了下面 3 件事情:

但是在 JVM 的即時編譯器中存在指令重排序的優化。也就是說上面的第二步和第三步的順序是不能保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被線程二搶佔了,這時 instance 已經是非 null 了(但卻沒有初始化),所以線程二會直接返回 instance,然後使用,然後順理成章地報錯。

自己是從事了七年開發的Android工程師,不少人私下問我,2019年Android進階該怎麼學,方法有沒有?

沒錯,年初我花了一個多月的時間整理出來的學習資料,希望能幫助那些想進階提升Android開發,卻又不知道怎麼進階學習的朋友。【 包括高級UI、性能優化、架構師課程、NDK、Kotlin、混合式開發(ReactNative+Weex)、Flutter等架構技術資料 】,希望能幫助到您面試前的復習且找到一個好的工作,也節省大家在網上搜索資料的時間來學習。

4. volitate 原理

volatile保證多線程可見性,volatile修飾的變數不會引起上下文切換和調度
cpu緩存,cpu運算速度與內存讀寫不匹配,因為cpu運算速度比內存讀寫快的多
從主內存中獲取或者寫入數據會花費很長時間,現在大多數cpu都不會直接訪問內存,而是訪問cpu緩存,cpu緩存是cpu與主內存之間的臨時存儲器,容量小,交換速度快,緩存中的數據是內存中的一小部分數據,是cpu即將訪問的。當cpu調用大量數據時候,就先從緩存中讀取從而加快讀取速度
按照讀取順序與cpu結合的緊密程度,cpu緩存分為
一級緩存:L1位於cpu內核旁邊,是與cpu結合最為緊密的cpu緩存
二級緩存:L2分為內部和外部兩種晶元,內部晶元二級緩存運行速度與主頻相同,外部晶元二級緩存運行速度則只有主頻的一半
三級緩存,只有高端的cpu才有
每一級緩存中所存儲的數據都是下一級緩存中存儲的數據的一部分
cpu要讀取一個數據的時候,首先從一級緩存中查找,如果沒有就從二級中查找,如果還沒有就從三級緩存中或者是內存總進行查找,一般來說,每級緩存的命中率大概有0.8左右,也就是全部數據量的0.8可以在一級緩存中查到,只有剩下的0.2總數據量從二級緩存中或者是三級緩存或者是內存中讀取

緩存行:緩存是分line的,一個段對應一個緩存行,是cpu緩存種可分配的最小存儲單元,通常是64位元組:當cpu看到一條讀取內存的指令的時候,會把內存地址傳遞給一級緩存,一級緩存會檢查它是否有這個內存地址對應的緩存段,如果沒有就把整個緩存段從內存共或者更高級的緩存種載入進來。
cpu執行計算的過程為:程序和數據被載入到主內存中,指令和數據被載入到cpu緩存中,cpu執行指令將結果寫入cpu緩存中,cpu緩存中的數據寫回到主內存中,但是這種方式僅限於單核cpu的時候
如果伺服器是多核cpu呢,
多核處理器中主內存核處理器一樣是分開的,這時候,L3作為統一的高速緩存共享,處理器1擁有自己的L1 L2

這個時候當核0讀取了一個位元組根據局部性原理,與他相鄰的位元組同樣會被讀入核0的緩存中
核3也讀取了同樣的一個位元組,根據局部性原理,與他相鄰的位元組同樣會被讀入到核3的數據中
此時,核0和核3的緩存中擁有同樣的數據
核0修改了那個位元組之後,被修改後那個位元組被回寫到了核0的緩存中,但是該信息並沒有回寫到主內存
當核3訪問該數據的時候,造成該數據不同步
為了解決這個問題**,當一個cpu修改緩存中的位元組的時候,**伺服器中其他cpu的會被通知他們的緩存將是為無效,這樣核1在修改緩存中的數據的時候,核3會發現自己的緩存中的數據已經無效,核0將自己的寫回到主內存中,然後核3將重新讀取該數據
將代碼轉化為匯編指令的時候發現在匯編指令add之前有一個lock指令,lock指令就是關鍵。
lock指令的作用:在修改內存的時候使用lock前綴指令調用加鎖的讀修改寫操作,保證多處理器系統總處理器之間進行可靠的通訊
1.鎖匯流排,其他cpu對內存的讀寫請求會被阻塞,直到鎖釋放,不過實際候來的處理器都採用了緩存緩存代替鎖匯流排,因為匯流排開銷過大,鎖匯流排的時候其他cpu沒辦法訪問內存
2.lock後的寫操作會回寫已經修改的數據,同時讓其他cpu相關緩存行失效,從而重新從內存中載入最新的數據
3.不是內存屏障卻能完成內存屏障的功能,阻止屏障兩邊的指令重排序

嗅探式的緩存一致性協議:所有內存的傳輸都發生在一條共享的匯流排上,而所有的處理器都能看到這條匯流排,緩存本身是獨立的,但是內存是共享的。所有的內存訪問都要進行仲裁,即同一個指令周期種只有一個cpu緩存可以讀寫數據。cpu緩存不僅在內存傳輸的時候與匯流排打交道,還會不斷的在嗅探匯流排上發生數據交換跟蹤其他緩存在做什麼,所以當一個cpu緩存代表它所屬的處理器讀寫內存的時候,其他的處理器都會得到通知(主動通知),他們以此使自己的緩存保存同步。只要某個處理器寫內存,其他處理器就馬上直到這塊內存在他們的緩存段種已經失效。。
MESI協議是緩存一致性協議,在MESI協議中每個緩存行有四個狀態,Modified修改的,表示這行數據有效,數據被修改了和內存中的數據不一致,數據只存在當前cache中,Exclusive獨有的,這行數據有效,數據和內存中的數據一致,數據只存在在本cache中,Shared共享的,這行數據有效,數據和內存中的數據一致,數據存在很多cache中,Invalid這行數據無效,這里的Invalid shared modified都符合我們的嗅探式的緩存一致性協議,但是Exclusive表示獨占的,當前數據有效並且和內存中的數據一致,但是只在當前緩存中,Exclusive狀態解決了一個cpu緩存在讀寫內存的之前我們要通知其他處理器這個問題,只有當緩存行處於Exclusive和modified的時候處理器才能寫,也就是說只有在這兩種狀態之下,處理器是獨占這個緩存行的,當處理器想寫某個緩存行的時候,如果沒有獨占權就必須先發送一條我要獨占權的請求給匯流排,這個時候會通知處理器把他們擁有同一緩存段的拷貝失效,只要在獲得獨占權的時候處理器才能修改數據並且此時這個處理器直到這個緩存行只有一份拷貝並且只在它的緩存里,不會有任何沖突,反之如果其他處理器一直想讀取這個緩存行(馬上就能直到,因為一直在嗅探匯流排),獨占或已修改的緩存行必須要先回到共享狀態,如果是已經修改的緩存行,還要先將內容回寫到內存中。

volatile變數的讀寫
工作內存其實就是cpu緩存,當兩條線程同時操作主內存中的一個volatile變數時候,A線程寫了變數i,此時A線程發出lock指令,發出的lock指令鎖匯流排或者鎖緩存行,同時線程b的高速緩存中的緩存行內容失效,線程A想內存中回寫最新的i。當線程B讀取變數的時候,線程發現對應地址的緩存行被鎖了等待鎖釋放,鎖的一致性協議會保證它讀取到最新的值。

5. volatile關鍵字的使用場景

1.使用volatile白能量作為狀態標志。在該場景中,應用程序的某個狀態由一個線程設置,其他線程會讀取該狀態並作為下一步計算依據。這是適用volatile變數作為同步機制的好處是一個線程能夠「通知」另外一個線程某個事件的發生,而這些線程有無需因此而使用鎖,避免了鎖的開銷和相關問題。

2.使用volatile保障可見性。

3.使用volatile代替鎖。當多個線程共享一個變數(而非一組變數)時,通常需要使用鎖來保障對這些變數的更新操作的原子性,以避免數據不一致。利用volatile關鍵字寫的原子性,將這一組狀態變數封裝成一個對象,將更新操作通過新建對象並將該對象賦值給volatile變數來實現。

4.實現簡易版讀寫鎖。通過volatile變數和鎖的混合使用實現;鎖保障寫操作的原子性,volatile保證讀操作的可見性。但這種讀寫鎖允許線程可以讀取到共享變數的非最新值。
public class Counter{
private volatile long count = 0;
public long value(){
return count;
}

public void increment{
synchorized(this){
count++;
}
}
}

6. voliate怎麼保證可見性

上次我們學習了volatile是如何解決多線程環境下共享變數的內存可見性問題,並且簡單介紹了基於多核CPU並發緩存架構模型的Java內存模型。
詳情見文章:

volatile很難?由淺入深懟到CPU匯編,徹底搞清楚它的底層原理

在並發編程中,有三個重要的特性:

內存可見性
原子性
有序性
volatile解決了並發編程中的可見性和有序性,解決不了原子性的問題,原子性的問題需要依賴synchronized關鍵字來解決。

關於並發編程三大特性的詳細介紹,大家可以點擊下方卡片搜索查看:

搜更多精彩內容

並發編程三大特性

內存可見性在上一篇文章中已經驗證,本文我們繼續通過代碼學習如下內容:1、volatile為什麼解決不了原子性問題?2、緩存行、緩存行填充3、CPU優化導致的亂序執行4、經典面試題:DCL必須要有volatile關鍵字嗎?5、valatile關鍵字是如何禁止指令重排序的?

以上每一步都會有一段代碼來驗證,話不多說,開始輸出干貨!

1、volatile為什麼解決不了原子性問題?

如果你對volatile了解的還可以,那麼咱們繼續往下看,如果不是太熟悉,請先行閱讀上一篇文章。

老規矩,先來一段代碼:

volatile原子性問題代碼驗證

這段代碼的輸出結果是多少?10000?大於10000?小於10000?

程序執行10次輸出的結果如下:

join:10000
join:10000
join:10000
join:9819
join:10000
join:10000
join:10000
join:9898
join:10000
join:10000
會有小幾率的出現小於10000的情況,因此volatile是無法保證原子性的,那麼到底在什麼地方出問題了呢?
還是用上篇文章的圖來說明一下程序的整體流程:

當線程1從內存讀取num的值到工作內存,同時線程2也從內存讀取num的值到工作內存了,他倆各自操作自己的num++操作,但是關鍵點來了:

當線程1、2都執行完num++,線程1執行第五步store操作,通過匯流排將新的num值寫回內存,刷新了內存中的num值,同時觸發了匯流排嗅探機制,告知線程2其工作內存中的num不可用,因此線程2的num++得到的值被拋棄了,但是線程2的num++操作卻是執行了。

2、CPU緩存行

寫上篇文章的時候,有朋友問到了CPU的三級緩存以及緩存行相關的問題,然後我就找了一些資料學習,形成了下面的一張圖:

CPU緩存行

CPU和主內存RAM之間會有三級緩存,因為CPU的速度要比內存的速度要快的多,大概是100:1,也就是CPU的速度比內存要快100倍,因此有了CPU三級緩存,那為什麼是三級緩存呢?不是四級、五級呢?四個大字送給你:工業實踐!相關概念:

ALU:CPU計算單元,加減乘除都在這里算
PC:寄存器,ALU從寄存器讀取一次數據為一個周期,需要時間小於1ns
L1:1級緩存,當ALU從寄存器拿不到數據的時候,會從L1緩存去拿,耗時約1ns
L2:2級緩存,當L1緩存里沒有數據的時候,會從L2緩存去拿,耗時約3ns
L3:3級緩存,一顆CPU里的雙核共用,L2沒有,則去L3去拿,耗時約15ns
RAM內存:當緩存都沒有數據的時候,會從內存讀取數據
緩存行:CPU從內存讀取數據到緩存行的時候,是一行一行的緩存,每行是64位元組(現代處理器)
問題來了:

1、緩存行存在的意義?好處是什麼?

空間的考慮:一個地址被訪問,相連的地址很大可能也被訪問;

時間的考慮:最近訪問的會被頻繁訪問好處:比如相連的地址,典型的就是數組,連續內存訪問,很快!

2、緩存行會帶來什麼問題?

緩存行會導致緩存失效的問題,從而導致程序運行效率低下。例如下圖:

當x,y兩個變數在一個緩存行的時候:

1、線程1執行x++操作,將x和y所在的緩存行緩存到cpu core1裡面去,

2、線程2執行y++操作,也將x和y所在的緩存行緩存到cpu core2裡面去,

3、線程1執行了x++操作,寫入到內存,同時為了保證cpu的緩存一致性協議,需要使其他內核x,y所在的緩存行失效,意味著線程2去執行y++操作的時候,無法從自己的cpu緩存拿到數據,必須從內存獲取。

這就是緩存行失效!

一段代碼來驗證緩存行失效的問題:

緩存行失效常式

耗時:2079ms
這個時候我們做一個程序的改動,在x變數的前面和後面各加上7個long類型變數,如下:

再次運行,看耗時輸出:

耗時:671ms
大約三倍的速度差距!

關於緩存行的更多概念,大家也可以點擊下方卡片直接搜索更多信息:

搜更多精彩內容

緩存行

3、CPU優化導致的亂序執行

上文在說緩存行的時候,主要是因為CPU的速度大約是內存的速度的100倍,因此CPU在執行指令的時候,為了不等待內存數據的讀取,會存在CPU指令優化而導致亂序執行的情況。

看下面這段代碼:

cpu亂序執行常式

執行後輸出(我執行了900多萬次才遇到x=0,y=0的情況,可以試試你的運氣哦~):

因為CPU的速度比內存要快100倍,所以當有兩行不相關的代碼在執行的時候,CPU為了優化執行速度,是會亂序執行的,所以上面的程序會輸出:x=0,y=0的情況,也就是兩個線程的執行順序變成了:

x = b;
y = a;
a = 1;
b = 1;
這個時候我們就需要加volatile關鍵字了,來禁止CPU的指令重排序!

4、DCL單例模式需要加volatile嗎?

一道經典的面試題:DCL單例模式需要加volatile欄位嗎?先來看DCL單例模式的一段代碼:

DCL單例模式

DCL全稱叫做Double Check Lock,就是雙重檢查鎖來保證一個對象是單例的。

核心的問題就是這個INSTANCE變數是否需要加volatile關鍵字修飾?答案肯定是需要的。

首先我們來看new一個對象的位元組碼指令:

查看其位元組碼指令:

NEW java/lang/Object
DUP
INVOKESPECIAL java/lang/Object.<init> ()V
ASTORE 1
即:

1、創建並默認初始化Object對象;

2、復制操作數棧對該對象的引用;

3、調用Object對象的初始化方法;

4、將變數Object o指向創建的這個對象,此時變數o不再為null;

根據上文描述我們知道因為CPU和內存速度不匹配的問題,CPU在執行命令的時候是亂序執行的,即CPU在執行第3步初始化方法時候如果需要很長的時間,CPU是不會等待第3步執行完了才去執行第4步,所以執行順序可能是1、2、4、3。

那麼繼續看DCL單常式序,當線程1執行new DCLStudy()的順序是先astore再invokespecial,但是invokespecial方法還沒有執行的時候,線程2進來了,這個時候線程2拿到的就是一個半初始化的對象。

因此,DCL單例模式需要加volatile關鍵字,來禁止上述new對象的過程的指令重排序!

valatile關鍵字是如何禁止指令重排序的

JVM規范中規定:凡是被volatile修飾的變數,在進行其操作時候,需要加內存屏障!

JVM規范中定義的JSR內存屏障定義:

LoadLoad屏障:
對於語句Load1;LoadLoad;Load2;Load1和Load2語句不允許重排序。
StoreStore屏障:
對於語句Store1;StoreStore;Store2;Store1和Store2語句不允許重排序。
LoadStore屏障:
對於語句Load1;StoreStore;Store2;Load1和Store2語句不允許重排序。
StoreLoad屏障:
對於語句Store1;StoreStore;Load2;Store1和Load2語句不允許重排序。
JVM層面volatile的實現要求:

如果對一個volatile修飾的變數進行寫操作:

前面加StoreStoreBarrier屏障,保證前面所有的store操作都執行完了才能對當前volatile修飾的變數進行寫操作;

後面要加StoreLoadBarrier,保證後面所有的Load操作必須等volatile修飾的變數寫操作完成。

如果對一個volatile修飾的變數進行讀操作:

後面的讀操作LoadLoadBarrier必須等當前volatile修飾變數讀操作完成才能讀;

後面的寫操作LoadStoreBarrier必須等當前的volatile修飾變數讀操作完成才能寫。

上篇文章我們通過一定的方式看到了程序執行的volatile修飾的變數底層匯編碼:

0x000000010d3f3203: lock addl $0x0,(%rsp) ;*putstatic flag
; - com.java.study.VolatileStudy::lambda$main$1@9 (line 31)
也就是到CPU的底層執行的命令其實就是這個lock,這個lock指令既完成了變數的可見性還保證了禁止指令充排序:

LOCK用於在多處理器中執行指令時對共享內存的獨占使用。它的作用是能夠將當前處理器對應緩存的內容刷新到內存,並使其他處理器對應的緩存失效;另外還提供了有序的指令無法越過這個內存屏障的作用。
end

至此,對volatile的學習就到這里了,通過兩篇文章來對volatile這個關鍵字有了一個系統的學習。

學無止境,對volatile的學習還只是一個基礎學習,還有更多的知識等待我們去探索學習,例如:

什麼是as-if-serial?什麼是happens-before?
Java的哪些指令可以重排序呢?重排序的規則是什麼?
我們下期再見!

1372閱讀
搜索
java自學一般要學多久
volatile底層原理
匯編111條指令詳解
java必背100源代碼
volatile架構圖
嵌入式volatile詳解

熱點內容
php旅遊網站系統 發布:2024-05-07 20:27:32 瀏覽:610
jdk源碼怎麼看 發布:2024-05-07 20:18:22 瀏覽:519
編程c語言自學書 發布:2024-05-07 20:12:03 瀏覽:422
usb大容量存儲驅動 發布:2024-05-07 19:02:01 瀏覽:815
紅米1s沒有存儲空間 發布:2024-05-07 18:59:09 瀏覽:505
妖雲解壓密碼 發布:2024-05-07 18:50:08 瀏覽:1002
sql語句等於怎麼寫 發布:2024-05-07 18:05:46 瀏覽:816
我的世界電腦版第三方伺服器大全 發布:2024-05-07 18:00:46 瀏覽:627
主伺服器的ip地址 發布:2024-05-07 17:58:50 瀏覽:546
組伺服器打電腦游戲 發布:2024-05-07 17:46:19 瀏覽:866