併發編程之線程安全性以及內置鎖

本章介紹線程的安全性和內置鎖,內容皆是筆者學習《Java併發編程實戰》或《Java併發編程的藝術》總結摘抄而來,僅作筆記。

當做個線程訪問某個類時,不管運行時環境採用何種調度方式或者這些線程將如何交替執行,並且在主調代碼中不需要任何額外的同步或協同,這個類都能表現出正確的行爲,那麼就稱這個類是線程安全的。正確的行爲是指某個類的行爲與其行爲規範一致。

通常,線程安全性的需求並非來源於對線程的直接使用,而是使用像Servlet這樣的框架。下面這個Servlet從請求中提取出數值然執行因數分解,將結果封裝到響應中。

public class StatelessFactorizer implements Servlet {
    public void service (ServletRequest req,ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp,factors);
    }
}

StatelessFactorizer是無狀態的:它既不包含任何域,也不包含任何對其他類中域的引用。由於線程訪問無狀態對象的行爲並不會影響其他線程中操作的正確性,因此無狀態對象是線程安全的。

大多數Servlet是無狀態的,從而極大降低了在實現Servlet線程安全性時的複雜性,只有當Servlet在處理請求時需要保存一些信息,線程安全性纔會成爲一個問題。

原子性

在Servlet中增加一個命中計數器來統計所處理的請求數量,方法是在Servlet中增加一個long類型的域,每處理一個請求就將這個值加1,實現如下所示:

public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;
    public long getCount(){
        return count;
    }
    public void service (ServletRequest req,ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count++;
        encodeIntoResponse(resp,factors);
    }
}

在單線程環境中UnsafeCountingFactorizer 能正確運行,但其並非是線程安全的。原因在於count++遞增操作時不是原子的,它包含三個獨立的操作:讀取count的值,將值加1,將計算結果寫入count。在沒有同步的情況下,線程A和線程B讀取count的值爲3,接着執行遞增操作,兩個線程都將計數器的值設爲4,也就導致計數器有了差值。

在併發編程中,這種由於不恰當的執行時序而出現不正確的結果是一種非常嚴重的情況,稱爲競態條件(Race Condition)。

當某個計算的正確性取決於多個線程的交替執行時序時,就會發生競態條件。最常見的競態條件類型就是“先檢查後執行”,即通過一個可能失效的觀測結果來決定下一步的動作。使用“先檢查後執行”的一種常見情況就是延遲初始化。延遲初始化的目的是將對象的初始化操作推遲到實際使用時才進行,同時要確保只被初始化一次。實際上就是單例模式中的懶漢式。

public class LazyInitRace {
    private LazyInitRace(){};
    private LazyInitRace instance = null;
    public LazyInitRace getInstance(){
        if(instance == null){
            instance = new LazyInitRace();
        }
        return instance;
    }
}

在LazyInitRace中包含了一個競態條件,它可能會使這個單例模式產生多個LazyInitRace對象。假設線程A和線程B同時執行getInstance()方法,線程A看到instance爲空,因此創建了一個新的LazyInitRace對象。此時線程B同樣判斷instance爲空,也創建了一個新的LazyInitRace對象。那麼這兩次調用就返回了兩個LazyInitRace對象,違反了我們創建這個類的初衷。

與大多數併發錯誤一樣,競態條件並不總是會產生錯誤,還需要某種不恰當的執行時序。

UnsafeCountingFactorizer和LazyInitRace都包含一組需要以原子方式執行的操作。要避免競態條件問題,就必須在某個線程修改該變量時,通過某種方式防止其他線程使用這個變量。從而確保其他線程只能在修改操作完成之前或之後讀取和修改狀態,而不是在修改狀態的過程中。

原子操作是指,對於訪問同一個狀態的所有操作來說,這個操作是一個以原子方式執行的操作。假定有兩個操作A和B,如果從執行A的線程來看,當另一個線程執行B時,要麼將B全部執行完,要不完全不執行B,那麼A和B對彼此來說就是原子的。

假如UnsafeCountingFactorizer中的遞增操作是原子的,競態條件就不會發生。我們將“先檢查後執行“和"讀取-修改-寫入”等操作稱爲複合操作,即包含了一組必須以原子方式執行的操作以確保線程安全性。

加鎖機制

