Java執行緒與鎖
Java執行緒與鎖
本篇是 《深入理解Java虛擬機器》的最後一章, 在此涉及到了執行緒安全, 但並不是如何從程式碼層次來實現執行緒安全, 而是虛擬機器本身對執行緒安全做出了哪些努力, 在安全與效能之間又採取了哪些優化措施.
那麼一步步來梳理這些概念.
三種執行緒概念——核心執行緒、輕量級程序、使用者執行緒
參考
核心執行緒(Kernel-Level Thread, KLT)
一個程序由於其執行空間的不同, 從而有核心執行緒和使用者程序的區分, 核心執行緒執行在核心空間, 之所以稱之為執行緒是因為它沒有虛擬地址空間, 只能訪問核心的程式碼和資料.
而使用者程序則執行在使用者空間, 不能直接訪問核心的資料但是可以通過中斷, 系統呼叫等方式從使用者態陷入核心態, 但是核心態只是程序的一種狀態, 與核心執行緒有本質區別.
核心執行緒就是核心的分身, 一個分身可以處理一件特定事情.核心執行緒的使用是廉價的, 唯一使用的資源就是核心棧和上下文切換時儲存暫存器的空間.支援多執行緒的核心叫做多執行緒核心(Multi-Threads kernel ).
核心執行緒只執行在核心態,不受使用者態上下文的拖累.
處理器競爭:可以在全系統範圍內競爭處理器資源;
使用資源:唯一使用的資源是核心棧和上下文切換時保持暫存器的空間
排程: 排程的開銷可能和程序自身差不多昂貴
資源的同步和資料共享比整個程序的資料同步和共享要低一些.
核心執行緒沒有自己的地址空間,所以它們的”current->mm”都是空的, 且核心執行緒只能在核心空間操作,不能與使用者空間互動.
至於核心空間, 使用者空間, 核心態, 使用者態, 後文再來描述.
輕量級程序
輕量級程序(LWP)是建立在核心之上並由核心支援的使用者執行緒,它是核心執行緒的高度抽象,每一個輕量級程序都與一個特定的核心執行緒關聯.核心執行緒只能由核心管理並像普通程序一樣被排程.
而輕量級程序就是我們通常意義上所說的執行緒.
與普通程序區別:LWP只有一個最小的執行上下文和排程程式所需的統計資訊.
處理器競爭:因與特定核心執行緒關聯,因此可以在全系統範圍內競爭處理器資源
使用資源:與父程序共享程序地址空間
排程:像普通程序一樣排程
每一個程序有一個或多個LWPs,每個LWP由一個核心執行緒支援.在這種實現的作業系統中,LWP就是使用者執行緒.
由於每個LWP都與一個特定的核心執行緒關聯,因此每個LWP都是一個獨立的執行緒排程單元.即使有一個LWP在系統呼叫中阻塞,也不會影響整個程序的執行.
輕量級程序具有侷限性.
首先,大多數LWP的操作,如建立、析構以及同步,都需要進行系統呼叫.系統呼叫的代價相對較高:需要在user mode和kernel mode中切換.
其次,每個LWP都需要有一個核心執行緒支援,因此LWP要消耗核心資源(核心執行緒的棧空間).因此一個系統不能支援大量的LWP.
注:
1.LWP的術語是借自於SVR4/MP和Solaris 2.x.
2.有些系統將LWP稱為虛擬處理器.
3.將之稱為輕量級程序的原因可能是:在核心執行緒的支援下,LWP是獨立的排程單元,就像普通的程序一樣.所以LWP的最大特點還是每個LWP都有一個核心執行緒支援.
使用者執行緒
狹義上的 使用者執行緒是完全建立在使用者空間的執行緒庫,使用者執行緒的建立、排程、同步和銷燬全又庫函式在使用者空間完成, 系統不能夠感知到執行緒存在的實現.因此這種執行緒是極其低消耗和高效的.可以支援更大規模的執行緒數量.
但其優點在於對於系統而言是透明的, 缺點也在於此, 對於作業系統而言, 只是將處理器的資源分配給程序, 程序是處理器資源的最小排程單位, 當在程序中, 一個使用者執行緒如果阻塞在系統呼叫中,則整個程序都將會阻塞.而同樣的, 因為核心訊號(無論是同步的還是非同步的)都是以程序為單位的,無法定位到使用者執行緒執行緒,所以這種實現方式不能用於多處理器系統.
因此, 在現實中,純使用者級執行緒的實現,除演算法研究目的以外,幾乎已經消失了.
使用者執行緒 + LWP
使用者執行緒庫還是完全建立在使用者空間中,因此使用者執行緒的操作還是很廉價,因此可以建立任意多需要的使用者執行緒.
作業系統提供了LWP作為使用者執行緒和核心執行緒之間的橋樑.LWP還是和前面提到的一樣,具有核心執行緒支援,是核心的排程單元,並且使用者執行緒的系統呼叫要通過LWP,因此程序中某個使用者執行緒的阻塞不會影響整個程序的執行.
使用者執行緒庫將建立的使用者執行緒關聯到LWP上,LWP與使用者執行緒的數量不一定一致.當核心排程到某個LWP上時,此時與該LWP關聯的使用者執行緒就被執行.
Java實現
對於Sun JDK來說, 它的Windows版與Linux版都是使用一對一的執行緒模型實現的, 一條Java執行緒就對映到一條輕量級程序之中, 因為Windows和Linux系統提供的執行緒模型就是一對一的.
在Solaris平臺中, 由於作業系統的執行緒特性可以同時支援一對一(通過Bound Threads或Alternate Libthread實現)及多對多(通過LWP/Thread Based Synchronization實現)的執行緒模型,因此在Solaris版的JDK中也對應提供了兩個平臺專有的虛擬機器引數:-XX:+UseLWPSynchronization(預設值)和-XX:+UseBoundThreads來明確指定虛擬機器使用哪種執行緒模型.
但在這裡我有點實在不理解, 對於Linux系統而言, 輕量級程序也即通常意義上的執行緒數量是有限的, 也就意味著一臺Linux伺服器所能支援的最大執行緒數不過是 幾百上千, 而就我所理解的, 在Java中, 對應每個Controller請求都會建立相應的執行緒, 不止如此, 在程式碼執行中, 程式設計師也可能手動建立執行緒執行任務, 如此多的執行緒究竟是怎樣被支撐起來的? 而相應的不說百萬, 幾萬的併發量又是如何解決的?
即使是所謂的在多個執行緒之間進行切換, 但單臺計算機本身能夠建立的執行緒數是有限的, 特別是在Linux系統下, 一個執行緒就是一個輕量級程序, 只不過是恰好與其他 程序 共享 資源而已.
我所經手的系統, 還從未遇到過大併發的情況, 所以概念都只在猜測中.
但就目前瞭解到的而言:
Tomcat 預設配置的最大請求數是 150, 也就是說同時支援 150 個併發. 雖然可以自由調整, 其實也意味著同一臺伺服器所支援的併發數量必然不能太多, 更高的應該是要用分散式來解決這個問題.
建立的每一個執行緒都是要消耗CPU資源的, 種種因素就決定了伺服器的最大承受併發數量.
核心空間 核心態 使用者空間 使用者態
關於這一段可以跳過, 因為這種種概念與之前所提到的執行緒關聯並不是很緊密, 更多的是一種對其中概念的補充說明, 而核心態與使用者態之間的切換, 與執行緒之間的切換分屬於不同層面的東西.
參考
是誰來劃分記憶體空間的呢?在電腦開機之前, 記憶體就是一塊原始的實體記憶體.什麼也沒有.開機加電, 系統啟動後, 就對實體記憶體進行了劃分.當然, 這是系統的規定, 實體記憶體條上並沒有劃分好的地址和空間範圍.這些劃分都是作業系統在邏輯上的劃分.不同版本的作業系統劃分的結果都是不一樣的.
核心空間中存放的是核心程式碼和資料, 而程序的使用者空間中存放的是使用者程式的程式碼和資料.不管是核心空間還是使用者空間, 它們都處於虛擬空間中.
劃分不同區域的目的, 為了讓程式之間互不干擾, 即使電腦的瀏覽器崩潰, 也不會導致電腦藍屏, 處於使用者態的程式只能訪問使用者空間, 而處於核心態的程式可以訪問使用者空間和核心空間.那麼使用者態和核心態有什麼區別呢?
當一個任務(程序)執行系統呼叫而陷入核心程式碼中執行時, 我們就稱程序處於核心執行態(或簡稱為核心態).此時處理器處於特權級最高的(0級)核心程式碼中執行.當程序處於核心態時, 執行的核心程式碼會使用當前程序的核心棧.每個程序都有自己的核心棧.當程序在執行使用者自己的程式碼時, 則稱其處於使用者執行態(使用者態).即此時處理器在特權級最低的(3級)使用者程式碼中執行.
而由使用者態切換到核心態的方式有三種:
a. 系統呼叫
這是使用者態程序主動要求切換到核心態的一種方式, 使用者態程序通過系統呼叫申請使 用作業系統提供的服務程式完成工作, 比如前例中fork()實際上就是執行了一個建立新程序的系統呼叫.而系統呼叫的機制其核心還是使用了作業系統為使用者 特別開放的一箇中斷來實現, 例如Linux的int 80h中斷.
系統呼叫實際上是應用程式在使用者空間激起了一次軟中斷, 在軟中斷之前要按照規範, 將各個需要傳遞的引數填入到相應的暫存器中.軟中斷會激起核心的異常處理, 此時就會強制陷入核心態(此時cpu執行許可權提升), 軟中斷的異常處理函式會根據應用軟體的請求來決定api呼叫是否合法, 如果合法選擇需要執行的函式, 執行完畢後軟中斷會填入返回值, 安全地降低cpu許可權, 將控制權交還給使用者空間.所以核心提供的api呼叫, 你完全可以認為就是一個軟體包, 只不過這些軟體包你不能控制, 只能請求核心幫你執行.
b. 異常
當CPU在執行執行在使用者態下的程式時, 發生了某些事先不可知的異常, 這時會觸發由當前執行程序切換到處理此異常的核心相關程式中, 也就轉到了核心態, 比如缺頁異常.
c. 外圍裝置的中斷
當外圍裝置完成使用者請求的操作後, 會向CPU發出相應的中斷訊號, 這時CPU會 暫停執行下一條即將要執行的指令轉而去執行與中斷訊號對應的處理程式, 如果先前執行的指令是使用者態下的程式, 那麼這個轉換的過程自然也就發生了由使用者態到 核心態的切換.比如硬碟讀寫操作完成, 系統會切換到硬碟讀寫的中斷處理程式中執行後續操作等.
Java執行緒排程
參考
執行緒排程是指系統為執行緒分配處理器使用權的過程, 主要排程方式分兩種, 分別是協同式執行緒排程和搶佔式執行緒排程.
協同式執行緒排程, 執行緒執行時間由執行緒本身來控制, 執行緒把自己的工作執行完之後, 要主動通知系統切換到另外一個執行緒上.最大好處是實現簡單, 且切換操作對執行緒自己是可知的, 沒啥執行緒同步問題.壞處是執行緒執行時間不可控制, 如果一個執行緒有問題, 可能一直阻塞在那裡.
搶佔式排程, 每個執行緒將由系統來分配執行時間, 執行緒的切換不由執行緒本身來決定(Java中, Thread.yield()可以讓出執行時間, 但無法獲取執行時間).執行緒執行時間系統可控, 也不會有一個執行緒導致整個程序阻塞.
Java執行緒排程就是搶佔式排程.
如果有興趣可以瞭解下: 程序切換 這個概念
Java執行緒狀態:
NEW 狀態是指執行緒剛建立,尚未啟動,不會出現在Dump中.
RUNNABLE 狀態是執行緒正在正常執行中, 當然可能會有某種耗時計算/IO等待的操作/CPU時間片切換等, 這個狀態下發生的等待一般是其他系統資源, 而不是鎖, Sleep等, 主要不同是runable裡面有2個狀態, 可以理解為就是JVM呼叫系統執行緒的狀態.
BLOCKED 受阻塞並等待監視器鎖.這個狀態下, 是在多個執行緒有同步操作的場景, 比如正在等待另一個執行緒的synchronized 塊的執行釋放, 或者可重入的 synchronized塊裡別人呼叫wait() 方法, 也就是這裡是執行緒在等待進入臨界區
WAITING 無限期等待另一個執行緒執行特定操作.這個狀態下是指執行緒擁有了某個鎖之後, 呼叫了他的wait方法, 等待其他執行緒/鎖擁有者呼叫 notify / notifyAll 一遍該執行緒可以繼續下一步操作, 這裡要區分 BLOCKED 和 WATING 的區別, 一個是在臨界點外面等待進入, 一個是在臨界點裡面wait等待別人notify, 執行緒呼叫了join方法 join了另外的執行緒的時候, 也會進入WAITING狀態, 等待被他join的執行緒執行結束
TIMED_WAITING 有時限的等待另一個執行緒的特定操作.這個狀態就是有限的(時間限制)的WAITING, 一般出現在呼叫wait(long), join(long)等情況下, 另外一個執行緒sleep後, 也會進入TIMED_WAITING狀態
TERMINATED 這個狀態下表示 該執行緒的run方法已經執行完畢了, 基本上就等於死亡了(當時如果執行緒被持久持有, 可能不會被回收)
鎖
執行緒安全
執行緒安全
多個執行緒訪問同一個物件時, 如果不用考慮這些執行緒在執行時環境下的排程和交替執行, 也不需要進行額外的同步, 或者在呼叫方進行任何其他操作, 呼叫這個物件的行為都可以獲得正確的結果, 那麼這個物件就是執行緒安全的.
而就目前的感覺,只要當需要考慮到執行緒安全的時候, 才需要提到鎖這個概念.
而討論執行緒安全的前提, 是在多個執行緒之間共享資料. 如果沒有資料共享, 或根本沒有多執行緒, 那麼討論執行緒安全也是沒有意義的.
那麼除了在Java記憶體模型中提到的Happens-before以外, 比較有總結性質的, Java中執行緒安全的描述如下:
不可變
在Java語言中(1.5記憶體模型修正以後), 不可變物件一定是執行緒安全的, 無論是物件的方法實現還是物件的呼叫者,都不需要再採取任何執行緒安全的保障措施, 只要一個不可變物件被正確的構建出來, 那麼其外部狀態就永遠不會改變, 則必然是執行緒安全的.
而對於基本型別而言, 用final關鍵字進行描述的屬性, 必然是不可變的, 這點在Java的記憶體模型中就有所定義, Java虛擬實現也對其做了相應保障.
而如果是個物件呢?需要保證的就是物件的行為不會對其狀態產生任何影響.這個概念是什麼意思呢?
不妨參考:
Java多執行緒程式設計之不可變物件模式不可變物件指的是, 物件內部沒有提供任何可供修改物件資料的方法, 如果需要修改共享變數的任何資料, 都需要先構建整個共享物件, 然後對共享物件進行整體的替換, 通過這種方式來達到對共享物件資料一致性的保證.
在設計時,需要注意以下幾點:
不可變物件的屬性必須使用final修飾, 以防止屬性被意外修改, 並且final還可以保證JVM在該物件構造完成時該屬性已經初始化成功(JVM在構造完物件時可能只是為其分配了引用空間, 而各個屬性值可能還未初始化完成, (僅僅用private 修飾是不夠的, 因為private無法保證在jvm初始化時的執行緒安全.)
屬性的設值必須在構造方法中統一構造完成, 其餘的方法只是提供的查詢各個屬性相關的方法;
對於可變狀態的引用型別屬性, 如集合, 在獲取該型別的屬性時, 必須返回該屬性的一個深度複製結果, 以防止不可變物件的該屬性值被客戶端修改;
不可變物件的類必須使用final修飾, 以防止子類對其本身或其方法進行修改;
絕對執行緒安全
絕對執行緒安全就是符合本節開頭定義的執行緒安全, 但是這代價往往是很大的, 甚至是不切實際的, 即使在JavaApi中標明瞭為執行緒安全的類, 在使用時同樣需要注意, 因為它也不是絕對意義上的執行緒安全.
對於java.util.Vector而言, 幾乎所有的方法都用 synchronized進行修飾, 但這依然不能保證執行緒安全.
至於程式碼就不貼在這裡了, 如果想看的話, 在 深入理解java虛擬機器第二版, 第十三章就有. 究其根本原因, 不過是因為檢測與更改, 呼叫, 這些方法雖然本身具有原子性,但是聯合呼叫 是不具有原子性的.
所以在javaApi中的執行緒安全,大都是相對安全的.
相對執行緒安全
相對執行緒安全就是通常意義上的執行緒安全, 它需要保證對這個物件的單獨操作是執行緒安全的, 我們在呼叫時無需考慮執行緒安全問題, 但是在組合呼叫時, 依然需要進行保障, 在java中, Vector, HashTable, Collections.synchronizedCollection()等其他都是這種相對安全的.
執行緒相容
指的是物件本身不是安全的, 但是可以通過在呼叫端正確的使用同步手段, 保證執行緒安全, 而平時所說的執行緒不安全, 即指這一類情況. 這種操作我們常常會用到, 即進行加鎖操作.
執行緒對立
指的是無論採取怎樣的措施在多執行緒情況下都無法同時呼叫的程式碼.
執行緒安全的實現
參考
互斥同步
互斥同步是一種常見的併發正確性保證手段, 主要是保證在多執行緒併發情況下, 在同一時刻, 只能夠被一個執行緒(或一些, 使用訊號量的情況下)使用.
互斥是實現同步的主要方式, 而實現的互斥的主要方式有:
二元訊號量
二元訊號量(Binary Semaphore)是一種最簡單的鎖, 它有兩種狀態:佔用和非佔用.它適合只能被唯一一個執行緒獨佔訪問的資源.當二元訊號量處於非佔用狀態時, 第一個試圖獲取該二元訊號量鎖的執行緒會獲得該鎖, 並將二元訊號量鎖置為佔用狀態, 之後其它試圖獲取該二元訊號量的執行緒會進入等待狀態, 直到該鎖被釋放.
訊號量
多元訊號量允許多個執行緒訪問同一個資源, 多元訊號量簡稱訊號量(Semaphore), 對於允許多個執行緒併發訪問的資源, 這是一個很好的選擇.一個初始值為N的訊號量允許N個執行緒併發訪問.執行緒訪問資源時首先獲取訊號量鎖, 進行如下操作:
- 將訊號量的值減1;
- 如果訊號量的值小於0, 則進入等待狀態, 否則繼續執行;
訪問資源結束之後, 執行緒釋放訊號量鎖, 進行如下操作:
- 將訊號量的值加1;
- 如果訊號量的值小於1(等於0), 喚醒一個等待中的執行緒;
互斥量
互斥量(Mutex)和二元訊號量類似,資源僅允許一個執行緒訪問.與二元訊號量不同的是,訊號量在整個系統中可以被任意執行緒獲取和釋放,也就是說,同一個訊號量可以由一個執行緒獲取而由另一執行緒釋放.而互斥量則要求哪個執行緒獲取了該互斥量鎖就由哪個執行緒釋放,其它執行緒越俎代庖釋放互斥量是無效的.
臨界區
臨界區(Critical Section)是一種比互斥量更加嚴格的同步手段.互斥量和訊號量在系統的任何程序都是可見的,也就是說一個程序建立了一個互斥量或訊號量,另一程序試圖獲取該鎖是合法的.而臨界區的作用範圍僅限於本程序,其它的程序無法獲取該鎖.除此之處,臨界區與互斥量的性質相同.
而在java中, 最基本的互斥同步手段就是通過 synchronized, 這個關鍵字在經過編譯之後, 會生成 monitorenter 和 monitorexit 指令, 分別對應進入同步塊 和退出 同步塊, 這兩個指令都需要一個 reference型別的引數來指明需要解鎖, 加鎖的物件, 這一點則和物件頭有關, 稍後會提到.
至於synchronized選取的物件, 如果指定了物件, 無需多言, 而如果沒有指定, 那麼就根據鎖定的 方法究竟是例項方法還是類方法, 去取對應的例項物件 又或者是 Class物件.
synchronized採取的方式就是互斥量, 不過是可重入的, 當需要獲取鎖的時候, 會去檢測當前執行緒是否已經擁有了相應物件的鎖, 如果已經擁有, 則計數器+1, 噹噹前執行緒釋放鎖的時候, 計數器減一. 當計數器為0表示當前物件沒有被任何執行緒佔用, 鎖處於空閒狀態. 如果沒有, 則需要將執行緒阻塞, 等到鎖被釋放的時候再對執行緒進行喚醒.
然而, 在Java中, 執行緒的實現是與作業系統相掛鉤的, 因此執行緒的阻塞, 喚醒都需要系統級別的支援, 需要將當前執行緒從使用者態轉移到核心態. 而如果執行緒阻塞時間很短, 又需要將執行緒喚醒, 這種切換狀態的耗時甚至可能已經超過了執行程式碼本身的耗時, 是一種非常消耗資源, 時間的行為.
因此在Java1.6以後, 以經對synchronized做出了相當程度的優化. 而鎖的另一種程式碼實現, ReentrantLock, 在1.6以後的版本上, 效能上的問題, 已經不是決定選擇這兩種鎖中哪一種的根本原因.
ReentrantLock可以實現這樣幾種特別的需求:
等待可中斷, 也就是設定等待時間, 超過之後,執行緒不再等待, 執行別的.
可實現公平鎖, 在構造器中加入相應引數即可指定是否為公平鎖, 預設是非公平鎖, 公平鎖是指多個執行緒等待同一把鎖的時候, 根據申請的先後順序來依次獲取鎖.
鎖繫結多條件 當執行緒wait()之後, 可以被其他執行緒喚醒, 但是喚醒具有隨機性, 即在等待當前鎖中的任何一個執行緒, 而喚醒條件 就是 在等待當前鎖的執行緒,可以通過不斷巢狀, 將喚醒的執行緒最終鎖定到某一個, 但這無疑是很不方便的, 因此想要指定更多喚醒條件時, 就需要通過 ReentrantLock newCondition();
至於newCondition()的使用:
參考
所以在非上述幾種情況下, 還是使用 synchronized 比較合適, 這也是jvm優化的重點關注.
非阻塞同步
執行緒阻塞喚醒帶來的主要問題就是效能問題, 這種同步屬於互斥同步, 互斥同步是一種悲觀同步, 即, 認為只要不進行同步操作就一定會帶來安全問題, 無論是否真的需要同步, 有共享資源, 都會進行加鎖操作 以及 使用者態核心態轉換, 維護鎖計數器, 以及是否有執行緒需要被喚醒的檢測操作.
而隨著硬體指令集發展, 出現了另一種選擇, 基於衝突檢測的樂觀併發策略, 通俗來說, 就是先操作, 如果成功了, 繼續, 如果不成功則加鎖.
而問題來了, 檢測操作是兩個動作, 執行緒不安全的核心問題也大都源於此, 那這種樂觀鎖又是如何保證的? 這就 硬體指令集的支援, 大多人可能已經有所耳聞, 即 CAS;
比較, 如果符合條件就更新, 如果不滿足, 則返回原值.比較並交換 是一個原子性操作, 不再是被拆分成兩步執行, 這也是樂觀鎖的核心所在, 既然已經知道了核心本質. 那麼如何使用就不再是一個問題.網上資料不少, 我就不再費力.
而CAS操作由 sun.misc.Unsafe類裡面的 compareAndSwapInt() 和 compareAndSwapLong() 提供, 虛擬機器對這些方法做了特殊處理, 編譯出來就是一條CAS指令. 而我們要是用的則是:
java.util.concurrent.atomic包下面的各種類即可. 如果使用基礎型別的話, 考慮到執行緒安全, 使用這些類已經可以保證執行緒安全了.
而至於ABA問題, 一般情況下並不會產生實質性的影響, 如果想要避免還是使用互斥同步來進行實現即可.
無同步方案
如同一開始所提到的, 當執行緒之間不存在共享變數, 不存在資料競爭, 自然不需要同步操作.
而這裡要提到的就是另一個類 ThreadLocal, 顧名思義, 本地執行緒, 對於每一個執行緒都會建立對應的副本, 具體用法並不想在這裡多做說明. 很容易就找到很多例子.
它的核心實現大概是將當前執行緒 與 一張雜湊表相關聯, 即可.
那麼該用在什麼地方呢? 如果有一個變數, 我們需要進行共享, 卻僅限於當前執行緒內進行共享操作, 就需要用到ThreadLocal, 對於一般的共享變數, 往往會導致執行緒安全問題, 又沒有辦法將例項控制在單一執行緒內產生或銷燬, ThreadLocal就提供了這樣一個功能. 就像session, 需要在一個會話中儲存對應的資料, 卻又不希望被其他執行緒感知, 共享.
在使用上, 需要注意記憶體洩露的問題, 特別是使用執行緒池的時候, 具體原因與ThreadLocal的實現有關.
可以參考: ThreadLocal可能引起的記憶體洩露
鎖的分類
在細說鎖之前, 還需要了解一個概念: monitor, 這就牽扯到了 Synchronized在底層究竟如何實現, 我們知道鎖用的是物件, 那麼究竟如何標識這個物件, 以標識物件已經被鎖定?
參考
Java虛擬機器給每個物件和class位元組碼都設定了一個監聽器Monitor,用於檢測併發程式碼的重入,同時在Object類中還提供了notify和wait方法來對執行緒進行控制.
在Java的設計中 ,每一個Java物件自打孃胎裡出來就帶了一把看不見的鎖,它叫做內部鎖或者monitor.
每一個執行緒都有一個可用monitor record列表,同時還有一個全域性的可用列表.每一個被鎖住的物件都會和一個monitor關聯(物件頭的MarkWord中的LockWord指向monitor的起始地址),同時monitor中有一個Owner欄位存放擁有該鎖的執行緒的唯一標識,表示該鎖被這個執行緒佔用, 其結構如下:
Owner:初始時為NULL表示當前沒有任何執行緒擁有該monitor record,當執行緒成功擁有該鎖後儲存執行緒唯一標識,當鎖被釋放時又設定為NULL;
EntryQ:關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor record失敗的執行緒。
RcThis:表示blocked或waiting在該monitor record上的所有執行緒的個數。
Nest:用來實現重入鎖的計數。
HashCode:儲存從物件頭拷貝過來的HashCode值(可能還包含GC age)。
Candidate:用來避免不必要的阻塞或等待執行緒喚醒,因為每一次只有一個執行緒能夠成功擁有鎖,如果每次前一個釋放鎖的執行緒喚醒所有正在阻塞或等待的執行緒,會引起不必要的上下文切換(從阻塞到就緒然後因為競爭鎖失敗又被阻塞)從而導致效能嚴重下降。Candidate只有兩種可能的值0表示沒有需要喚醒的執行緒1表示要喚醒一個繼任執行緒來競爭鎖。
對照圖來說, 機制如下:
Monitor可以類比為一個特殊的房間,這個房間中有一些被保護的資料,Monitor保證每次只能有一個執行緒能進入這個房間進行訪問被保護的資料,進入房間即為持有Monitor,退出房間即為釋放Monitor。
當一個執行緒需要訪問受保護的資料(即需要獲取物件的Monitor)時,它會首先在entry-set入口佇列中排隊(這裡並不是真正的按照排隊順序),如果沒有其他執行緒正在持有物件的Monitor,那麼它會和entry-set佇列和wait-set佇列中的被喚醒的其他執行緒進行競爭(即通過CPU排程),選出一個執行緒來獲取物件的Monitor,執行受保護的程式碼段,執行完畢後釋放Monitor,如果已經有執行緒持有物件的Monitor,那麼需要等待其釋放Monitor後再進行競爭。
再說一下wait-set佇列。當一個執行緒擁有Monitor後,經過某些條件的判斷(比如使用者取錢發現賬戶沒錢),這個時候需要呼叫Object的wait方法,執行緒就釋放了Monitor,進入wait-set佇列,等待Object的notify方法(比如使用者向賬戶裡面存錢)。當該物件呼叫了notify方法或者notifyAll方法後,wait-set中的執行緒就會被喚醒,然後在wait-set佇列中被喚醒的執行緒和entry-set佇列中的執行緒一起通過CPU排程來競爭物件的Monitor,最終只有一個執行緒能獲取物件的Monitor。
參考
總的來說, 種種鎖的分類, 實現, 目的是為了儘可能的細粒度化加鎖, 也就是在絕對需要用到鎖的地方加鎖. 至於這個'絕對需要' 本身的定義就成為了種種鎖設計的動機所在. 鎖的相關東西, 偏向於底層, 依賴於作業系統, 種種優化也大多是在jvm層面進行優化, 因此對其實現暫時並沒有太高興致.
從排程策略上來說, 有公平鎖 非公平鎖, 當然在Java中要使用非公平鎖就需要ReentrantLock, 公平鎖指的是根據申請鎖的先後順序分配鎖給對應執行緒. 而非公平鎖則指的是 將當鎖被釋放之後, 隨機執行一個正在等待這個鎖的執行緒.
而樂觀鎖, 悲觀鎖指的並非是某一特定的鎖, 而是一種思想.
a. 悲觀鎖認為對於同一個資料的併發操作,一定是會發生修改的,哪怕沒有修改,也會認為存在競爭.因此對於同一個資料的併發操作,悲觀鎖採取加鎖的形式.悲觀的認為,不加鎖的併發操作一定會出問題.
b. 樂觀鎖則認為對於同一個資料的併發操作,是不會發生修改的.在更新資料的時候,會採用嘗試更新,不斷重新的方式更新資料.樂觀的認為,不加鎖的併發操作是沒有事情的.
從上面的描述我們可以看出,悲觀鎖適合寫操作非常多的場景,樂觀鎖適合讀操作非常多的場景,不加鎖會帶來大量的效能提升.
悲觀鎖在Java中的使用,就是利用各種鎖.
樂觀鎖在Java中的使用,是無鎖程式設計,常常採用的是CAS演算法,典型的例子就是原子類,通過CAS自旋實現原子操作的更新.自旋鎖
這是JVM進行的一種優化, 對於需要同步的程式碼而言, 即使兩個執行緒在爭用同一把鎖, 也不會使得另一個執行緒立刻進入阻塞狀態, 不難想象, 當要執行的程式碼本身非常少時, 阻塞狀態的切換, 喚醒等操作所需要消耗的時間, 效能影響都已經超過了執行程式碼本身時, 直接切入阻塞狀態無疑是一件比較不友好的事情.
在這裡就採取了一種自旋操作, 在每一次自旋中都需要判定是否鎖已經被釋放, 如果釋放, 獲取鎖, 如果沒有則繼續自旋. 通過這種方式, 就避免了頻繁的 不合時宜的 阻塞.
而自旋的次數, 使用者可以通過 -XX:PreBlockSpin來進行修改, 預設是10次.
而自旋在1.6以後成為了自適應的, 如果在前一次獲取鎖的時候很快速, 並且成功了, 那麼本次會多等一會, 如果很少獲得成功, 那麼會跳過自旋操作, 直接進入阻塞狀態.
輕量級鎖
輕量級鎖是相對於使用作業系統的互斥量實現的"重量級鎖"而言的.而輕量級鎖並不是用來代替重量級鎖的, 本意是為了減少多執行緒進入互斥的機率,並不是要替代互斥.
要理解輕量級鎖, 需要了解 Mark Word這個概念, 虛擬機器的物件頭包含兩部分, 第一部分用於儲存自身的執行時資訊, 如 雜湊碼, GC分代年齡等, 這部分被稱為 Mark Word, 它是實現輕量級鎖和偏向鎖的關鍵, Mark Word被設計成一個非固定的資料結構,以便在最小的空間記憶體儲儘量多的資訊.
在物件未被鎖定時, 儲存資訊在32位虛擬機器下,mark word32bit的資訊, 其中25bit用來儲存物件的雜湊碼,4bit儲存GC分代年齡資訊,兩bit儲存鎖標誌位, 剩下1bit固定位0. 而在其他的狀態下, 如下表所示:
儲存內容 標誌位 狀態 物件雜湊碼, 物件分代年齡 01 未鎖定 指向鎖記錄的指標 00 輕量級鎖定 指向重量級鎖(monitor)的指標 10 膨脹(重量級鎖) 空, 不需要記錄資訊 11 GC標記 偏向執行緒ID,偏向時間戳,物件分代年齡 01 可偏向 在程式碼進入同步塊時, 如果物件沒有被鎖定, 虛擬機器首先在當前執行緒的棧幀中建立一個名為 Lock Record(鎖記錄)的空間, 用於儲存物件目前的 Mark Word的拷貝.
然後嘗試以CAS的方式將物件的 Mark Word更新為指向Lock Record的指標, 如果更新成功, 則表示執行緒已經擁有了物件的鎖, 並將物件的 Mark Word的標誌位設定為00, 標識當前是處於 輕量級鎖定狀態下.
如果更新失敗, 則檢查是否其 Mark Record是否是指向當前執行緒的棧幀, 如果是的話, 表示已經獲取到鎖, 那麼就可以直接進入同步塊執行, 否則說明當前鎖已經被其他執行緒搶佔了.
如果兩個執行緒爭用同一把鎖, 不論這種爭用是發生在 CAS更新失敗, 還是在初始時鎖已經被佔用, 那麼輕量級鎖就不再有效, 需要膨脹為重量級鎖, 不僅僅Mark Word的標誌位要變成"10", 而Mark Word中儲存的指標也要變成指向 重量級鎖(互斥量, monitor)的指標, 後面等待鎖的執行緒也要進入阻塞狀態.
而釋放的時候, 也是通過CAS操作來進行, 如果Mark Word 仍然指向執行緒的 Lock Record, 則將Mark Word 與 Lock Record中儲存的 Mark Word替換, 如果直接替換成功, 則表示釋放鎖, 如果替換不成功, 則說明其已經膨脹為重量級鎖, 有其他執行緒嘗試獲取當前鎖, 則 仍然是通過 Mark Word中儲存的 monitor指標, 找到 wait set, 根據策略喚醒相應的執行緒.
偏向鎖
假設虛擬機器已經啟動了偏向鎖(-XX:+UseBiasedLocking, 在1.6中預設啟動), 那麼當鎖第一次被執行緒獲取到時, 虛擬機器會將物件的 標識位 置為 01, 同時將執行緒的 ID 記錄在物件的 Mark Word中, 如果CAS操作成功, 持有偏向鎖的執行緒以後在每次進入相關同步塊時, 虛擬機器都無需進行任何同步操作(Locking, unlocking, mark word update等)
而一旦有另一個執行緒嘗試獲取鎖時, 偏向模式即宣告結束, 如果物件目前未被鎖定, 則撤銷恢復至未鎖定狀態, 如果物件已經鎖定, 那麼升級成為輕量級鎖, 其操作就不再多說.
且無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖, 鎖只能升級, 不能降級.