第2章
并發模式初探
在前一章中,我們讨論了競争問題。其實,在現實生活中也充滿着競争(下面的例子是假設的)。鑒于目前發達的通信技術,人們在日常生活中很少會錯過約會。但是,假如沒有這些技術,讓我們看看又會怎麼樣。假設今天是星期五,我準備下班回家,然後計劃去吃晚飯和看電影。我打電話給我的妻子和女兒,讓他們提前到達電影院。然而,在開車回家的路上,我遇上了周五晚上的交通高峰而被堵在路上。由于我未及時趕到,我的妻子決定帶着女兒去散步,并去拜訪一位住在附近的朋友,和他聊天叙舊。
與此同時,我到達了電影院,停好車并立刻走進電影院。然而,我卻沒看到我的妻女,是以決定去附近的餐館找她們。當我在餐館尋找她們時,她們來到了電影院,卻沒有發現我在那裡。是以,她決定去停車場看看。
這種情況可能會一直持續下去,最終我們可能錯過周五的電影和晚餐。但是,由于有了手機,我們可以輕松地同步我們的行程,在電影院及時相遇。
在本章中,我們将研究線程的上下文,了解這個概念對于了解Java的并發工作方式至關重要。我們首先研究單例設計模式(singleton design pattern)和共享狀态問題。但在此之前,我們先來看一些背景資訊。
https://github.com/PacktPublishing/Concurrent-Patterns-and-Best-Practices提供完整的代碼檔案。
2.1 線程及其上下文
正如我們在第1章中所看到的,程序是線程的容器。程序具有可執行代碼和全局資料,所有線程與同一程序的其他線程共享這些東西。如圖2-1所示,二進制可執行代碼是隻讀的,它可以由線程自由共享,因為不存在任何可變性。

