1. 程式人生 > 其它 >synchronized和volatile的區別

synchronized和volatile的區別

synchronized和volatile的區別:

一旦一個共享變數(類的成員變數、類的靜態成員變數)被volatile修飾之後,那麼就具備了兩層語義:
1)保證了不同執行緒對這個變數進行操作時的可見性,即一個執行緒修改了某個變數的值,這新值對其他執行緒來說是
立即可見的。
2)禁止進行指令重排序。
volatile本質是在告訴jvm當前變數在暫存器(工作記憶體)中的值是不確定的,需要從主存中讀取;
synchronized則是鎖定當前變數,只有當前執行緒可以訪問該變數,其他執行緒被阻塞住。

1.volatile僅能使用在變數級別;
synchronized則可以使用在變數、方法、和類級別的

2.volatile僅能實現變數的修改可見性,並不能保證原子性;

synchronized則可以保證變數的修改可見性和原子性

3.volatile不會造成執行緒的阻塞;
synchronized可能會造成執行緒的阻塞。

4.volatile標記的變數不會被編譯器優化;
synchronized標記的變數可以被編譯器優化

 

講到Java併發,多執行緒程式設計,一定避免不了對關鍵字volatile的瞭解,那麼如何來認識volatile,從哪些方面來了解它會比較合適呢?

個人認為,既然是多執行緒程式設計,那我們在平常的學習中,工作中,大部分都接觸到的就是執行緒安全的概念。

而執行緒安全就會涉及到共享變數的概念,所以首先,我們得弄清楚共享變數是什麼,且處理器和記憶體間的資料互動機制是如何導致共享變數變得不安全。

共享變數

能夠在多個執行緒間被多個執行緒都訪問到的變數,我們稱之為共享變數。共享變數包括所有的例項變數,靜態變數和陣列元素。他們都被存放在堆記憶體中。

處理器與記憶體的通訊機制

大家都知道處理器是用來做計算的,且速度是非常快的,而記憶體是用來儲存資料的,且其訪問速度相比處理器來說,是慢了好幾個級別的。那麼當處理器需要處理資料時,如果每次都直接從記憶體拿資料的話,就會導致效率非常低,因此在現代計算機系統中,處理器是不直接跟記憶體通訊的,而是在處理器和記憶體之間設定了多個快取,也就是我們常常聽到的L1, L2, L3等快取記憶體。

具體架構如下所示:

 

memory_processor_communication.png

處理器都是將資料從記憶體讀到自己內部的快取中,然後在快取中對資料進行修改等操作,結束後再由快取寫到回主存中去。如果一個共享變數 X,在多執行緒的情況下,同時被多個處理器讀到各自的快取中去,當其中一個處理器修改了X的值,改成Y了,先寫回了記憶體,而此時另外一個處理器,又將X改成Z,再寫回記憶體,那麼之前的Y就會被覆蓋掉了。

這種情況下,資料就已經有問題了,這種因為多執行緒操作而導致的異常問題,通常我們就叫做執行緒不安全。

 

memory_processor_communication_core1.png

 

memory_processor_communication_core2.png

如上述兩圖所示,X的變數同時被不同的處理器修改成各自的Y和Z,那麼如何避免這種情況呢?這就涉及到了Java記憶體模型中的可見性的概念。

Java記憶體模型之可見性

可見性,意思就是說,在多執行緒程式設計中,某個共享變數在其中一個執行緒被修改了,其修改結果要馬上能夠被其他執行緒看到,拿上面的例子來說,也就是當X在其中一個處理器的快取中被修改成Y了, 另一個處理器必須能夠馬上知道自己快取中的X已經被修改成Y了,當此處理器要拿此變數去參與計算的時候,必須重新去記憶體中將此變數的值Y讀到快取中。

而一個變數,如果被宣告成violate,那麼其就能保證這種可見性,這就是volatile變數的作用了。

volatile

那麼 volatile 變數能夠保證可見性的實現原理是什麼?宣告成volatile的變數,在編譯成彙編指令的時候,會多出以下一行:

0x0bca13ae:lock addl $0x0,(%esp)      ;

這一句指令的意思是在暫存器上做一個+0的空操作,但這條指令有個Lock字首。而處理器在處理Lock字首指令時,其實是聲言了處理器的Lock#訊號。在之前的處理器中,Lock#訊號會導致傳輸資料的匯流排被鎖定,其他處理器都不能訪問匯流排,從而保證處理Lock指令的處理器能夠獨享操作資料所在的記憶體區域。

但由於匯流排被鎖住,其他的處理器都被堵住了,影響多處理器執行的效率。在後來的處理器中,聲言Lock#訊號的處理器,不會再鎖住匯流排,而是檢查到資料所在的記憶體區域,如果是在處理器的內部快取中,則會鎖定此快取區域,將快取寫回到記憶體當中,並利用快取一致性的原則來保證其他處理器中的快取區域資料的一致性。

快取一致性

快取一致性原則會保證一個在快取中的資料被修改了,會保證其他快取了此資料的處理器中的快取失效,從而讓處理器重新去記憶體中讀取最新修改後的資料。

在實際的處理器操作中,各個處理器會一直在總線上嗅探其內部快取區域中的記憶體地址在其它處理器的操作情況,一旦嗅探到某處理器打算修改某記憶體地址,而此記憶體地址剛好也在自己內部的快取中,則會強制讓自己的快取無效。當下次訪問此記憶體地址的時候,則重新從記憶體當中讀取新資料。

volatile不僅保證了共享變數在多執行緒間的可見性,其還保證了一定的有序性。

有序性

何謂有序性呢?事實上,java程式程式碼在編譯器階段和處理器執行階段,為了優化執行的效率,有可能會對指令進行重排序。如果一些指令彼此之間互相不影響,那麼就有可能不按照程式碼順序執行,比如後面的程式碼先執行,而之前的程式碼則慢執行,但處理器會保證結束時的輸出結果是一致的。以上的這種情況就說明指令有可能不是有序的。

volatile變數,上面我們看過其彙編指令,會多出一條Lock字首的指令,這條指令能夠 保證,在這條指令之前的所有指令全部執行完畢,而在這條指令之後的所有指令全部未執行,也相於在這裡立起了一道柵欄,稱之為記憶體柵欄,而更通俗的說法,則是記憶體屏障。

那麼有了這道屏障,volatile變數就禁止了指令的重排序,從而保證了指令執行的有序性。

所有對volatile變數的讀操作一定發生在對volatile變數的寫操作之後。這同時也說明了volatile變數在多個執行緒之間能夠實現可見性的原理。所以各種規定和操作,其實之間互有關聯,彼此依賴,才能更好地保證指令執行的準確和效率。

記憶體屏障

在上面我們也引出了記憶體屏障的概念,也知道了,其實它就是一組處理器的操作指令。

插入一個記憶體屏障,則相當於告訴處理器和編譯器先於這個指令的必須先執行,後於這個指令的必須後執行。

 

image

記憶體屏障另一個作用是強制更新一次不同CPU的快取。

例如,一個寫屏障會把這個屏障前寫入的資料重新整理到快取,這樣任何試圖讀取該資料的執行緒將得到最新值,而不用考慮到底是被哪個cpu核心或者哪顆CPU執行的。

這再仔細一想,不就是上面所說的volatile的作用嗎?

所以,記憶體屏障,可見性,有序性,快取一致性原則,在java併發中各種各樣的名詞,本質上可能就只是同一種現象或者同一種設計,從不同的角度觀察和探討所得出的不同的解釋。

每一個剛接觸多執行緒併發程式設計的同學,當被問到,如果多個執行緒同時訪問一段程式碼,發生併發的時候,應該怎麼處理?

