Java并發機制底層實現原理

在文章開始之前,先分享下奶奶家養的可愛小貓咪 233333

在現如今的軟件開發領域,并發編程是老生常談的東西。但是要理解并掌握好并發編程卻并不是那么容易的事情。對于我來說,在學習過程中能夠應用到并發編程的場景不是很多,所以更多的東西一直都是停留在理論層面,或者是僅僅停留在Java語言的層面。為了在以后的工作當中更順利地進行并發編程,我一直都在學習這方面的知識。之前閱讀過《Java并發編程實戰》一書,也看過JDK包中一些并發容器類以及同步容器類的源碼,同時也堅持閱讀相關的博客,所以對并發編程有一點點淺顯的了解,為了加深自己對并發相關知識的理解,我打算針對Java并發編程這一塊寫一些讀書筆記并分享一些自己的學習心得。

本文暫時只對Java虛擬機自帶的鎖機制以及volatile等知識進行了總結,并沒有涉及JUC包中類的介紹,后期會加上這一部分。

volatile實現原理

volatile在并發編程中扮演著重要的角色,volatile就像是輕量級的synchronized,它在多處理器中保證了共享變量的可見性??梢娦缘囊馑际钱斠粋€線程修改一個共享變量時,其他線程能夠及時讀取到這個修改的值。由于volatile并不會引起線程上下文的切換和調度,所以它比synchronized的使用和執行成本更低。volatile在并發編程中還起著另一個作用---那就是禁止指令重排。我們知道,編譯器在對Java代碼進行優化的時候可能會發生指令重排,指令重排對于CPU來說就是指令亂序執行,這是多條指令在流水線上執行,很好地利用了多處理器。雖然這樣在單線程語義中不會發生什么錯誤,但是可能在多線程中會出現一些并發性問題,關于這一點我在后面會提到。首先來說說volatile如何保證可見性。

volatile保證可見性

Java語言規范第3版中對volatile的定義是這樣的:Java編程語言允許線程訪問共享變量,為了確保共享變量能被準確和一致地更新,線程應該確保通過排他鎖單獨獲得這個變量。Java語言提供了volatile,在某些情況下比鎖要更方便。如果一個字段聲明成volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。
那么volatile是如何保證可見性的呢?

在對volatile修飾的變量進行寫操作的時,轉變成的匯編代碼會多出一條Lock前綴的指令,Lock前綴的指令在多核處理器下引發了兩件事情:

  • 將當前處理器緩存行的數據寫回到系統內存;
  • 這個寫回內存的操作會使其他CPU里緩存了該內存地址的數據無效。

為了提高處理速度,處理器不直接與內存進行通信,而是先將系統內存的數據讀到內部緩存后再進行操作,但操作完不知道何時會寫到內存。如果對被volatile聲明的變量進行寫操作,JVM虛擬機就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到內存當中。同時為了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統內存中把數據讀到處理器緩存當中。

volatile的兩條實現原則底層實現

  • Lock前綴指令會引起處理器緩存回寫到內存:Lock前綴指令導致在執行指令期間,聲言處理器的LOCK#信號。在多處理器環境中,LOCK#信號確保在聲言該信號期間,處理器可以獨占任何共享內存(在以前的處理器當中,這條指令會鎖住總線導致其他CPU無法訪問總線從而限制其他處理器無法訪問系統內存)。但是在現在的處理器中,一般不會鎖總線而是鎖緩存,因為鎖總線的開銷比較大。對于某些處理器來說,它們在鎖操作時,總是在總線上聲言LOCK#信號。但是對于另外一些處理器來說,它們不會聲言LOCK#信號。相反,它會鎖定這塊內存區域的緩存并回寫到內存,并使用緩存一致性機制來確保修改的原子性,此操作被稱為“緩存鎖定”,緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據。
  • 一個處理器的緩存回寫到內存會導致其它處理器的緩存無效:一些處理使用MESI控制協議去維護內部緩存和其他處理器緩存的一致性。在多核處理器系統中進行操作的時候,一些處理器能夠嗅探其他處理器訪問系統內存和它們的內部緩存。處理器使用嗅探技術保證它的內部緩存、系統內存和其他處理器的緩存的數據在總線上保持一致。例如,在某些處理器中如果通過嗅探一個處理器來檢測其它處理器打算寫內存地址,而這個地址當前處于共享狀態,那么正在嗅探的處理器將使它的緩存無效,在下次訪問相同內存地址時,強制執行緩存行填充。
volatile禁止指令重排

volatile為寫-讀建立happens-before關系
在多處理器對指令進行流水線處理時,不可避免地會發生亂序執行,即指令重排的情況發生。在某些時候,這種優化可能會導致并發性問題。但是volatile能夠禁止指令重排,其底層是通過在讀和寫指令前后插入內存屏障實現的。下面以DCL單例為例進行說明:

DCL雙鎖檢測單例模式