但是,全局資料是可變的,如圖2-1所示,這正是并發錯誤的根源!我們将在本書中研究的大多數技術和模式都是為了避免此類錯誤。
同一程序的線程同時運作,在隻有一組寄存器的情況下,這是如何實作的?答案是“線程上下文”,此上下文有助于使線程運作時資訊始終獨立于另一個線程,線程上下文包含寄存器集和堆棧。
圖2-1顯示線程上下文的各個部分。
當排程程式搶占正在運作的線程時,它會備份線程上下文,也就是說,它會儲存CPU寄存器和堆棧的内容。接下來,它選擇另一個可運作的線程并加載其上下文,這意味着它會将線程寄存器的内容還原為與上次一樣(它的堆棧也會還原為與上次一樣,依此類推),然後繼續執行線程。
那麼可執行二進制代碼呢?運作相同代碼的程序可以共享同一塊代碼,因為代碼在運作時不會更改。(例如,共享庫的代碼在程序間共享。)
圖2-2是一些簡單的規則,用于了解由各種變量表示的狀态的線程安全方面。
如圖2-2所示,final變量和局部變量(包括函數參數)始終是線程安全的,你不需要任何鎖來保護它們。final變量是不可變的(即隻讀),是以不存在多個線程同時更改值的問題。
final變量在可見性方面也享有特殊地位,稍後我們将詳細介紹這意味着什麼。可變靜态執行個體變量是不安全的,如果它們不受保護,我們可以輕松建立競争條件。
2.2 競争條件
我們先來研究一些并發錯誤。下面是一個遞增計數器的簡單示例:
這個代碼不是線程安全的,如果兩個正在運作的線程并發地使用同一對象,則每個線程擷取的計數器值序列基本上是不可預測的,原因是“++counter”操作,這個看起來很簡單的語句實際上由三個不同的操作組成:讀取新值、修改新值并儲存新值。
如圖2-3所示,線程的執行是互相不知道的,是以會在不知情的情況下發生幹預,進而造成丢失更新。
以下代碼說明單例設計模式(singleton design pattern)。從時間和記憶體方面來看,建立LazyInitialization執行個體是很昂貴的,是以,我們接管對象建立。想法是将建立延遲到第一次使用時,然後隻建立一個執行個體并重用它:
當我們想要接管執行個體建立時,一個常見的設計技巧是使構造函數私有化,進而迫使用戶端代碼使用我們的public修飾的工廠方法getInstance()。
單例模式和工廠方法模式是著名的“四人組”(GOF)一書中衆多創造性設計模式中的兩種。這些模式有助于我們強制進行設計決策,例如,在這裡確定我們隻有一個類執行個體。對單例的需求非常普遍,單例的典型示例是日志記錄服務,線程池也表示為單例(我們将在下一章中介紹線程池)。在樹形資料結構中,單例作為哨兵節點,用來表示終端節點。一個樹可以有數千個節點來儲存各種資料項,但是,終端節點沒有任何資料(根據定義),是以終端節點的兩個執行個體完全相同。通過将終端節點設定為單例,可以利用此性質,進而節省大量的記憶體。在周遊樹時,編寫條件語句來檢查是否命中了哨兵節點是很簡單的:隻需比較哨兵節點的引用。有關更多資訊,請參閱
https://sourcemaking.com/design_patterns/null_object 。Scala的None是一個null對象。
在第一次調用getter方法時,我們建立并傳回新執行個體。對于後續調用,将傳回相同的執行個體,進而避免昂貴的構造過程,如圖2-4所示。
為什麼我們需要将執行個體變量聲明為volatile?因為這時編譯器能優化我們的代碼。例如,編譯器可以選擇将變量存儲在寄存器中,當另一個線程初始化執行個體變量時,第一個線程可能有一個舊副本,如圖2-5所示。
通過一個關鍵字volatile可以解決這個問題。在寫入之後,執行個體引用始終使用記憶體屏障(Store Barrier)保持最新,并在讀取之前使用加載屏障(Load Barrier)。記憶體屏障使所有CPU(以及在它們上執行的線程)都知道狀态的更改,如圖2-6所示。
同樣,加載屏障(Load Barrier)讓所有CPU能讀到最新值,進而避免過時狀态問題。有關更多資訊,請參閱
https://dzone.com/articles/memory-barriers-fences。
該代碼中存在競争條件。兩個線程都檢查條件,但有時,第一個線程尚未完成對象的初始化。(請記住,初始化對象比較昂貴,這也是我們要先完成這些煩瑣手續的原因。)與此同時,第二個線程被排程,擷取引用并開始使用它,也就是說,它開始使用部分構造的執行個體,這将是一個bug。
這個“部分構造的執行個體”是如何實作的?JVM可以重新排列指令,是以實際結果不會改變,但性能會提高。
當正在執行LazyInitialization()表達式時,它可以首先配置設定記憶體,并将已配置設定記憶體的位置引用傳回給執行個體變量,然後啟動對象的初始化。由于引用是在構造函數已經有機會執行之前傳回的,是以它會産生一個其引用不為空的對象,然而,構造函數還沒有完成。
由于執行了部分初始化的對象,可能會導緻一些神秘的異常,并且它們很難重制!讓我們看看圖2-7所示的情況。
諸如此類的競争條件基本上是不可預測的,線程的排程時間取決于外部因素。大多數情況下,代碼将按預期工作,然而,偶爾也會出現問題。那麼,如前所述,我們應該如何調試呢?
調試器将無濟于事,我們需要確定競争不會因設計而發生。接下來,讓我們進入螢幕模式學習。
2.2.1 螢幕模式
我們之前看到的遞增計數器的操作包括以下步驟:
這些步驟應該是原子的,即不可分割,要麼線程執行所有這些操作,要麼一個都不執行。螢幕模式的作用是使這樣的操作序列原子化,Java通過其synchronized關鍵字來提供螢幕:
如代碼所示,現在,計數器代碼是線程安全的。每個Java對象都有一個内置鎖,也稱為内部鎖(intrinsic lock)。進入同步塊(synchronized block)的線程将擷取此鎖,鎖一直保持到塊執行為止。當線程退出方法時(因為它執行完成或由于異常),鎖被釋放,如圖2-8所示。
同步塊是可重入的:持有鎖的同一線程可以再次進入塊,否則,如圖2-8所示,将導緻死鎖。這種情況下,線程本身不會往下推進,因為它在等待鎖(由它自己在第一位持有鎖)被釋放,顯然,其他線程将被鎖定,進而使系統停止運作。
2.2.2 線程安全性、正确性和不變性
不變性是了解代碼正确性的一個好工具。例如,對于單連結清單,我們可以說最多有一個非空節點的next指針為空,如圖2-9所示。
在圖2-9中,第一部分顯示帶不變性的單連結清單。我們想在最後一個值為19的節點之前,添加一個值為15的節點。當插入算法在調整指針鍊的過程中,第二部分顯示它已将節點c的next指針設定為null之前的狀态。
無論是順序的單線程模型還是多線程模型,都應該保持代碼不變性。
顯式同步使得違反不變性的系統狀态可能暴露,對于該連結清單示例,我們必須同步資料結構的所有狀态,以確定始終保持不變性。
假設有一個size()方法計算清單節點數。當第一個線程位于第二個快照(處于插入節點過程中間)時,如果另一個線程通路第二個快照并調用size(),我們就會遇到一個讨厭的錯誤。隻是在偶然情況下,size()方法會傳回4,而不是預期的5,那可調試性如何呢?
2.2.2.1 順序一緻性
另一個了解并發對象的工具是順序一緻性(Sequential Consistency),請考慮如圖2-10所示的執行流程。
如圖2-10所示,我們通過假設x的值為1來閱讀和了解代碼,同時計算p的指派。我們從頂部開始,向下進行,它是如此直覺,明顯是正确的。
左側執行過程是順序一緻的,因為我們在評估後面的步驟時看到了先前步驟的結果。
然而,Java記憶體模型在背景不是按這種方式工作。雖然對我們隐藏了,但是其實并不是線性的,因為代碼針對性能進行了優化。然而,運作時間可以確定滿足我們的期望,在單線程世界中一切都很好。
當我們引入線程時,事情并不那麼樂觀。如圖2-10的右側所示,在計算p時,無法保證線程T2能讀取x變量的正确的最新值。
鎖定機制(或volatile)保證了正确的可見性語義。
2.2.2.2 可見性和final字段
衆所周知,final字段是不可變的,一旦在構造函數中初始化,之後就無法更改。final字段對其他線程是可見的,我們不需要任何機制,如鎖定或volatile來實作這一點,如圖2-11所示。
如圖2-11所示,兩個線程共享fv靜态字段,a字段聲明為final,并在構造函數中初始化為值9。在extractVal()方法中,a的正确值對其他線程可見。
然而,b字段卻沒有這樣的保證。因為它被聲明時,修飾符既不是final字段,也不是volatile,并且沒有鎖定,我們不能明确b的值,其他線程同樣如此。
但是有一個問題,final字段不應該從構造函數中洩漏。
如圖2-12所示,在構造函數執行完成之前,this引用被洩露給someOther
ServiceObj的構造函數。可能有另一個線程同時使用someOtherServiceObj,這樣就會間接使用FinalVisibility類執行個體。
由于FinalVisibility構造函數尚未完成,是以final字段a的值對于其他線程是不可見的,進而出現海森堡bug。
有關更多資訊和有關從構造函數中洩漏引用的讨論,請參閱
http://www.javapractices.com/topic/TopicAction.do?Id=2522.2.3 雙重檢查鎖定
我們可以使用“内在鎖”編寫單例的線程安全版本。
請看這裡:
由于該方法是同步的,是以任何時候隻有一個線程可以執行它。如果多個線程多次調用getInstance(),該方法很快就會成為瓶頸。其他競争通路此方法的線程将阻塞等待鎖,并且在此期間将無法執行任何有效的操作,系統的活力将受到影響,這個瓶頸會對系統的并發性産生不利影響。
這促成雙重檢查鎖定模式技術的開發,如下面的代碼片段所示:
這是一種機智的想法:鎖可確定安全地建立實際執行個體。由于volatile關鍵字,其他線程要麼得到空值,要麼得到更新的執行個體值。如圖2-13所示,代碼仍然是不完整的。
我們暫停一下來研究代碼。第一次檢查後有一個時間視窗,在這裡可以進行上下文切換,另一個線程有機會進入同步塊,我們在這裡同步一個鎖變量:“類鎖”。第二次檢查是同步的,并且隻由一個線程執行,假設它是null。然後,擁有鎖的線程向前推進,并建立執行個體。
一旦它退出塊并繼續執行,其他線程将依次通路鎖。它們應該發現執行個體已被完全構造,是以它們使用完全構造的對象,我們要做的是安全地釋出共享的執行個體變量。
2.2.3.1 安全釋出
當一個線程建立一個共享對象時(如本例所示),其他線程也想使用它。“安全釋出”這個術語是指建立者線程将對象作為已可供其他線程使用的狀态進行釋出。
問題是,隻是用volatile修飾該執行個體變量并不能保證其他線程看到一個完全構造的對象。volatile适用于執行個體引用釋出本身,但如果指稱對象(在本例中為LazyInitialization對象)包含可變成員,則不适用。在這種情況下,我們可以得到部分初始化的變量,如圖2-14所示。
當LazyInitialization構造函數退出時,所有final字段都被保證對通路它們的其他線程可見。有關final關鍵字與安全釋出之間關系的更多資訊,請參閱
https://www.javamex.com/tutorials/synchronization_final.shtml使用volatile關鍵字并不能保證可變對象的安全釋出。你可以在這裡找到關于這個問題的讨論:
https://wiki.sei.cmu.edu/confluence/display/java/CON50J.+Do+not+assume+that+declaring+a+reference+volatile+guarantees+safe+publication+of+the+members+of+ the+referenced+object 。
接下來我們将學習一個設計模式,它簡化了執行個體的延遲建立,而不需要所有這些複雜性。
2.2.3.2 初始化Demand Holder模式
我們似乎陷入了困境。一方面,我們不想為不必要的同步付出代價;另一方面,雙重檢查鎖定是斷開的,可能會釋出一個部分構造的對象。
以下代碼段顯示了延遲加載的單例。生成的代碼很簡單,不依賴于細微的同步語義,相反,它利用了JVM的類加載語義:
getInstance()方法使用靜态類LazyInitializationHolder,當首次調用getIn-stance()方法時,JVM将加載這個靜态類。
現在,Java語言規範(JLS)確定類初始化階段是按順序的。所有後續的并發執行都将傳回相同且被正确被初始化的執行個體,而且無須任何同步。
該模式利用了這個功能,以完全避免任何鎖定,并且仍然實作了正确的“懶惰”初始化語義。
單例模式經常被批評,因為它表現為全局狀态。但是,正如我們所看到的,有時我們仍然需要它們的功能,并且這種模式是一個很好的可重用解決方案,也就是說,它是一個并發設計模式。
你還可以使用枚舉來建立單例,有關此設計技術的更多資訊,請參閱
https://dzone.com/articles/java-singletons-using-enum2.2.4 顯式鎖定
synchronized關鍵字是一種内部鎖機制,它非常友善,但也有一些限制。例如,我們不能中斷等待内部鎖的線程,在擷取鎖時也決不允許逾時等待。
有一些用例需要這些功能,在這種情況下,我們使用顯式鎖。Lock接口允許我們克服這些限制,如圖2-15所示。
ReentrantLock具備synchronized關鍵字的功能。已經持有它的線程可以再次擷取它,就像使用同步語義一樣。在這兩種情況下,記憶體可見性和互斥保證都是相同的。
此外,ReentrantLock為我們提供了非阻塞tryLock()和可中斷鎖定。
我們承擔使用ReentrantLock的責任,即使是例外情況,我們也需要確定通過所有傳回路徑釋放鎖定。
這種顯式鎖讓我們可以控制鎖的粒度,在這裡,我們可以看到一個使用排序連結清單實作的并發集合資料結構的示例:
其中,該并發集合持有一個節點連結清單(節點類型是Node),它的定義如下:
Node類表示連結清單的一個節點類型,下面是構造函數:
圖2-16顯示構造函數執行完成後的狀态。
如圖2-16所示,預設構造函數初始化一個空的集合,這是一個由兩個節點組成的連結清單。頭節點始終保持最小值(Integer.MIN_VALUE),最後一個節點包含最大值(Integer.MAX_VALUE)。使用這樣的哨兵節點是一種常見的算法設計技術,它簡化了其餘的代碼,如下所示:
ConcurrentSet也有一個名為lck的字段,它被初始化為ReentrantLock。下面是我們的add方法:
add(int)方法從擷取鎖開始。由于清單是一個集合,是以所有元素都是唯一的,并且元素按升序存儲。
接下來是lookUp(int)方法:
lookUp(int)方法搜尋集合,如果找到參數元素,則傳回true,否則傳回false。最後,下面是remove(int)方法,它調整下一個指針,以便删除包含該元素的節點:
問題是我們正在使用粗粒度同步:我們持有全局鎖。如果集合中包含大量元素,則一次隻能有一個線程執行添加、删除或查找。執行基本上是順序的,如圖2-17所示。
同步顯然是正确的,代碼更容易了解。但是,由于它是粗粒度的,如果許多線程争奪鎖,它們最終會等待鎖。原本可以用來做有成效工作的時間卻花在等待上,是以說鎖是瓶頸。
2.2.4.1 手拉手模式
上一節中解釋的粗粒度同步會損害并發性,是以我們可以不鎖定整個清單,而是将前一個節點和目前節點都鎖定來加以改進。如果線程在周遊清單時這樣做(稱為手拉手鎖定),則允許其他線程同時處理清單,如下所示:
注意,我們讨論的是鎖定節點,而這需要删除我們的全局鎖,并且不是在節點本身中建立鎖字段。為了提高代碼的可讀性,我們提供了兩個原語:lock()和unlock(),如圖2-18所示。
為了使用這種模式,我們重寫了add(int)方法,如下所示:
與前面一樣,我們需要用try或finally來保護鎖。是以,在異常的情況下,能夠保證釋放鎖,如圖2-19所示。
前面的代碼片段解釋了各種并發方案。下面是remove(int)方法:
remove(int)方法移除相同行。代碼對需要權衡的狀況進行了平衡:它盡快解鎖,但確定它同時持有prev節點鎖和curr節點鎖,以消除出現任何競争條件的可能性:
這段代碼是一個測試驅動器,請注意,它是單線程的。通過編寫一個多線程的驅動器,并産生兩個或更多個共享并發集合的線程,将有助于更好地了解代碼。編寫的lookUp(int)方法類似于add方法和remove方法,留給讀者自己練習。
2.2.4.2 觀察後判斷這是正确的嗎?
為什麼這段代碼和手拉手模式都有效呢?這裡有一些推理可以幫助我們建立對代碼的信心。例如,在管理多個鎖時,避免死鎖是一項挑戰。前面的代碼是如何幫助我們避免死鎖的呢?
假設線程T1調用add()方法,同時線程T2調用remove()。可能出現圖2-20中顯示的情況嗎?
這段代碼保證不可能出現死鎖情況。我們確定始終從頭節點開始按順序擷取鎖,是以,圖2-20中的鎖定順序不可能發生。
那麼如果發生兩個并發add(int)調用呢?假設該集合的内容為{9,22,35},并且T1向集合加入10,同時T2向集合加入25。
如圖2-21所示,總是有一個公共節點(是以總是有一個公共鎖)需要被兩個(或多個)線程擷取,因為根據定義,隻有一個線程可以獲勝,進而迫使其他線程等待。
很難看出我們是如何使用Java的内部鎖(synchronized關鍵字)來實作手拉手模式。顯式鎖為我們提供了更多控制權,并允許我們輕松地實作該模式。
2.2.5 生産者/消費者模式
在上一章中,我們看到線程需要互相協作才能實作重要功能。當然,協作離不開通信,ReentrantLock允許線程向其他線程發送信号,我們使用這種機制來實作一個并發FIFO隊列:
該類在其lck字段中持有可重入鎖。當然,它還有兩個Condition類型的字段:need-Space和needElem。在這裡,我們将看到如何使用它們,隊列元素存儲在名為items的數組中,如圖2-22所示。
head指向要消費的下一個元素,同樣,tail指向一個将存儲新元素的空槽。構造函數配置設定一個容量為cap的數組:
這裡有一些微妙之處,讓我們先了解一下簡單的東西。生産者線程嘗試将這些條目(items)壓入隊列,它首先擷取lck鎖,該方法代碼的其餘部分在這個鎖下執行。tail變量持有下一個槽的索引,我們可以在那裡存儲新的數字。下面的代碼将新元素壓入隊列:
如果我們已經用完所有數組槽,那麼tail将回到0:
count變量表示目前可供消費的元素個數。當我們再生成一個元素時,count會遞增。
接下來,讓我們看一下并發方面,如圖2-23所示。
由于items數組具有有限的容量(它最多可以容納cap個元素),是以我們需要處理隊列已滿的情況,此時,生産者需要等待消費者從隊列中取得一個或多個元素。
此等待通過調用needSpace條件變量的await()來完成。重要的是要意識到,線程被設定為等待和鎖lck被釋放,這是兩個原子操作。
假設有線程已從隊列中消費了一個或多個條目(我們很快就會在pop()方法中看到這是如何做的),此時,生産者線程在擷取鎖後醒來,擷取鎖對于讓其餘代碼正常工作是前提條件:
pop方法的工作原理與此類似,除了彈出邏輯外,它是push方法的鏡像,如圖2-24所示。
消費者線程使用以下代碼從隊列中彈出一個元素:
head移動到下一個可用元素(如果有):
請注意,就像tail 變量一樣,我們不斷回到開頭。當可用元素個數減少一個時,count則減1。
虛假和丢失的喚醒
為什麼我們需要先獲得鎖?當然,count變量是生産者和消費者二者共享的狀态。
還有一個原因是,我們需要在擷取lck鎖之後調用await。如
https://docs.oracle.com/cd/E19455-01/806-5257/sync-30/index.html所述,可能會出現圖2-25所示的情況。
如圖2-25所示,沒有被鎖定,是以信号丢失;沒有線程被喚醒,是以信号丢失。對于正确的信号語義來說,需要鎖定await()。
在循環中檢查條件也是必需的,換句話說,線程喚醒後,必須重新測試該條件,然後再繼續。這是處理“虛拟和丢失的喚醒”所必需的:
如果我們使用if條件,就會有一個潛在的bug。由于arcane平台效率的原因,await()方法可以虛假地傳回(沒有任何理由)。
在等到某個條件時,通常允許出現虛假喚醒,作為對底層平台語義的讓步。這對大多數應用程式幾乎沒有實際影響,因為循環中應該始終等待一個條件,進而測試正在等待的狀态謂詞。自由地消除虛假喚醒的可能性是可以實作的,但建議應用程式程式員始終假設它們可以發生,是以始終在循環中等待。
上一段話引用自相關Java文檔,其連結如下:
https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/Condition.html圖2-26是可能發生的錯誤情景。
如圖2-26所示,如果在一個循環中測試條件,生産者線程總是會醒來、再次檢查并繼續執行正确的語義。
2.2.6 比較和交換
鎖是昂貴的,試圖擷取鎖時被阻塞的線程會被挂起,挂起和恢複線程也是非常昂貴的。作為替代方案,我們可以使用CAS(比較和設定)指令來更新并發計數器。
CAS操作将處理如下項:
- 變量的記憶體位置(x)
- 變量的期望值(v)
- 需要設定的新值(nv)
CAS操作會自動将x中的值更新為nv,但前提是x中的現有值與v比對,否則,不采取任何行動。
在這兩種情況下,都傳回x的現有值。對于每個CAS操作,執行以下三個操作:
1.獲得值
2.比較值
3.更新值
所指定的三個操作均作為單個原子機器指令執行。
當多個線程嘗試執行CAS操作時,隻有一個線程獲勝并更新該值,但是,其他線程不會被挂起,CAS操作失敗的線程可以重新嘗試更新。
CAS的最大的優點是完全避免了上下文切換,如圖2-27所示。
如圖2-27所示,線程不斷循環并試圖通過嘗試執行CAS操作來獲勝。該調用采用目前值和新值,僅在更新成功時傳回true。如果其他某個線程赢了,循環就會重複,進而一次又一次地嘗試。
CAS更新操作是原子操作,更重要的是它避免了線程的挂起(以及随後的恢複)。在這裡,我們使用CAS來實作我們自己的鎖,如圖2-28所示。
getAndSet()方法嘗試設定新值并傳回前一個值。是以,如果前一個值為false,并且我們設法将其設定為true(請記住compare和set是原子的),那麼我們已經獲得鎖。
在使用CAS操作的情況下,通過擴充鎖接口而不阻塞任何線程來實作鎖定!但是,當多個線程争用鎖時,會導緻更激烈的争用,性能就會下降。
這就是為什麼線程運作在核心上的原因。每個核心都有一個緩存,這個緩存會存儲鎖變量的副本。getAndSet()調用會導緻所有核心使鎖的緩存副本失效,是以,當我們有更多線程和更多這樣的循環鎖時,會存在太多不必要的緩存失效,如圖2-29所示。
之前的代碼通過使用緩存變量b進行循環(也就是說,等待鎖定)來提高性能。當b的值變為false(進而意味着解鎖)時,while循環中斷。現在,執行getAndSet()調用以擷取鎖。
2.3 本章小結
在這一章中,我們從競争條件入手,無論是在實際環境中還是在并發代碼中,我們看到了同步的必要性。我們還詳細了解了競争條件,并了解了volatile關鍵字的作用。
接下來,我們研究了表示程式全局狀态的單例模式,也了解了如何使用螢幕安全地共享狀态。我們還糾正了可見性語義,并研究了稱為雙重檢查鎖定的優化。
我們研究了一個使用排序連結清單的并發集合實作的用例,使用鎖可能導緻粗粒度鎖定,雖然語義上是正确的,但此方案隻允許單個線程,這可能會損害并發性。
解決方案是使用手拉手設計模式,我們對它進行了深入的研究,了解到顯式鎖如何為我們提供更好的解決方案,進而既保持正确性,又提高并發性。
最後,我們介紹了生産者/消費者設計模式,我們了解了線程如何使用條件進行通信,我們還讨論了正确使用條件所涉及的微妙之處。
是以,親愛的讀者們,我們在這裡已經講了很多。現在,請看下一章中的更多設計模式。