我相信閃現在腦海中的第一個解決方案就是用synchronized,用鎖,讓這段程式碼同一時間只能被一個執行緒執行。 我們也知道,synchronized關鍵字可以用在方法上,也可以用在程式碼塊上,如果要使用synchronized,我們一般就會如下使用:

public synchronized void doSomething() {
    //do something here
}

或者

synchronized(LockObject) {
    //do something here
}

那麼實際上,synchronized關鍵字到底是怎麼加鎖的?鎖又長什麼樣子的呢?關於鎖,還有一些什麼樣的概念需要我們去認識,去學習,去理解的呢?

以前在學習synchronized的時候,就有文章說, synchronized是一個很重的操作,開銷很大,不要輕易使用,我們接受了這樣的觀點,但是為什麼說是重的操作呢,為什麼開銷就大呢?

到java 1.6之後,java的開發人員又針對鎖機制實現了一些優化,又有文章告訴我們現在經過優化後,使用synchronized並沒有什麼太大的問題了,那這又是因為什麼原因呢?到底是做了什麼優化?

那今天我們就嘗試著從鎖機制實現的角度,來講述一下synchronized在java虛擬機器上面的適應場景是怎麼樣的。

由於java在1.6之後,引入了一些優化的方案,所以我們講述synchronized,也會基於java1.6之後的版本。

鎖物件

首先,我們要知道鎖其實就是一個物件,java中每一個物件都能夠作為鎖。

所以我們在使用synchronized的時候,

  1. 對於同步程式碼塊,就得指定鎖物件。
  2. 對於修飾方法的synchronized,預設的鎖物件就是當前方法的物件。
  3. 對於修飾靜態方法的synchronized,其鎖物件就是此方法所對應的類Class物件。

我們知道,所謂的物件,無非也就是記憶體上的一段地址,上面存放著對應的資料,那麼我們就要想,作為鎖,它跟其它的物件有什麼不一樣呢?怎麼知道這個物件就是鎖呢?怎麼知道它跟哪個執行緒關聯呢?它又怎麼能夠控制執行緒對於同步程式碼塊的訪問呢?

Markword

可以瞭解到在虛擬機器中,物件在記憶體中的儲存分為三部分:

  1. 物件頭
  2. 例項資料
    3 對齊填充

其中,物件頭填充的是該物件的一些執行時資料,虛擬機器一般用2到3個字寬來儲存物件頭。

  1. 陣列物件,會用3個字寬來儲存。
  2. 非資料物件,則用2個字寬來儲存。

其結構簡單如下:

 


從上表中,我們可以看到,鎖相關的資訊,是存在稱之為Markword中的記憶體域中。

拿以下的程式碼作為例子,

synchonized(LockObject) {
    //do something here
}

在物件LockObject的物件頭中,當其被建立的時候,其Markword的結構如下:

 

從上面Markword的結構中,可以看出

所有新建立的物件,都是可偏向的(鎖標誌位為01),但都是未偏向的(是否偏向鎖標誌位為0)。

偏向鎖

當執行緒執行到臨界區(critical section)時,此時會利用CAS(Compare and Swap)操作,將執行緒ID插入到Markword中,同時修改偏向鎖的標誌位。

這說明此物件就要被當做一個鎖來使用,那麼其Markword的內容就要發生變化了。 其結構其會變成如下:

 

可以看到,

  1. 鎖的標誌位還是01
  2. “是否偏向鎖”這個欄位變成了1
  3. hash值變成了執行緒ID和epoch值

也就是說,這個鎖將自己偏向了當前執行緒,心裡默默地藏著執行緒id, 在這裡,我們就引入了“偏向鎖”的概念。