上面這個DCL懶漢式看起來很美好地實現單例模式----先檢測singleton對象是否為空,如果為空就鎖定Singleton類,接著再次檢測singleton對象是否為空,若為空就進行實例化操作最后返回實例化的對象。但是它會存在不安全發布的情況---即對象還未完成初始化就發布出去了,將會引發一系列的問題。

下面我們詳細說說為什么會發生不安全發布的問題
這里是singleton對象初始化的過程:

  1. 分配對象的內存空間;
  1. 初始化對象;
  2. 設置singleton指向剛剛分配的內存空間。

上面是正常初始化一個對象的流程。可是由于指令重排的存在,步驟2和步驟3可能會亂序執行。此時假如有線程A和線程B同時調用上面代碼中的getSingleton()函數,假設A先B一步已經開始進行對象初始化過程了。由于指令重排的存在,A可能先進行步驟3的操作----設置singleton指向分配的內存空間。此時B才開始調用getSingleton()方法,想要獲得一個singleton對象。然后它發現singleton指向的內存區域不為空,就直接返回了singleton??墒谴藭r線程A由于亂序執行的原因,可能尚未進行對象的初始化或者是對象初始化還沒有完成,這就導致了一個沒有完全初始化的對象被發布了,這可能導致非常嚴重的問題。所以DCL雙鎖單例模式并不完美。
但是假如把對象用volatile關鍵字修飾,那就可以避免在對象進行初始化的過程中發生指令重排的情況,從而避免對象的不安全發布現象的發生。

在DCL雙鎖單利模式中用volatile關鍵字修飾對象

既然提到了單例,那么下面就介紹另一種線程安全的單例模式實現

基于類初始化的安全單例模式

JVM在類初始化階段(即在Class被加載后,且被線程使用之前),會執行類的初始化操作。在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個線程對同一個類的初始化。基于這個特性,可以實現另一種線程安全的延遲初始化方案:

基于類初始化的安全單例模式

Java語言規范規定,對于每一個類或接口C,都有一個唯一的初始化鎖LC與之對應。從C到LC的映射,由JVM的具體實現去自由實現。JVM在類初始化期間會獲取這個初始化鎖,并且每個線程至少獲取一次鎖來確保這個類已經被初始化了。

synchronized實現原理

synchronized在大部分時候是實現同步的基礎:Java中每一個對象都可以作為鎖,具體表現為以下3種形式:

  • 對于普通同步方法,鎖是當前實例對象;
  • 對于靜態同步方法,鎖是當前類的Class對象;
  • 對于同步方法塊,鎖是synchronized括號里配置的對象。

關于synchronized底層實現原理,我在這篇博客里面介紹了。
Java虛擬機規范
JVM基于進入和退出Monitor對象來實現方法同步以及代碼塊同步,但兩者的實現細節不太一樣,代碼塊同步是使用monitorenter和monitorexit指令實現的,方法同步是在方法表中添加一個ACC_SYNC的標志位,但是方法的同步同樣可以使用這兩個指令來實現。

鎖的升級與對比

鎖一共有四種狀態,級別由低到高一次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態。這幾個狀態會隨著競爭情況逐漸升級,鎖可以升級但不可以降級(這里和后面提到的ReentrantReadWriteLock中的寫鎖降級為讀鎖不是一回事),即輕量級鎖升級為重量級鎖之后不能降級成輕量級鎖。這種鎖升級后不能降級的策略的目的是為了提高獲取鎖和釋放鎖的效率。

偏向鎖

大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊并獲取鎖時,會在對象頭和棧幀中的鎖記錄當中存儲鎖偏向的線程ID,以后該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需要簡單地測試一下對象頭的Mark Word里是否存儲著指向當前線程的偏向鎖。如果測試成功則代表當前線程已經獲得了鎖,如果測試失敗則需要再測試一下Mark Word中偏向鎖的標志是否設置成為了1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖,如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

偏向鎖的撤銷

偏向鎖使用了一種等到出現競爭才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖的時候,持有偏向鎖的線程才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,然后檢查持有偏向鎖的線程是否還活著,如果線程不處于活動狀態,則將對象頭設置為無所狀態;如果線程仍然活著,持有偏向鎖的棧會被執行,遍歷對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要么重新偏向于其他線程,要么恢復到無鎖或者標記對象不適合所謂偏向鎖,最后喚醒暫停的線程。

關閉偏向鎖

偏向鎖在jdk1.6和jdk1.7里面是默認啟用的,但是它在應用程序啟動幾秒之后才激活,如有必要可使用參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果我們確定應用程序里所有的鎖通常情況下處于競爭狀態,可以通過參數來關閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序默認會進入輕量級鎖狀態。

輕量級鎖

輕量級鎖加鎖

線程在執行同步塊之前,JVM會先在當前線程的棧幀中創建存儲鎖記錄的空間,并將對象頭中的Mark Word復制到鎖記錄中,即Displaced Mark Word。然后線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。若成功,當前線程獲得鎖;若失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。

輕量級鎖解鎖

