天天看點

帶你讀《Java并發程式設計的藝術》之二:Java并發機制的底層實作原理

點選這裡檢視第一章:并發程式設計的挑戰 點選這裡檢視第三章:Java記憶體模型

第2章:Java并發機制的底層實作原理

Java代碼在編譯後會變成Java位元組碼,位元組碼被類加載器加載到JVM裡,JVM執行位元組碼,最終需要轉化為彙編指令在CPU上執行,Java中所使用的并發機制依賴于JVM的實作和CPU的指令。本章我們将深入底層一起探索下Java并發機制的底層實作原理。

2.1 volatile的應用

在多線程并發程式設計中synchronized和volatile都扮演着重要的角色,volatile是輕量級的synchronized,它在多處理器開發中保證了共享變量的“可見性”。可見性的意思是當一個線程修改一個共享變量時,另外一個線程能讀到這個修改的值。如果volatile變量修飾符使用恰當的話,它比synchronized的使用和執行成本更低,因為它不會引起線程上下文的切換和排程。本文将深入分析在硬體層面上Intel處理器是如何實作volatile的,通過深入分析幫助我們正确地使用volatile變量。

我們先從了解volatile的定義開始。

1.volatile的定義與實作原理

Java語言規範第3版中對volatile的定義如下:Java程式設計語言允許線程通路共享變量,為了確定共享變量能被準确和一緻地更新,線程應該確定通過排他鎖單獨獲得這個變量。Java語言提供了volatile,在某些情況下比鎖要更加友善。如果一個字段被聲明成volatile,Java線程記憶體模型確定所有線程看到這個變量的值是一緻的。

在了解volatile實作原理之前,我們先來看下與其實作原理相關的CPU術語與說明。表2-1是CPU術語的定義。

表2-1 CPU的術語定義

術語 英文單詞 術語描述
記憶體屏障 memory barriers 是一組處理器指令,用于實作對記憶體操作的順序限制
緩沖行 cache line 緩存中可以配置設定的最小存儲機關。處理器填寫緩存線時會加載整個緩存線,需要使用多個主記憶體讀周期
原子操作 atomic operations 不可中斷的一個或一系列操作
緩存行填充 cache line f?ill 當處理器識别到從記憶體中讀取操作數是可緩存的,處理器讀取整個緩存行到适當的緩存(L1,L2,L3的或所有)
緩存命中 cache hit 如果進行高速緩存行填充操作的記憶體位置仍然是下次處理器通路的位址時,處理器從緩存中讀取操作數,而不是從記憶體讀取
寫命中 write hit 當處理器将操作數寫回到一個記憶體緩存的區域時,它首先會檢查這個緩存的記憶體位址是否在緩存行中,如果存在一個有效的緩存行,則處理器将這個操作數寫回到緩存,而不是寫回到記憶體,這個操作被稱為寫命中
寫缺失 write misses the cache 一個有效的緩存行被寫入到不存在的記憶體區域

volatile是如何來保證可見性的呢?讓我們在X86處理器下通過工具擷取JIT編譯器生成的彙編指令來檢視對volatile進行寫操作時,CPU會做什麼事情。

Java代碼如下。

instance = new Singleton(); // instance是volatile變量

轉變成彙編代碼,如下。

0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);

有volatile變量修飾的共享變量進行寫操作的時候會多出第二行彙編代碼,通過查IA-32架構軟體開發者手冊可知,Lock字首的指令在多核處理器下會引發了兩件事情。

1)将目前處理器緩存行的資料寫回到系統記憶體。

2)這個寫回記憶體的操作會使在其他CPU裡緩存了該記憶體位址的資料無效。

為了提高處理速度,處理器不直接和記憶體進行通信,而是先将系統記憶體的資料讀到内部緩存(L1,L2或其他)後再進行操作,但操作完不知道何時會寫到記憶體。如果對聲明了volatile的變量進行寫操作,JVM就會向處理器發送一條Lock字首的指令,将這個變量所在緩存行的資料寫回到系統記憶體。但是,就算寫回到記憶體,如果其他處理器緩存的值還是舊的,再執行計算操作就會有問題。是以,在多處理器下,為了保證各個處理器的緩存是一緻的,就會實作緩存一緻性協定,每個處理器通過嗅探在總線上傳播的資料來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的記憶體位址被修改,就會将目前處理器的緩存行設定成無效狀态,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器緩存裡。