Java提供了一種內置的鎖機制來支持原子性:同步代碼塊(Synchronized Block)。同步代碼塊包括兩部分:一個作爲鎖的對象引用,一個作爲由這個鎖保護的代碼塊。

關鍵字synchronized可以修飾實例方法、靜態方法和代碼塊。修飾實例方法時鎖對象就是調用此實例方法的實例對象,基本格式如下:

public synchronized 返回值 方法名(){}

靜態的synchronized方法以Class對象作爲鎖,可以控制靜態成員的併發。其基本格式如下:

public static synchronized 返回值 方法名(){}

如果一個類中既有靜態synchronized方法又有非靜態synchronized方法,線程A調用靜態synchronized方法時,線程B調用非靜態synchronized方法是可以的,因爲他們的鎖不一樣。靜態synchronized方法的鎖是類的Class對象,非靜態synchronized方法的鎖是調用方法的實例對象。

在只有一小部分語句需要加鎖時就可以使用同步代碼塊。其基本格式如下:

synchronized(鎖對象){}

每個Java對象都可以用作一個實現同步的鎖,這些鎖被稱爲內置鎖(Intrinsic Lock)或監視器鎖(Monitor Lock)。線程在進入同步代碼塊之前會自動獲得鎖,並且在退出同步代碼塊時釋放鎖,無論是通過正常的控制路徑退出,還是通過從代碼塊中拋出異常退出。獲得內置鎖的唯一途徑就是進去由這個鎖保護的同步代碼塊或方法。

Java的內置鎖相當於一種互斥鎖這意味着最多隻有一個線程能持有這種鎖。當線程A嘗試獲取一個線程B持有的鎖時,線程A必須等待或阻塞,知道線程B釋放這個鎖。如果B永遠不釋放鎖,那麼A將永遠等下去。

由於每次只能有一個線程執行內置鎖保護的代碼塊,因此,由這個鎖保護的同步代碼塊會以原子方式執行,多個線程在執行該代碼塊時也不會相互干擾。併發環境中的原子性與事務應用程序中的原子性有着相同的含義:一組作爲一個不可分割的單元被執行的語句。任何一個執行同步代碼塊的線程,都不可能看到有其他線程正在執行由同一個鎖保護的同步代碼塊。

當某個線程請求一個由其他線程持有的鎖時,發出請求的線程就會阻塞。然而由於內置鎖是可重入的,因此如果某個線程試圖獲得一個已經由它自己持有的鎖,那麼這個請求就會成功。”重入“意味着獲取鎖的操作的粒度是“線程”,而不是調用。重入的一種實現方法是爲每個鎖關聯一個獲取計數值和一個所有者線程。當計數值爲0時,這個鎖就被認爲是沒有被任何線程持有。當線程請求一個未被持有的鎖時,JVM將記下鎖的持有者,並且將計數器的值置爲1。如果同一個線程再次獲取這個鎖,計數值將遞增。當線程退出同步代碼塊時,計數器會對應的遞減。當計數值爲0時,這個鎖將被釋放。

Synchronized實現原理

JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步,但兩者的實現細節不一樣。代碼塊同步使用monitorenter和monitorexit指令實現的,而方法同步是使用另一種方式實現的。但方法的同步也可以使用這兩個指令實現。

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

synchronized用的鎖是存在Java對象頭裏的。如果對象是數組類型,則虛擬機用3個字寬(Word)存儲對象頭;如果對象是非數組類型,則用2個字寬存儲對象頭。在32位虛擬機中,1字寬等於4字節,即32bit。如下圖所示

Java對象頭的長度
長度 內容 說明
32/64bit Mark Word 存儲對象的hashCode、分代年齡、GC標誌或鎖信息等
32/64bit Class Metadata Address 存儲到對象類型數據的指針,JVM通過這個指針確定該對象是哪個類的實例
32/32bit Array length 如果當前對象是數組,則存儲的是數組的長度

32位JVM的Mark Word的默認存儲結構如下表。

Java對象頭的存儲結構
鎖狀態 25bit 4bit 1bit(是否是偏向鎖) 2bit(鎖標誌位)
無鎖狀態 對象的hashCode 對象分代年齡 0 01

在運行期間,Mark Word裏存儲的數據會隨着鎖標誌位的變化而變化。Mark Word可能變化爲存儲以下四種數據,如下表。

