Synchronized
在多線程並發中synchronized一直是元老級別的角色。利用synchronized來實現同步具體有一下三種表現形式:
- 對於普通的同步方法,鎖是當前實例對象。
- 對於靜態同步方法,鎖是當前類的class對象。
- 對於同步方法塊,鎖是synchronized括號里配置的對象。
當一個代碼,方法或者類被synchronized修飾以后。當一個線程試圖訪問同步代碼塊的時候,它首先必須得到鎖,退出或拋出異常的時候必須釋放鎖。那么這樣做有什么好處呢?
它主要確保多個線程在同一時刻,只能有一個線程處於方法或者同步塊中,它保證了線程對變量的可見性和排他性。
如何實現排他性
如下圖所示,一個普通的方法會有一個左右擺動的開關,可以連接到任意一個線程,如果該方法代碼不是原子性的,可能會出現一個線程並沒有將方法代碼執行完畢就鏈接到另一個線程中去。而被synchronized修飾的方法,鏈接到一個線程后,除非這個線程將方法執行完畢或者拋出異常,開關才會鏈接至別的線程。就這樣將一個並行的操作變了穿行操作。(同一時間保證只有一個線程在執行方法代碼)
int i = 1;
public synchronized void increment(){
i++;
}
在前面並發基礎及鎖的原理中我們介紹過i++並不是原子操作,所有當多個線程同時操作i++的時候可能會出現多線程並發問題。而上訴代碼塊中i++是在synchronized修飾的方法中。其中一個線程進入該方法首先獲得當前實例對象的鎖,當另一個線程試圖執行該方法的時候,由於前一個線程並沒有執行完畢釋放掉鎖,所以該線程掛起等待鎖的釋放。
通過加鎖的方式我們實現了將i++非原子操作的方法變成了原子操作的方法。從而實現了排他性。
如何實現可見性
因為在java內存模型中規定:在執行被synchronized修飾的代碼時,線程首先獲取鎖→清空工作內存→在主內存中拷貝最新變量的副本到工作內存→執行完代碼→將工作內存中更改后的共享變量的值刷新到主內存中→釋放互斥鎖。
這里有一個細節需要注意: 當一個線程A將最新的共享變量刷新到主內存的時候,會導致緩存在其他線程B的工作內存的這個共享變量失效。
當線程B下一次去訪問這個變量的時候,會發現,工作緩存的這個變量已經失效。會強制從主內存中重新讀取這個共享變量
Volatile
當聲明共享變量為volatile后,對這個變量的讀/寫將會很特別。volatile可以說是java虛擬機提供的最輕量級的同步機制。他只能能只能保證變量的可見性與讀/寫的原子性。要理解volatile確實是不容易的,接下來我們進入深入的分析!
volatile的特性
下面有兩個示例代碼:
public class VolatileTest1 {
volatile long a = 0L; //使用volatile聲明64位的long型變量
public void set(long b) {
a = b; //單個volatile變量的寫
}
public void increment() {
a++; //復合(多個)volatile變量的讀/寫
}
public long get() {
return a; //的那個volatile變量的讀
}
}
public class VolatileTest2 {
long a = 0L; //64位的普通long型變量
public synchronized void set(long b) { //單個普通變量的寫使用同步鎖
a = b;
}
public void increment() { //普通方法調用
long tmp = get(); //調用以同步的讀方法
tmp += 1; //普通的寫操作
set(tmp); //調用以同步的寫方法
}
public synchronized long get() { //單個普通變量的讀使用同步鎖
return a;
}
}
上述兩個示例代碼所帶來的的執行效果是相同的。
可以看到被volatile修飾的變量讀與寫操作是原子性的。如前面所述,被Synchronized修飾的變量每次寫操作完成后,會強制將工作內存中緩存的共享變量強制刷新到主內存中。所以保證了volatile修飾變量的可見性。
從上述示例代碼中我們也能看出,即便讀與寫是原子性,但是依舊不能保證 a++;是原子操作。這也是很多人對volatile字段理解困難的原因所在。
簡而言之,volatile變量自身具有下列特征。
- 可見性:對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最后的寫入。
- 原子性:對任意單個volat變量的讀 / 寫具有原子性,但類似volatile++這種復合操作不具有原子性。
這里插入一個以前樓主遇到過的一個面試題:請問對於double和long類型的讀寫是原子性的嗎?
double和long類型是64位的,在一些32位的處理器上,可能會把一個64位的long/double型變量的寫操作才分為兩個32位的寫操作來執行。所以此時對這個64位變量的寫操作將不再具有原子性。 但是如果被volatile修飾的話,寫64位的double和long的操作依舊是原子操作。
volatile的禁止重排序
除了前面內存可見性中講到的volatile關鍵字可以保證變量修改的可見性之外,還有另一個重要的作用:在JDK1.5之后,可以使用volatile變量禁止指令重排序。
volatile關鍵字通過提供“內存屏障”的方式來防止指令被重排序,為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。
對於編譯器來說,發現一個最優布置來最小化插入屏障的總數幾乎不可能,為此,Java內存模型采取保守策略。下面是基於保守策略的JMM內存屏障插入策略:
- 在每個volatile寫操作的前面插入一個StoreStore屏障。
- 在每個volatile寫操作的后面插入一個StoreLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadStore屏障
總結來說:
- volatile寫操作之前的操作不會被編譯器重排序到寫操作之后。
- volatile讀之后的操作不會被編譯器重排序到volatile讀操作之前。
- 第一個是volatile讀操作,第二個是volatile寫操作,不能重排序
volatile的使用場景
-
狀態標志
用volatile修飾的boolean 變量來作為while循環的的判斷條件:當這個變量被其他線程修改的時候能保證while循環能立即讀到。
-
一次性安全發布
初始化對象的正確步驟為:
1、分配對象的內存空間
2、初始化對象
3、設置引用指向剛分配的內存地址
然而由於重排序機制,可能導致2、3步驟重排序,導致初始化對象的步驟變為 1-3-2。 著名的**雙重檢查鎖**定存在的問題就是因為初始化對象的重排序,引用所指向的對象可能還沒有完成初始化,而僅僅是指向了一個空的內存地址。
-
獨立觀察
這是第一種使用場景的引用。例如一種環境傳感器能夠感覺環境溫度。一個后台線程可能會每隔幾秒讀取一次該傳感器,並更新包含當前文檔的 volatile 變量。然后,其他線程可以讀取這個變量,從而隨時能夠看到最新的溫度值。
-
開銷較低的讀-寫鎖策略
前面我們介紹過,因為 ++x 實際上是三種操作(讀、添加、存儲)的簡單組合,如果多個線程湊巧試圖同時對 volatile 計數器執行增量操作,那么它的更新值有可能會丟失。但是被volatile修飾變量的讀 / 寫卻是原子操作。所以當共享變量被volatile修飾之后,我們只需要在復合操作的方法上加上synchronized比直接用synchronized修飾該變量效率高的多。
volatile總結
相對於synchronized塊的代碼鎖,volatile應該是提供了一個輕量級的針對共享變量的鎖,當我們在多個線程間使用共享變量進行通信的時候需要考慮將共享變量用volatile來修飾。
volatile是一種稍弱的同步機制,在訪問volatile變量時不會執行加鎖操作,也就不會執行線程阻塞,因此volatilei變量是一種比synchronized關鍵字更輕量級的同步機制。
synchronized和volatile的區別
1、 volatile不會進行加鎖操作:
volatile變量是一種稍弱的同步機制在訪問volatile變量時不會執行加鎖操作,因此也就不會使執行線程阻塞,因此volatile變量是一種比synchronized關鍵字更輕量級的同步機制。
2、volatile變量作用類似於同步變量讀寫操作:
從內存可見性的角度看,寫入volatile變量相當於退出同步代碼塊,而讀取volatile變量相當於進入同步代碼塊。
3、volatile不如synchronized安全:
在代碼中如果過度依賴volatile變量來控制狀態的可見性,通常會比使用鎖的代碼更脆弱,也更難以理解。僅當volatile變量能簡化代碼的實現以及對同步策略的驗證時,才應該使用它。一般來說,用同步機制會更安全些。
4、volatile無法同時保證內存可見性和原則性:
加鎖機制(即同步機制)既可以確保可見性又可以確保原子性,而volatile變量只能確保可見性,原因是聲明為volatile的簡單變量如果當前值與該變量以前的值相關,那么volatile關鍵字不起作用,也就是說如下的表達式都不是原子操作:“count++”、“count = count+1”。