多線程筆記:同步機制(1)

同步機制簡介

線程同步機制是一套用于協調線程間的數據訪問及活動的機制,該機制用于保障線程安全以及實現這些線程的共同目標。

線程同步機制是編程語言為多線程運行制定的一套規則,合理地運用這些規則可以很大程度上保障程序的正確運行。

這套機制包含兩方面的內容,一是關于多線程間的數據訪問的規則,二是多線程間活動的規則。前者關乎程序運行的正確與否,是相當重要的內容;后者很大程度上是影響程序的運行效率,也是不容忽視的內容。不太嚴謹地說,數據訪問的規則主要是由鎖來實現,線程間活動的規則則表現線程調度上。

線程安全問題的產生前提是多個線程并發訪問共享數據,那么一種保障線程安全的方法就是將多個線程對共享數據的并發訪問轉換為串行訪問,即一個共享數據一次只能被一個線程訪問,該線程訪問結束后其他線程才能對其進行訪問。鎖就是利用這種思路來實現線程同步機制。

GoLang中換了個思路,通過通道(channel)來實現共享數據的安全性。

鎖的相關概念

鎖在編程里是個蠻有趣的概念。

鎖:置于可啟閉的器物上,以鑰匙或暗碼(如字碼機構、時間機構、自動釋放開關、磁性螺線管等)打開的扣件 ——在線新華字典

特定代碼的作用域或是lock()unlock()方法之間的代碼構成的區域就是“器物”的表征,線程訪問其中的共享數據相當于解開“扣件”,打開了“器物”;通常所說“獲得xx鎖”,更像是獲得了“鑰匙或暗碼”能夠打開“扣件”的憑證。

說人話就是,鎖在生活通常是“護衛”、“保護”的含義,應當是阻止進入或下一步行動的拒絕機制;在編程里略有不同,它一方面是指代了需要被保護代碼(或數據)的范圍,一方面又指代了進入受保護代碼(或數據)的憑證。基于此,許多書與博客中的“申請鎖”這說法才能說的通。不然,按生活常理,你獲得了一把鎖,沒有鑰匙好像也沒什么用。

上述內容是本人的一點心得體會,不保證正確,不保證嚴謹

下面介紹與鎖相關的一些概念。

  1. 臨界區(Critical Section):獲得鎖之后和釋放鎖之前的這段時間內執行的代碼
  2. 內部鎖(Intrinsic Lock)與顯式鎖(Explicit Lock):按時Java虛擬機對鎖實現的方式劃分,內部鎖(由關鍵字synchronized實現)與顯式鎖(Lock接口的實現類實現)
  3. 可重入性(Reentrancy):一個線程在其持有一個鎖的時候能否再次(或多次)申請該鎖
  4. 鎖的爭用與調度:鎖也可以被看作是一種排他性的資源,因此爭用、調度概念也對鎖適用。鎖的調用基本上是Java虛擬機的設計者需要考慮的問題。Java平臺中鎖的調度策略包括了公平與非公平兩種,內部鎖屬于非公平鎖而顯式鎖則既支持公平鎖又支持非公平鎖
  5. 鎖的粒度:一個鎖實例所保護的共享數據的數量大小就被稱為鎖的粒度。但這是一個相對概念,應該根據實際情況來說明鎖粒度的大小
  6. 如果有多個線程訪問同一個鎖所保護的共享數據,那么就你這些線程同步在這個鎖上,或是對這些線程所訪問的共享數據訪問進行了加鎖;相應地,這些線程所執行的臨界區就被為這個鎖所引導的臨界區
  7. 鎖的排他、互斥:都是指一個鎖一次只能被一個線程所持有的特性。

以上就是與鎖相關的一些概念,這些概念也比較通用,在其他編程語言里或多或少也會有它們的身影。

內部鎖:synchronized關鍵字

Java平臺中任何一個對象都有唯一一個與之關聯的鎖。這種鎖被稱為監視器或是內部鎖。內部鎖是一種排他鎖,它能保障原子性、可見性和有序性。

內部鎖通過synchronized關鍵字實現的。synchronized關鍵字可以修飾方法及代碼塊。修飾方法時,此方法被稱為同步(靜態/實例)方法;修飾代碼塊時,被稱為同步(靜態)代碼塊。