輕量級鎖解鎖時,會使用原子的CAS操作將Displaced Mark Word替換到對象頭。如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距 如果線程間存在鎖競爭,會帶來額外的消耗 適用于只有一個線程訪問同步塊場景
輕量級鎖 競爭的線程不會阻塞,提高了程序的響應速度 如果始終得不到鎖競爭的線程,使用自旋會消耗CPU 追求響應速度,同步塊執行速度非常快
重量級鎖 線程競爭不會自旋,不會消耗CPU資源 線程阻塞,響應速度緩慢 追求吞吐量,同步塊執行時間比較長

原子操作實現原理

處理器如何實現原子操作
  • 使用總線鎖保證原子性。第一個機制是通過總線鎖保證原子性,所謂總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那么該處理器可以獨占共享內存;
  • 使用緩存鎖保證原子性。因為在同一時刻,我們只需保證對某個內存地址的操作是原子性即可,但總線鎖把CPU和內存之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。

但是在這兩種情況下處理器不會使用緩存鎖定:

  • 當操作的數據不能被還存在處理器內部,或操作的數據跨多個緩存行時,處理器會調用總線鎖定;
  • 有些處理器不支持緩存鎖定。
Java中如何實現原子操作

在Java中可以通過鎖和循環CAS來實現原子操作。從jdk1.5開始,JDK的并發包里面提供了一些類來支持原子操作,如AtomicBoolean(用原子的方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong等。這些原子包裝類提供了有用的工具方法,例如以原子的方式將當前值自增1或自減1。
CAS實現原子操作的三大問題

  • ABA問題;
  • 循環時間長開銷大;
  • 只能保證一個共享變量的原子操作。從jdk1.5開始,JDK提供了AtomicReference類來保證引用對象之間的原子性,就可以把多個變量放在一個對象里來進行CAS操作。
使用鎖機制實現原子操作

鎖機制保證了只有獲得鎖的線程才能操作鎖定的內存區域。JVM內部實現了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。有意思的是,除了偏向鎖,JVM實現鎖的方式都使用了循環CAS,即當一個線程想進入同步塊的時候使用循環CAS的方式來獲取鎖,當它退出同步塊的時候使用循環CAS釋放鎖。

final的內存語義

與鎖和volatile相比,對final域的讀寫更像是普通的變量訪問。但其實final在某些時候也可以用來防止對象的不安全發布,下面來說說final的內存語義。

final的重排序規則

對于final域,編譯器和處理器要遵守兩個重排序規則:

  • 在構造函數內對一個final域的寫入,與隨后把這個構造函數的引用賦值給一個引用的變量,這兩個操作之間不能重排序;
  • 初次都一個包含final域的對象的引用,與隨后初次讀這個final域,這兩個操作之間不能重排序。
寫final域的重排序規則
  • JMM禁止編譯器把final域的寫重排序到構造函數之外;
  • 編譯器會在final域的寫之后,構造函數return之前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到構造函數之外。
讀final域的重排序規則

讀final域的重排序規則是,在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操作(這里要注意,此規則僅僅針對處理器)。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。
初次讀對象引用與初次讀該對象包含的final域,這兩個操作之間存在間接依賴關系。由于編譯器遵守間接依賴關系,因此編譯器不會重排序這兩個操作。大多數處理器也會遵守間接依賴,也不會遵守重排序這兩個操作。讀final域的重排序規則可以確保:在讀一個對象的final域之前,一定會先讀包含這個final域的對象的引用,如果該引用不為空,那么引用對象的final域一定已經被A線程初始化過了。

如果final域為引用類型

對于引用類型,寫final域的重排序規則對編譯器和處理器做了如下約束:在構造函數內對一個final引用的對象的成員域的寫入,與隨后在構造函數外吧這個被構造對象的引用賦值給一個引用變相,這兩個操作之間不能重排序。

寫final域的規則可以確保在引用變量為任意線程可見之前,該引用變量指向的對象的final域已經在構造函數中被正確初始化過了。其實要得到這個效果,還需要一個保證:那就是在構造函數內部,不能讓這個被構造對象的引用為其他線程所見,也就是對象引用不能再構造函數中"逸出",即不能發生this逸出的情況。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Java8張圖 11、字符串不變性 12、equals()方法、hashCode()方法的區別 13、...
    Miley_MOJIE閱讀 3,731評論 0 11
  • 并發系列的文章都是根據閱讀《Java 并發編程的藝術》這本書總結而來,想更深入學習的同學可以自行購買此書進行學習。...
    小之丶閱讀 878評論 0 10
  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發崗...
    時芥藍閱讀 42,366評論 11 349
  • 今天很榮幸參加了去哪兒的一面,但是死的很慘,怪自己沒好好準備,但是收獲很大,畢竟是第一次面試,以后要漲點心。 第一...
    Vaiety閱讀 310評論 0 0
  • 一、啟用和禁用selinux 二、文件標簽更改 三、端口標簽更改 四、布爾值的狀態(0、1) 五、查日志 六、se...
    Miracle001閱讀 1,315評論 0 3