下面來具體講解volatile的兩條實作原則。

1)Lock字首指令會引起處理器緩存回寫到記憶體。Lock字首指令導緻在執行指令期間,聲言處理器的LOCK#信号。在多處理器環境中,LOCK#信号確定在聲言該信号期間,處理器可以獨占任何共享記憶體。但是,在最近的處理器裡,LOCK#信号一般不鎖總線,而是鎖緩存,畢竟鎖總線開銷的比較大。在8.1.4節有詳細說明鎖定操作對處理器緩存的影響,對于Intel486和Pentium處理器,在鎖操作時,總是在總線上聲言LOCK#信号。但在P6和目前的處理器中,如果通路的記憶體區域已經緩存在處理器内部,則不會聲言LOCK#信号。相反,它會鎖定這塊記憶體區域的緩存并回寫到記憶體,并使用緩存一緻性機制來確定修改的原子性,此操作被稱為“緩存鎖定”,緩存一緻性機制會阻止同時修改由兩個以上處理器緩存的記憶體區域資料。

2)一個處理器的緩存回寫到記憶體會導緻其他處理器的緩存無效。IA-32處理器和Intel 64處理器使用MESI(修改、獨占、共享、無效)控制協定去維護内部緩存和其他處理器緩存的一緻性。在多核處理器系統中進行操作的時候,IA-32和Intel 64處理器能嗅探其他處理器通路系統記憶體和它們的内部緩存。處理器使用嗅探技術保證它的内部緩存、系統記憶體和其他處理器的緩存的資料在總線上保持一緻。例如,在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫記憶體位址,而這個位址目前處于共享狀态,那麼正在嗅探的處理器将使它的緩存行無效,在下次通路相同記憶體位址時,強制執行緩存行

填充。

2.volatile的使用優化

著名的Java并發程式設計大師Doug lea在JDK 7的并發包裡新增一個隊列集合類Linked-TransferQueue,它在使用volatile變量時,用一種追加位元組的方式來優化隊列出隊和入隊的性能。LinkedTransferQueue的代碼如下。