同步方法

synchronized修飾的方法被稱為同步方法。同步方法的整個方法體就是一個臨界區。

public synchronized void sayHello(){
     // do something
}

同步靜態方法相當于以當前類對象為引導鎖的同步塊。

同步塊

synchronized (鎖句柄){
// do something
}

synchronized關鍵字所引導的代碼塊就是臨界區。鎖句柄是一個對象的引用。鎖句柄可以填寫this關鍵字,表示當前對象。習慣上也直接稱鎖句柄為鎖。鎖句柄對應的監視器就被稱為相應同步塊的引導鎖。相應地,我們稱呼相應的同步為該鎖引導的同步塊
作為鎖句柄的變量通常采用final修飾。這是因為鎖句柄變量的值一旦改變,會導致執行同一個同步塊的多個線程實際上使用不同的鎖,從而導致競態。因而,鎖句柄的變量通常聲明形式為private final Object lock = new Object();

特性

線程在執行臨界區代碼時必須持有該臨界區的引導鎖。一個線程執行到同步塊(同步方法也可看作是同步塊)時必須先申請該同步塊的引導鎖,只有申請成功該的線程才能夠執行相應的臨界區。一個線程執行完成臨界區代碼后引導該臨界區的鎖就會被自動釋放。在這個過程中,線程對內部鎖申請與釋放的動作由Java虛擬機負責完成,這也是synchronized實現的鎖被稱之為內部鎖的原因。
內部鎖的使用并不會導致鎖泄漏。Java編譯器對同步塊代碼作了特殊的處理,這使得臨界區的代碼即使拋出異常也不會妨礙內部鎖的釋放。

內部鎖的調度

Java虛擬機會為每個內部鎖分配一個入口集,用于記錄等待獲得相應內部鎖的線程。多個線程申請同一個鎖的時候,只有一個申請都能夠成為該鎖的持有線程(即申請鎖成功),而其他申請者的申請操作會失敗。這些申請失敗的線程并不會拋出異常,而是會被暫停(生命周期狀態變為BLOCKED)并被存入相應鎖的入口集中等待再申請鎖的機會。入口 集中的線程就被稱為相應內部鎖的等待線程。當該內部鎖被釋放時,入口集中的任意線程會被Java虛擬機喚醒,得到再次申請鎖的機會。由于是非公平的高度,被喚醒的等待處理器運行時可能還有其他新和活躍線程(RUNNABLE狀態且未進入過入口集)與該線程搶占這個被釋放的鎖,因此被喚醒的線程不一定就能成為該鎖的持有線程。另外,Java虛擬機從入口集中選擇一個等待線程的算法與虛擬機具體實現有關,總的來說是隨機的。(像極了女神和她的備胎們)

顯式鎖:Lock接口

顯式鎖是自JDK1.5引入的排他鎖,其作用與內部鎖大致相同,并額外提供些特性。

顯式鎖是java.util.concurrent.locks.Lock接口的實例。該接口對顯式鎖進行了抽象,類java.uitl.concurrent.locks.ReentrantLock是它默認實現類。

使用模版:

private final Lock lock = ....; // 一個Lock接口的實例
...
lock.lock(); // 申請鎖
try{
// do something
}finally{
lock.unlock(); // 手動釋放鎖,避免鎖泄漏
}

顯式鎖支持公平調度,但開銷相對較大,默認使用非公平調度。

改進型鎖:讀寫鎖

鎖的排他性使得多個線程無法以線程安全的方式在同一時刻對變量進行讀取(只讀不更新),不利于提高系統的并發性。
讀寫鎖(Read/Write Lock)是一種改進型的排他鎖,也被稱為共享/排他鎖(Shared/Exclusive Lock)。讀寫鎖允許多個線程可以同時讀取(只讀)共享變量,但是一次只允許一個線程對共享變量進行更新(包括讀取后再更新)。任何線程讀取變量的時候,其他線程無法更新這些變量 ;一個線程更新共享變量的時候,其他任何線程都無法訪問該變量。

輕量級同步機制:volatile關鍵字