在此執行緒之後的執行過程中,如果再次進入或者退出同一段同步塊程式碼,並不再需要去進行加鎖或者解鎖操作,而是會做以下的步驟:

  1. Load-and-test,也就是簡單判斷一下當前執行緒id是否與Markword當中的執行緒id是否一致.
  2. 如果一致,則說明此執行緒已經成功獲得了鎖,繼續執行下面的程式碼
  3. 如果不一致,則要檢查一下物件是否還是可偏向,即“是否偏向鎖”標誌位的值。
  4. 如果還未偏向,則利用CAS操作來競爭鎖,也即是第一次獲取鎖時的操作。
  5. 如果此物件已經偏向了,並且不是偏向自己,則說明存在了競爭。此時可能就要根據另外執行緒的情況,可能是重新偏向,也有可能是做偏向撤銷,但大部分情況下就是升級成輕量級鎖了。

以下是Java開發人員提供的一張圖:

 

biased-locking.png

“偏向鎖”是Java在1.6引入的一種優化機制,其核心思想在於,可以讓同一個執行緒一直擁有同一個鎖,直到出現競爭,才去釋放鎖。

因為經過虛擬機器開發人員的調查研究,在大多數情況下,總是同一個執行緒去訪問同步塊程式碼,基於這樣一個假設,引入了偏向鎖,只需要用一個CAS操作和簡單地判斷比較,就可以讓一個執行緒持續地擁有一個鎖。

也正因為此假設,在Jdk1.6中,偏向鎖的開關是預設開啟的,適用於只有一個執行緒訪問同步塊的場景。

鎖膨脹

在上面,我們講到,一旦出現競爭,也即有另外一個執行緒也要來訪問這一段程式碼,偏向鎖就不適用於這種場景了。

如果兩個執行緒都是活躍的,會發生競爭,此時偏向鎖就會發生升級,也就是我們常常聽到的鎖膨脹。

偏向鎖會膨脹成輕量級鎖(lightweight locking)。

鎖撤銷

偏向鎖有一個不好的點就是,一旦出現多執行緒競爭,需要升級成輕量級鎖,是有可能需要先做出銷撤銷的操作。

而銷撤銷的操作,相對來說,開銷就會比較大,其步驟如下:

  1. 在一個安全點停止擁有鎖的執行緒,就跟開始做GC操作一樣。
  2. 遍歷執行緒棧,如果存在鎖記錄的話,需要修復鎖記錄和Markword,使其變成無鎖狀態。
  3. 喚醒當前執行緒,將當前鎖升級成輕量級鎖。

輕量級鎖

而本質上呢,其實就是鎖物件頭中的Markword內容又要發生變化了。

下面先簡單地描述 其膨脹的步驟:

  1. 執行緒在自己的棧楨中建立鎖記錄 LockRecord
  2. 將鎖物件的物件頭中的MarkWord複製到執行緒的剛剛建立的鎖記錄中
  3. 將鎖記錄中的Owner指標指向鎖物件
  4. 將鎖物件的物件頭的MarkWord替換為指向鎖記錄的指標。

同樣,我們還是利用Java開發人員提供的一張圖來描述此步驟:

 

lightweight-locking-01.png

 

lightweight-locking-02.png

可以根據上面兩圖來印證上面幾個步驟,但在這裡,其實物件的Markword其實也是發生了變化的,其現在的內容結構如下:

bit fields 鎖標誌位 指向LockRecord的指標 00

說到這裡,我們又通過偏向鎖引入了輕量級鎖的概念,那麼輕量級鎖是怎麼個輕量級法,它具體的實現又是怎麼樣的呢?

就像偏向鎖的前提,是同步程式碼塊在大多數情況下只有同一個執行緒訪問的時候。 而輕量級鎖的前提則是,執行緒在同步程式碼塊裡面的操作非常快,獲取鎖之後,很快就結束操作,然後將鎖釋放出來。

但是不管再怎麼快,一旦一個執行緒獲得鎖了,那麼另一個執行緒同時也來訪問這段程式碼時,怎麼辦呢?這就涉及到我們下面所說的鎖自旋的概念了。

自旋鎖/自適應自旋鎖