/* 隊列中的頭部節點 /

private transient f?inal PaddedAtomicReference head;

/* 隊列中的尾部節點 /

private transient f?inal PaddedAtomicReference tail;

static f?inal class PaddedAtomicReference extends AtomicReference {

// 使用很多4個位元組的引用追加到64個位元組

Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;

PaddedAtomicReference(T r) {

super(r);           

}

public class AtomicReference implements java.io.Serializable {

private volatile V value;

// 省略其他代碼

追加位元組能優化性能?這種方式看起來很神奇,但如果深入了解處理器架構就能了解其中的奧秘。讓我們先來看看LinkedTransferQueue這個類,它使用一個内部類類型來定義隊列的頭節點(head)和尾節點(tail),而這個内部類PaddedAtomicReference相對于父類AtomicReference隻做了一件事情,就是将共享變量追加到64位元組。我們可以來計算下,一個對象的引用占4個位元組,它追加了15個變量(共占60個位元組),再加上父類的value變量,一共64個位元組。

為什麼追加64位元組能夠提高并發程式設計的效率呢?因為對于英特爾酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M處理器的L1、L2或L3緩存的高速緩存行是64個位元組寬,不支援部分填充緩存行,這意味着,如果隊列的頭節點和尾節點都不足64位元組的話,處理器會将它們都讀到同一個高速緩存行中,在多處理器下每個處理器都會緩存同樣的頭、尾節點,當一個處理器試圖修改頭節點時,會将整個緩存行鎖定,那麼在緩存一緻性機制的作用下,會導緻其他處理器不能通路自己高速緩存中的尾節點,而隊列的入隊和出隊操作則需要不停修改頭節點和尾節點,是以在多處理器的情況下将會嚴重影響到隊列的入隊和出隊效率。Doug lea使用追加到64位元組的方式來填滿高速緩沖區的緩存行,避免頭節點和尾節點加載到同一個緩存行,使頭、尾節點在修改時不會互相鎖定。

那麼是不是在使用volatile變量時都應該追加到64位元組呢?不是的。在兩種場景下不應該使用這種方式。

緩存行非64位元組寬的處理器。如P6系列和奔騰處理器,它們的L1和L2高速緩存行是32個位元組寬。

共享變量不會被頻繁地寫。因為使用追加位元組的方式需要處理器讀取更多的位元組到高速緩沖區,這本身就會帶來一定的性能消耗,如果共享變量不被頻繁寫的話,鎖的幾率也非常小,就沒必要通過追加位元組的方式來避免互相鎖定。

不過這種追加位元組的方式在Java 7下可能不生效,因為Java 7變得更加智慧,它會淘汰或重新排列無用字段,需要使用其他追加位元組的方式。除了volatile,Java并發程式設計中應用較多的是synchronized,下面一起來看一下。

2.2 synchronized的實作原理與應用

在多線程并發程式設計中synchronized一直是元老級角色,很多人都會稱呼它為重量級鎖。但是,随着Java SE 1.6對synchronized進行了各種優化之後,有些情況下它就并不那麼重了。本文詳細介紹Java SE 1.6中為了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖,以及鎖的存儲結構和更新過程。

先來看下利用synchronized實作同步的基礎:Java中的每一個對象都可以作為鎖。具體表現為以下3種形式。

對于普通同步方法,鎖是目前執行個體對象。

對于靜态同步方法,鎖是目前類的Class對象。

對于同步方法塊,鎖是Synchonized括号裡配置的對象。

當一個線程試圖通路同步代碼塊時,它首先必須得到鎖,退出或抛出異常時必須釋放鎖。那麼鎖到底存在哪裡呢?鎖裡面會存儲什麼資訊呢?

從JVM規範中可以看到Synchonized在JVM裡的實作原理,JVM基于進入和退出Monitor對象來實作方法同步和代碼塊同步,但兩者的實作細節不一樣。代碼塊同步是使用monitorenter和monitorexit指令實作的,而方法同步是使用另外一種方式實作的,細節在JVM規範裡并沒有詳細說明。但是,方法的同步同樣可以使用這兩個指令來實作。

monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它将處于鎖定狀态。線程執行到monitorenter指令時,将會嘗試擷取對象所對應的monitor的所有權,即嘗試獲得對象

的鎖。

2.2.1 Java對象頭

synchronized用的鎖是存在Java對象頭裡的。如果對象是數組類型,則虛拟機用3個字寬(Word)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。在32位虛拟機中,1字寬等于4位元組,即32bit,如表2-2所示。

表2-2 Java對象頭的長度

長  度 内  容 說  明
32/64bit Mark Word 存儲對象的hashCode或鎖資訊等
Class Metadata Address 存儲到對象類型資料的指針
32/32bit Array length 數組的長度(如果目前對象是數組)

Java對象頭裡的Mark Word裡預設存儲對象的HashCode、分代年齡和鎖标記位。32位JVM的Mark Word的預設存儲結構如表2-3所示。

表2-3 Java對象頭的存儲結構

鎖狀态 25bit 4bit 1bit是否是偏向鎖 2bit鎖标志位
無鎖狀态 對象的hashCode 對象分代年齡 01

在運作期間,Mark Word裡存儲的資料會随着鎖标志位的變化而變化。Mark Word可能變化為存儲以下4種資料,如表2-4所示。

表2-4 Mark Word的狀态變化

帶你讀《Java并發程式設計的藝術》之二:Java并發機制的底層實作原理

在64位虛拟機下,Mark Word是64bit大小的,其存儲結構如表2-5所示。

表2-5 Mark Word的存儲結構

帶你讀《Java并發程式設計的藝術》之二:Java并發機制的底層實作原理

2.2.2 鎖的更新與對比

Java SE 1.6為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,在Java SE 1.6中,鎖一共有4種狀态,級别從低到高依次是:無鎖狀态、偏向鎖狀态、輕量級鎖狀态和重量級鎖狀态,這幾個狀态會随着競争情況逐漸更新。鎖可以更新但不能降級,意味着偏向鎖更新成輕量級鎖後不能降級成偏向鎖。這種鎖更新卻不能降級的政策,目的是為了提高獲得鎖和釋放鎖的效率,下文會詳細分析。

1.偏向鎖

HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競争,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程通路同步塊并擷取鎖時,會在對象頭和棧幀中的鎖記錄裡存儲鎖偏向的線程ID,以後該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,隻需簡單地測試一下對象頭的Mark Word裡是否存儲着指向目前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的辨別是否設定成1(表示目前是偏向鎖):如果沒有設定,則使用CAS競争鎖;如果設定了,則嘗試使用CAS将對象頭的偏向鎖指向目前線程。

(1)偏向鎖的撤銷

偏向鎖使用了一種等到競争出現才釋放鎖的機制,是以當其他線程嘗試競争偏向鎖時,持有偏向鎖的線程才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的位元組碼)。它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着,如果線程不處于活動狀态,則将對象頭設定成無鎖狀态;如果線程仍然活着,擁有偏向鎖的棧會被執行,周遊偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼重新偏向于其他線程,要麼恢複到無鎖或者标記對象不适合作為偏向鎖,最後喚醒暫停的線程。圖2-1中的線程1示範了偏向鎖初始化的流程,線程2示範了偏向鎖撤銷的流程。

圖2-1 偏向鎖初始化的流程

帶你讀《Java并發程式設計的藝術》之二:Java并發機制的底層實作原理

(2)關閉偏向鎖

偏向鎖在Java 6和Java 7裡是預設啟用的,但是它在應用程式啟動幾秒鐘之後才激活,如有必要可以使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你确定應用程式裡所有的鎖通常情況下處于競争狀态,可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程式預設會進入輕量級鎖狀态。

2.?輕量級鎖

(1)輕量級鎖加鎖

線程在執行同步塊之前,JVM會先在目前線程的棧桢中建立用于存儲鎖記錄的空間,并将對象頭中的Mark Word複制到鎖記錄中,官方稱為Displaced Mark Word。然後線程嘗試使用CAS将對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,目前線程獲得鎖,如果失敗,表示其他線程競争鎖,目前線程便嘗試使用自旋來擷取鎖。

(2)輕量級鎖解鎖

輕量級解鎖時,會使用原子的CAS操作将Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競争發生。如果失敗,表示目前鎖存在競争,鎖就會膨脹成重量級鎖。圖2-2是兩個線程同時争奪鎖,導緻鎖膨脹的流程圖。

圖2-2 争奪鎖導緻的鎖膨脹流程圖

帶你讀《Java并發程式設計的藝術》之二:Java并發機制的底層實作原理

因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖更新成重量級鎖,就不會再恢複到輕量級鎖狀态。當鎖處于這個狀态下,其他線程試圖擷取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之争。

3.鎖的優缺點對比

表2-6是鎖的優缺點的對比。

表2-6 鎖的優缺點的對比

鎖 優  點 缺  點 适用場景

偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距 如果線程間存在鎖競争,會帶來額外的鎖撤銷的消耗 适用于隻有一個線程通路同步塊場景

輕量級鎖 競争的線程不會阻塞,提高了程式的響應速度 如果始終得不到鎖競争的線程,使用自旋會消耗CPU 追求響應時間

同步塊執行速度非常快

重量級鎖 線程競争不使用自旋,不會消耗CPU 線程阻塞,響應時間緩慢 追求吞吐量

同步塊執行速度較長

2.3 原子操作的實作原理

原子(atomic)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意為“不可被中斷的一個或一系列操作”。在多處理器上實作原子操作就變得有點複雜。讓我們一起來聊一聊在Intel處理器和Java裡是如何實作原子操作的。

1.術語定義

在了解原子操作的實作原理前,先要了解一下相關的術語,如表2-7所示。

表2-7 CPU術語定義

術語名稱 英  文 解  釋
緩存行 Cache line 緩存的最小操作機關
比較并交換 Compare and Swap CAS操作需要輸入兩個數值,一個舊值(期望操作前的值)和一個新值,在操作期間先比較舊值有沒有發生變化,如果沒有發生變化,才交換成新值,發生了變化則不交換
CPU流水線 CPU pipeline CPU流水線的工作方式就像工業生産上的裝配流水線,在CPU中由5~6個不同功能的電路單元組成一條指令處理流水線,然後将一條X86指令分成5~6步後再由這些電路單元分别執行,這樣就能實作在一個CPU時鐘周期完成一條指令,是以提高CPU的運算速度
記憶體順序沖突 Memory order violation 記憶體順序沖突一般是由假共享引起的,假共享是指多個CPU同時修改同一個緩存行的不同部分而引起其中一個CPU的操作無效,當出現這個記憶體順序沖突時,CPU必須清空流水線

2.處理器如何實作原子操作

32位IA-32處理器使用基于對緩存加鎖或總線加鎖的方式來實作多處理器之間的原子操作。首先處理器會自動保證基本的記憶體操作的原子性。處理器保證從系統記憶體中讀取或者寫入一個位元組是原子的,意思是當一個處理器讀取一個位元組時,其他處理器不能通路這個位元組的記憶體位址。Pentium 6和最新的處理器能自動保證單處理器對同一個緩存行裡進行16/32/64位的操作是原子的,但是複雜的記憶體操作處理器是不能自動保證其原子性的,比如跨總線寬度、跨多個緩存行和跨頁表的通路。但是,處理器提供總線鎖定和緩存鎖定兩個機制來保證複雜記憶體操作的原子性。

(1)使用總線鎖保證原子性

第一個機制是通過總線鎖保證原子性。如果多個處理器同時對共享變量進行讀改寫操作(i++就是經典的讀改寫操作),那麼共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變量的值會和期望的不一緻。舉個例子,如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2,如圖2-3所示。

原因可能是多個處理器同時從各自的緩存中讀取變量i,分别進行加1操作,然後分别寫入系統記憶體中。那麼,想要保證讀改寫共享變量的操作是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操作緩存了該共享變量記憶體位址的緩存。

處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個LOCK#信号,當一個處理器在總線上輸出此信号時,其他處理器的請求将被阻塞住,那麼該處理器可以獨占共享記憶體。

(2)使用緩存鎖保證原子性

第二個機制是通過緩存鎖定來保證原子性。在同一時刻,我們隻需保證對某個記憶體位址的操作是原子性即可,但總線鎖定把CPU和記憶體之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他記憶體位址的資料,是以總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。

頻繁使用的記憶體會緩存在處理器的L1、L2和L3高速緩存裡,那麼原子操作就可以直接在處理器内部緩存中進行,并不需要聲明總線鎖,在Pentium 6和目前的處理器中可以使用“緩存鎖定”的方式來實作複雜的原子性。所謂“緩存鎖定”是指記憶體區域如果被緩存在處理器的緩存行中,并且在Lock操作期間被鎖定,那麼當它執行鎖操作回寫到記憶體時,處理器不在總線上聲言LOCK#信号,而是修改内部的記憶體位址,并允許它的緩存一緻性機制來保證操作的原子性,因為緩存一緻性機制會阻止同時修改由兩個以上處理器緩存的記憶體區域資料,當其他處理器回寫已被鎖定的緩存行的資料時,會使緩存行無效,在如圖2-3所示的例子中,當CPU1修改緩存行中的i時使用了緩存鎖定,那麼CPU2就不能同時緩存i的緩存行。

但是有兩種情況下處理器不會使用緩存鎖定。

第一種情況是:當操作的資料不能被緩存在處理器内部,或操作的資料跨多個緩存行(cache line)時,則處理器會調用總線鎖定。

第二種情況是:有些處理器不支援緩存鎖定。對于Intel 486和Pentium處理器,就算鎖定的記憶體區域在處理器的緩存行中也會調用總線鎖定。

針對以上兩個機制,我們通過Intel處理器提供了很多Lock字首的指令來實作。例如,位測試和修改指令:BTS、BTR、BTC;交換指令XADD、CMPXCHG,以及其他一些操作數和邏輯指令(如ADD、OR)等,被這些指令操作的記憶體區域就會加鎖,導緻其他處理器不能同時通路它。

3.Java如何實作原子操作

在Java中可以通過鎖和循環CAS的方式來實作原子操作。

(1)使用循環CAS實作原子操作

JVM中的CAS操作正是利用了處理器提供的CMPXCHG指令實作的。自旋CAS實作的基本思路就是循環進行CAS操作直到成功為止,以下代碼實作了一個基于CAS線程安全的計數器方法safeCount和一個非線程安全的計數器count。

private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
    f?inal Counter cas = new Counter();
    List<Thread> ts = new ArrayList<Thread>(600);
    long start = System.currentTimeMillis();
    for (int j = 0; j < 100; j++) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10000; i++) {
                    cas.count();
                    cas.safeCount();
                }
            }
        });
        ts.add(t);
    }
    for (Thread t : ts) {
        t.start();

    }
// 等待所有線程執行完成
    for (Thread t : ts) {
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
    System.out.println(cas.i);
    System.out.println(cas.atomicI.get());
    System.out.println(System.currentTimeMillis() - start);
}
/**        * 使用CAS實作線程安全計數器        */
private void safeCount() {
    for (;;) {
        int i = atomicI.get();
        boolean suc = atomicI.compareAndSet(i, ++i);
        if (suc) {
            break;
        }
    }
}
/**
 * 非線程安全計數器
 */