volatile有“易揮發”的意思,引申為“不穩定”。volatile關鍵字用于修飾共享可變變量,即沒有使用final關鍵字修飾的實例變量或靜態變量,相應的變量被稱為volatile變量。

volatile關鍵字表示被修飾的變量的值容易變化(即被其他線程更改),因而不穩定。volatile變量的不穩定性意味著對這種變量的讀寫操作都必須從高速緩存或者主內存中讀取,以獲取變量的相對新值。因些,volatile變量不會被編譯器分配到寄存器進行存儲,對volatile變量的讀寫操作都是內存訪問操作。

volatile關鍵字常被稱為輕量級鎖,其作用與鎖的作用有相同的地方:保證可見性的有序性。在原子性方面,它僅能保障寫volatile變量操作的原子性,但沒有鎖的排他性;其次,volatile關鍵字的使用不會引起上下文切換(正是“輕量級”的原因)。

作用

volatile關鍵字的作用包括:保障可見性、有序性和long/double型變量讀寫操作的原子性(不是賦值操作)。
某些32位Java虛擬機上long/double型變量寫操作不具有原子性,加上volatile關鍵字后,其讀寫操作本身就具有了原子性。一般地,對于volatile變量的賦值操作,其右邊表達式中涉及共享變量(包括賦值的volatile變量本身),那么這個同仁操作就不是原子操作,要保障這樣操作的原子性,仍然需要鎖。
讀線程對volatile變量讀取操作會產生類似于獲得鎖的效果;寫線程則會產生類似于釋放鎖的效果;因此,volatile具有保障有序性和可見性的作用。
如果volatile變量是數組,那么volatile關鍵字只能對數組引用本身的操作起作用(讀取數組引用和更新數組引用),而無法對數組元素的操作起作用(讀取、更新數組元素)。

開銷

總的來說,讀寫操作成本介于普通變量寫和在臨界區內進行讀寫操作之間。

CAS指令

CAS(Compare and Swap)是對一種處理器指令的稱呼,不少多線程相關的Java標準庫類的實現最終都會借助CAS,實際中大多數情況不會直接使用。
對于簡單的自增操作count++來說,使用鎖的開銷過大,使用volatile又不能保證原子性,這種情況下可以使用CAS。它能將read-modify-write和check-and-act之類操作轉換為原子操作,其實現如偽代碼所示:

boolean compareAndSwap(Variable v ,Object oldVal ,Object newVal){
    if (oldVal == v.get()){ // check: 檢查變量值是否被其他線程修改過
        v.set(newVal); // act:更新變量值
        return true; // 更新成功
    }
    return false; // 變量值被其他線程修改,更新失敗
}

CAS操作前提假設是:如果變量v的當前值和客戶請求(即調用)CAS時所提供的變量值(即變量的舊值)是相等的,那么就說明其他線程并沒有修改過變量v的值,可以執行更新值的操作。其他線程更新值的操作則會失敗。
這個假設并不一定總能成立。

ABA 問題

對于共享變量v,當前線程看到它的值為A的那一刻,其他線程已經將其值更新為B,接著在當前線程執行CAS時該變量又被其他線程更新為A了,這就是ABA問題。
對ABA 問題接受度與要實現的算法有關,某些情況下無法接受ABA問題的存在。ABA問題常引入修訂號來解決,即用[共享變量實際值,修改號]這樣的元組來表示共享變量的值,AtomicStampedReference類就是基于這種思想實現的。

原子變量類

原子變量類比鎖的粒度更細,更輕量級,并且對于在多處理器系統上實現高性能的并發代碼來說是非常關鍵的。原子變量將發生競爭的范圍縮小到單個變量上。
原子變量類相當于一種泛化的 volatile 變量,能夠支持原子的、有條件的讀/改/寫操作。
原子類在內部使用 CAS 指令(基于硬件的支持)來實現同步。這些指令通常比鎖更快。
原子變量類可分為4組:

分組
基礎數據型 AtomicBoolean AtomicInteger AtomicLong
數組型 AtomicIntegerArray AtomicLongArray AtomicReferenceArray
字段更新器 AtomicIntegerFieldUpdater AtomicLongFieldUpdater AtomicStampedReference
引用型 AtomicReference AtomicReferenceFieldUpdater AtomicMarkableReference
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容