來到輕量級鎖,其實輕量級的敘述就來自於自旋的概念。 因為前提是執行緒在臨界區的操作非常快,所以它會非常快速地釋放鎖,所以只要讓另外一個執行緒在那裡地迴圈等待,然後當鎖被釋放時,它馬上就能夠獲得鎖,然後進入臨界區執行,然後馬上又釋放鎖,讓給另外一個執行緒。 所謂自旋,就是執行緒在原地空迴圈地等待,不阻塞,但它是消耗CPU的。 所以對於輕量級鎖,它也有其限制所在:

  1. 因為消耗CPU,所以自旋的次數是有限的,如果自旋到達一定的次數之後,還獲取不到鎖,那這種自旋也就無意義。但在上述的前提下,這種自旋的次數還是比較少的(經驗資料)。 當然,一開始的自旋次數都是固定的,但是在經驗程式碼中,獲得鎖的執行緒通常能夠馬上再獲得鎖,所以又引入了自適應的自旋,即根據上次獲得鎖的情況和當前的執行緒狀態,動態地修改當前執行緒自旋的次數。
  2. 當另一個執行緒釋放鎖之後,當前執行緒要能夠馬上獲得鎖,所以如果有超過兩個的執行緒同時訪問這段程式碼,就算另外一個執行緒釋放鎖之後,當前執行緒也可能獲取不到鎖,還是要繼續等待,空耗CPU。

從以上兩點可以看出,當執行緒通過自旋獲取不到鎖了,比如臨界區的操作太花時間了,或者有超過2個以上的執行緒在競爭鎖了,輕量級鎖的前提又不成立了。當虛擬機器檢查到這種情況時,又開始了膨脹的腳步。

互斥鎖(重量級鎖)

相比起輕量級鎖,再膨脹的鎖,一般稱之為重量級鎖,因為是依賴於每個物件內部都有的monitor鎖來實現的,而monitor又依賴於作業系統的MutexLock(互斥鎖)來實現,所以一般重量級鎖也叫互斥鎖。

由於需要在作業系統的核心態和使用者態之間切換的,需要將執行緒阻塞掛起,切換執行緒的上下文,再恢復等操作,所以當synchronized升級成互斥鎖,依賴monitor的時候,開銷就比較大了,而這也是之前為什麼說synchronized是一個很重的操作的原因了。

當然,升級成互斥鎖之後,鎖物件頭的Markword內容也是會變化的,其內容如下:

 

每次檢查當前執行緒是否獲得鎖,其實就是檢查Mutex的值是否為0,不為0,說明其為其執行緒所佔有,此時作業系統就會介入,將執行緒阻塞,掛起,釋放CPU時間,等待下一次的執行緒排程。

好了,到這裡,對於synchronized所修改的同步方法或者同步程式碼塊,虛擬機器是如何操作的,大家應該也有一個簡單的印象了。

當使用synchronized關鍵字的時候,在java1.6之後,根據不同的條件和場景,虛擬機器是一步一步地將偏向鎖升級成輕量級鎖,再最終升級成重量級鎖的,而這個過程是不可逆的,因為一旦升級成重量級鎖,則說明偏向鎖和輕量級鎖是不適用於當前的應用場景的,那再降級回去也沒什麼意義。

從這一點,也可以看出,如果我們的應用場景本身就不適用於偏向鎖和輕量級鎖,那麼我們在程式一開始,就應該禁用掉偏向鎖和輕量級鎖,直接使用重量級鎖,省去無謂的開銷。

總結

在這裡總結一下,在使用synchronized關鍵字的時候,本質上是否獲得鎖,是通過修改鎖物件頭中的markword的內容來標記是否獲得鎖,並由虛擬機器來根據具體的應用場景來鎖進行升級。

簡單地將上述幾個零散的markword變化合在一起,展示在下面:

  抄錄於 https://www.cnblogs.com/cqqfboy/p/15266792.html