private void count() {
    i++;
  }
}
           

從Java 1.5開始,JDK的并發包裡提供了一些類來支援原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。這些原子包裝類還提供了有用的工具方法,比如以原子的方式将目前值自增1和自減1。

(2)CAS實作原子操作的三大問題

在Java并發包中有一些并發架構也使用了自旋CAS的方式來實作原子操作,比如LinkedTransferQueue類的Xfer方法。CAS雖然很高效地解決了原子操作,但是CAS仍然存在三大問題。ABA問題,循環時間長開銷大,以及隻能保證一個共享變量的原子操作。

1)ABA問題。因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本号。在變量前面追加上版本号,每次變量更新的時候把版本号加1,那麼A→B→A就會變成1A→2B→3A。從Java 1.5開始,JDK的Atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法的作用是首先檢查目前引用是否等于預期引用,并且檢查目前标志是否等于預期标志,如果全部相等,則以原子方式将該引用和該标志的值設定為給定的更新值。

public boolean compareAndSet(

V      expectedReference,    // 預期引用
        V      newReference,        // 更新後的引用
       int    expectedStamp,        // 預期标志
       int    newStamp        // 更新後的标志           

)

2)循環時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令,那麼效率會有一定的提升。pause指令有兩個作用:第一,它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決于具體實作的版本,在一些處理器上延遲時間是零;第二,它可以避免在退出循環的時候因記憶體順序沖突(Memory Order Violation)而引起CPU流水線被清空(CPU Pipeline Flush),進而提高CPU的執行效率。

3)隻能保證一個共享變量的原子操作。當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖。還有一個取巧的辦法,就是把多個共享變量合并成一個共享變量來操作。比如,有兩個共享變量i=2,j=a,合并一下ij=2a,然後用CAS來操作ij。從Java 1.5開始,JDK提供了AtomicReference類來保證引用對象之間的原子性,就可以把多個變量放在一個對象裡來進行CAS操作。

(3)使用鎖機制實作原子操作

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

2.4 本章小結

本章我們一起研究了volatile、synchronized和原子操作的實作原理。Java中的大部分容器和架構都依賴于本章介紹的volatile和原子操作的實作原理,了解這些原理對我們進行并發程式設計會更有幫助。