Mark Word的狀態變化
鎖狀態 25bit 4bit 1bit 2bit
23bit 2bit 是否是偏向鎖 鎖標誌位
輕量級鎖 指向棧中鎖記錄的指針 00
重量級鎖 指向互斥量(重量級鎖)的指針 10
GC標記 11
偏向鎖 線程ID Epoch 對象分代年齡 1 01

在64位虛擬機下,Mark Word是64bit大小的,其存儲結構如下表。

Mark Word的存儲結構
鎖狀態 25bit 31bit 1bit 4bit 1bit 2bit
    cms_free 分代年齡 偏向鎖 鎖標誌位
無鎖 unused hashCode     0 01
偏向鎖 ThreadId(54bit) Epoch(2bit)     1 01

CAS

CAS(compare and swap,簡稱CAS),即比較交換,是在無鎖的策略中使用的一個原子算法。一個CAS算法包含以下三個參數:

  • V:要更新的變量
  • E:預期值
  • N:新值

當V的值與E相同時纔會將V的值更新爲N,否則說明V的值可能被其他線程改了,當前線程放棄此操作。

當多個線程同時使用CAS競爭一個變量時,只會有一個成功。其他失敗的線程可以放棄,也可以自旋CAS操作(即循環執行CAS操作,直到成功)。CAS與鎖相比,性能更優越,並且由於是非阻塞的,沒有死鎖問題。

但目前來看CAS操作也是有競態條件問題的,屬於“先比較後執行”類型,那是不是就沒有什麼意義了呢?不是的。筆者在上面的定義中強調了是一個原子操作,即在CAS中,比較V與E的值後更新V的值其實是原子的。這個原子操作是由CPU的指令實現的,這裏不深入介紹了(筆者以後或許深入瞭解之後再補充)。

雖然CAS很高效的解決了院子操作,但CAS仍然存在三大問題:

  1. ABA問題:如果一個變量原來的值是A,變成了B,又變成了A,使用CAS檢查時發現它的值並沒有發生變化,但實際上卻變化了。解決方式是在變量前面加上版本號,每次變量更新的時候把版本號加1。從JDK1.5開始,JDK的Atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法的作用是首先檢查當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。
  2. 循環時間長開銷大:自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。
  3. 只能保證一個共享變量的原子操作:當對一個共享變量執行操作時,可以使用循環CAS的方式來保證原子操作,但對多個共享變量操作時,循環CAS就無法保證操作的原子性,這時可以用鎖。

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

鎖的優化

Java SE 1.6爲了較少獲得鎖和釋放鎖帶來的性能消耗,引入了偏向鎖和輕量級鎖。鎖一共有四種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。這幾個狀態會隨着競爭情況而逐漸升級。鎖可以升級但不能降級,意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提高獲得鎖和釋放鎖的效率。

偏向鎖

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

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

偏向鎖在Java6和Java7中是默認啓用的,但它在應用程序啓動幾秒鐘後才激活,如果有必要可以只用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果應用程序裏所有的鎖通常情況下出於競爭狀態,可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程序默認會進去輕量級鎖狀態。

輕量級鎖

線程在執行同步塊之前,JVM會先在當前線程的棧幀中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中,官方稱爲Didplaced Mark Word。然後線程嘗試使用CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針。如果替換成功,當前線程獲得鎖;如果替換失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。

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

輕量級鎖是用於線程交替執行同步塊的場景,如果同一時間有多個線程訪問同一個同步塊,輕量級鎖就會膨脹成重量級鎖。

下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖。

因爲自旋會消耗CPU,爲了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖膨脹爲重量級鎖,就不會再恢復到輕量級鎖,當鎖出於這個狀態下,其他線程試圖獲取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。

下面是鎖的優缺點的對比。

鎖的優缺點對比
優點 缺點 適用場景
偏向鎖 加鎖和解鎖都不需要額外的消耗,與執行非同步方法相比僅存在納秒級的差距 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 只有一個線程訪問同步塊的場景
輕量級鎖 競爭的線程不會阻塞,提高了程序的響應速度 如果始終得不到鎖競爭的線程,使用自旋會消耗CPU 追求響應時間或同步塊執行速度非常塊
重量級鎖 線程競爭不使用自旋,不會消耗CPU 線程阻塞,響應時間緩慢 追求吞吐量或同步塊執行時間較長
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章