Java併發系列之volatile和Synchronized


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

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

而線程安全就會涉及到共享變量的概念,所以首先,我們得弄清楚共享變量是什麼,且處理器和內存間的數據交互機制是如何導致共享變量變得不安全。

共享變量

能夠在多個線程間被多個線程都訪問到的變量,我們稱之為共享變量。共享變量包括所有的實例變量,靜態變量和數組元素。他們都被存放在堆內存中。

處理器與內存的通信機制

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

具體架構如下所示:


Java併發系列之volatile和Synchronized

memory_processor_communication.png

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

這種情況下,數據就已經有問題了,這種因為多線程操作而導致的異常問題,通常我們就叫做線程不安全

Java併發系列之volatile和Synchronized

memory_processor_communication_core1.png


Java併發系列之volatile和Synchronized

memory_processor_communication_core2.png

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

Java內存模型之可見性

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

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

volatile

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

<code>0x0bca13ae:lock addl $0x0,(%esp)      ;
/<code>

這一句指令的意思是在寄存器上做一個+0的空操作,但這條指令有個Lock前綴。而處理器在處理Lock前綴指令時,其實是聲言了處理器的Lock#信號。在之前的處理器中,Lock#信號會導致傳輸數據的總線被鎖定,其他處理器都不能訪問總線,從而保證處理Lock指令的處理器能夠獨享操作數據所在的內存區域。

但由於總線被鎖住,其他的處理器都被堵住了,影響多處理器執行的效率。在後來的處理器中,聲言Lock#信號的處理器,不會再鎖住總線,而是檢查到數據所在的內存區域,如果是在處理器的內部緩存中,則會鎖定此緩存區域,將緩存寫回到內存當中,並利用緩存一致性的原則來保證其他處理器中的緩存區域數據的一致性。

緩存一致性

緩存一致性原則會保證一個在緩存中的數據被修改了,會保證其他緩存了此數據的處理器中的緩存失效,從而讓處理器重新去內存中讀取最新修改後的數據。

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

volatile不僅保證了共享變量在多線程間的可見性,其還保證了一定的有序性。

有序性

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

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

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

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

內存屏障

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

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

Java併發系列之volatile和Synchronized

image

內存屏障另一個作用是強制更新一次不同CPU的緩存。

例如,一個寫屏障會把這個屏障前寫入的數據刷新到緩存,這樣任何試圖讀取該數據的線程將得到最新值,而不用考慮到底是被哪個cpu核心或者哪顆CPU執行的。

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

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

每一個剛接觸多線程併發編程的同學,當被問到,如果多個線程同時訪問一段代碼,發生併發的時候,應該怎麼處理?

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

<code>public synchronized void doSomething() {
//do something here

}
/<code>

或者

<code>synchronized(LockObject) {
//do something here
}
/<code>

那麼實際上,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個字寬來存儲。

其結構簡單如下:


Java併發系列之volatile和Synchronized


從上表中,我們可以看到,鎖相關的信息,是存在稱之為Markword中的內存域中。

拿以下的代碼作為例子,

<code>synchonized(LockObject) {
//do something here
}
/<code>

在對象LockObject的對象頭中,當其被創建的時候,其Markword的結構如下:


Java併發系列之volatile和Synchronized

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

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

偏向鎖

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

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

Java併發系列之volatile和Synchronized

可以看到,

  1. 鎖的標誌位還是01
  2. “是否偏向鎖”這個字段變成了1
  3. hash值變成了線程ID和epoch值

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

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

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

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

Java併發系列之volatile和Synchronized

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開發人員提供的一張圖來描述此步驟:

Java併發系列之volatile和Synchronized

lightweight-locking-01.png

Java併發系列之volatile和Synchronized

lightweight-locking-02.png

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

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

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

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

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

自旋鎖/自適應自旋鎖

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

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

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

互斥鎖(重量級鎖)

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

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

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


Java併發系列之volatile和Synchronized

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

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

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

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

總結

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

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


Java併發系列之volatile和Synchronized


分享到:


相關文章: