Java併發-鎖

17年10月份接觸的java併發,距離現在也有一段時間了,對鎖這一塊一直處於非常迷茫疑惑的狀態,一是因爲有些概念比較抽象,二是名詞性的東西太多,沒有一個整體的理解很難去區分不同的名詞和概念,因此寫篇博客把鎖想過知識點整理一下。


從代碼的層面來劃分鎖,其實很簡單,Java 提供了兩種鎖機制來控制多個線程對共享資源的互斥訪問,第一個是 JVM 實現的 synchronized,而另一個是 JDK 實現的 Lock。Lock又可以再細分爲ReentrantLock和ReadWriteLock。一般會將synchronized與ReentrantLock進行對比。

synchronized

synchronized是jvm自帶的關鍵字,不需要引入任何包即可使用,可分爲以下四種用法:

  • 同步代碼塊
public void func() {
    synchronized (this) {
        // ...
    }
}

作用於對象,只有調用同一對象的同步代碼塊纔會同步。

  • 同步方法
public synchronized void func () {
    // ...
}

和同步代碼塊作用範圍一致。

  • 同步類
public void func() {
    synchronized (SynchronizedExample.class) {
        // ...
    }
}

作用於類,即使調用同一個類實例化的不同對象也會同步。

  • 同步靜態代碼塊
public synchronized static void fun() {
    // ...
}

和同步類的作用範圍一致。

對象鎖 & 類鎖

在synchronized中,從上訴四種使用方式可以看出,若按作用範圍可以劃分爲對象鎖和類鎖。對象鎖和類鎖只是爲了更好的理解synchronized而起的別名。

鎖優化

這裏的鎖優化主要是指 JVM 對 synchronized 的優化。主要是爲了提升
synchronized的性能。

自旋鎖

使用synchronized進行同步時,如果出現了併發必然有線程會阻塞,而線程必須從運行態轉換爲阻塞態。學過操作系統的人都知道,線程調度是需要CPU在用戶態和內核態之間進行切換的,而這種切換開銷很大。同時,根據統計,在實際的業務場景中,共享數據的鎖定狀態只會持續很短的一段時間。因此,自旋鎖的思想是讓一個線程在請求一個共享數據的鎖時執行忙循環(自旋)一段時間,如果在這段時間內能獲得鎖,就可以避免進入阻塞狀態。

自旋鎖雖然能避免進入阻塞狀態從而減少開銷,但是它需要進行忙循環操作佔用 CPU 時間,因此自旋時間必須設定範圍。

在 JDK 1.6 中引入了自適應的自旋鎖。自適應意味着自旋的次數不再固定了,而是由前一次在同一個鎖上的自旋次數及鎖的擁有者的狀態來決定。

鎖消除

鎖消除是指對於被檢測出不可能存在競爭的共享數據的鎖進行消除。

鎖消除主要是通過逃逸分析來支持,如果堆上的共享數據不可能逃逸出去被其它線程訪問到,那麼就可以把它們當成私有數據對待,也就可以將它們的鎖進行消除。

對於一些看起來沒有加鎖的代碼,其實隱式的加了很多鎖。例如下面的字符串拼接代碼就隱式加了鎖:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一個不可變的類,編譯器會對 String 的拼接自動優化。在 JDK 1.5 之前,會轉化爲 StringBuffer 對象的連續 append() 操作:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每個 append() 方法中都有一個同步塊(synchronized標識)。虛擬機觀察變量 sb,很快就會發現它的動態作用域被限制在 concatString() 方法內部。也就是說,sb 的所有引用永遠不會逃逸到 concatString() 方法之外,其他線程無法訪問到它,因此可以進行消除。

鎖粗化

如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,頻繁的加鎖操作就會導致性能損耗。

上一節的示例代碼中連續的 append() 方法就屬於這類情況。如果虛擬機探測到由這樣的一串零碎的操作都對同一個對象加鎖,將會把加鎖的範圍擴展(粗化)到整個操作序列的外部。對於上一節的示例代碼就是擴展到第一個 append() 操作之前直至最後一個 append() 操作之後,這樣只需要加鎖一次就可以了。

重量級鎖

前文解釋了synchronized的實現和運用,瞭解monitor的作用,但是由於monitor監視器鎖的操作是基於操作系統的底層Mutex Lock實現的,對所要加鎖線程加上互斥鎖,但是加鎖時間相比其他指令就長很多了,因此將這種基於互斥鎖的加鎖機制成爲重量級鎖。

輕量級鎖

在某些情況下,synchronized區域不存在競爭,依然按照重量級鎖的方式運行,會無端消耗資源,因此JDK1.6之後引入了偏向鎖和輕量級鎖,從而讓鎖擁有了四個狀態:無鎖狀態(unlocked)、偏向鎖狀態(biasble)、輕量級鎖狀態(lightweight locked)和重量級鎖狀態(inflated)。

以下是 HotSpot 虛擬機對象頭的內存佈局,這些數據被稱爲 Mark Word。其中 tag bits 對應了五個狀態,這些狀態在右側的 state 表格中給出。除了 marked for gc 狀態,其它四個狀態已經在前面介紹過了。

image

下圖左側是一個線程的虛擬機棧,其中有一部分稱爲 Lock Record 的區域,這是在輕量級鎖運行過程創建的,用於存放鎖對象的 Mark Word。而右側就是一個鎖對象,包含了 Mark Word 和其它信息。

image

輕量級鎖是相對於傳統的重量級鎖而言,它使用 CAS 操作來避免重量級鎖使用互斥量的開銷。對於絕大部分的鎖,在整個同步週期內都是不存在競爭的,因此也就不需要都使用互斥量進行同步,可以先採用 CAS 操作進行同步,如果 CAS 失敗了再改用互斥量進行同步。

當嘗試獲取一個鎖對象時,如果鎖對象標記爲 0 01,說明鎖對象的鎖未鎖定(unlocked)狀態。此時虛擬機在當前線程的虛擬機棧中創建 Lock Record,然後使用 CAS 操作將對象的 Mark Word 更新爲 Lock Record 指針。如果 CAS 操作成功了,那麼線程就獲取了該對象上的鎖,並且對象的 Mark Word 的鎖標記變爲 00,表示該對象處於輕量級鎖狀態。

image

如果 CAS 操作失敗了,虛擬機首先會檢查對象的 Mark Word 是否指向當前線程的虛擬機棧,如果是的話說明當前線程已經擁有了這個鎖對象,那就可以直接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程線程搶佔了。如果有兩條以上的線程爭用同一個鎖,那輕量級鎖就不再有效,要膨脹爲重量級鎖。

偏向鎖

偏向鎖的思想是偏向於讓第一個獲取鎖對象的線程,這個線程在之後獲取該鎖就不再需要進行同步操作,甚至連 CAS 操作也不再需要。

當鎖對象第一次被線程獲得的時候,進入偏向狀態,標記爲 1 01。同時使用 CAS 操作將線程 ID 記錄到 Mark Word 中,如果 CAS 操作成功,這個線程以後每次進入這個鎖相關的同步塊就不需要再進行任何同步操作。

當有另外一個線程去嘗試獲取這個鎖對象時,偏向狀態就宣告結束,此時撤銷偏向(Revoke Bias)後恢復到未鎖定狀態或者輕量級鎖狀態。

image

ReentrantLock

ReentrantLock 是 java.util.concurrent(J.U.C)包中的鎖。J.U.C包時JAVA 5.0 提供的工具包,包含了目前併發編程經常使用的各種工具類。

public class LockExample {

    private Lock lock = new ReentrantLock();

    public void func() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        } finally {
            lock.unlock(); // 確保釋放鎖,從而避免發生死鎖。
        }
    }
public static void main(String[] args) {
    LockExample lockExample = new LockExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> lockExample.func());
    executorService.execute(() -> lockExample.func());
}
}

輸出:

0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

比較

1.鎖的實現

synchronized 是 JVM 實現的,而 ReentrantLock 是 JDK 實現的。

2.性能

新版本 Java 對 synchronized 進行了很多優化,例如自旋鎖等,synchronized 與 ReentrantLock 大致相同。

3.等待可中斷

當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改爲處理其他事情。

ReentrantLock 可中斷,而 synchronized 不行。

4.公平鎖

公平鎖是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖。

synchronized 中的鎖是非公平的,ReentrantLock 默認情況下也是非公平的,但是也可以是公平的。

5.鎖綁定多個條件

一個 ReentrantLock 可以同時綁定多個 Condition 對象。

ReadWriteLock

ReadWriteLock管理一組鎖,一個是隻讀的鎖,一個是寫鎖。讀鎖可以在沒有寫鎖的時候被多個線程同時持有,寫鎖是獨佔的。

讀寫鎖的機制:

  • “讀-讀” 不互斥
  • “讀-寫” 互斥
  • “寫-寫” 互斥

Java併發包中ReadWriteLock是一個接口,主要有兩個方法,如下:

public interface ReadWriteLock {
/**
 * 返回讀鎖
 */
Lock readLock();

/**
 * 返回寫鎖
 */
Lock writeLock();
}

Java併發庫中ReetrantReadWriteLock實現了ReadWriteLock接口並添加了可重入的特性。

線程進入讀鎖的前提條件:

  1. 沒有其他線程的寫鎖
  2. 沒有寫請求,或者有寫請求但調用線程和持有鎖的線程是同一個線程

線程進入寫鎖的前提條件:

  1. 沒有其他線程的讀鎖
  2. 沒有其他線程的寫鎖

鎖分類(概念)

公平鎖 & 非公平鎖

在ReentrantLock中很明顯可以看到其中同步包括兩種,分別是公平的FairSync和非公平的NonfairSync。公平鎖的作用就是嚴格按照線程啓動的順序來執行的,不允許其他線程插隊執行的;而非公平鎖是允許插隊的。

默認情況下ReentrantLock是通過非公平鎖來進行同步的,包括synchronized關鍵字都是如此,因爲這樣性能會更好。

可重入鎖 & 不可重入鎖

  • 可重入鎖指同一個線程可以再次獲得之前已經獲得的鎖。
  • 可重入鎖可以在某種程度上避免死鎖。
  • Java中的可重入鎖:synchronized 和java.util.concurrent.locks.ReentrantLock

基本我們使用的都是可重入鎖,只是強調一下可重入的概念,不需太過糾結。

互斥鎖 & 共享鎖

  • 互斥鎖:同時只能有一個線程獲得鎖。比如,ReentrantLock 是互斥鎖,ReadWriteLock 中的寫鎖是互斥鎖。
  • 共享鎖:可以有多個線程同時或的鎖。比如,Semaphore、CountDownLatch 是共享鎖,ReadWriteLock 中的讀鎖是共享鎖。

樂觀鎖 & 悲觀鎖

簡而言之:

  • 悲觀鎖:假定會發生併發衝突,屏蔽一切可能違反數據完整性的操作。
  • 樂觀鎖:假設不會發生併發衝突,只在提交操作時檢查是否違反數據完整性。樂觀鎖最典型的例子就是CAS,大量的運用於併發包中的原子類(eg.AutomicInteger,AutomicLong,AutomicBoolean)

CAS(Compare And Swap)

CAS(Compare And Swap),即比較並交換。是解決多線程並行情況下使用鎖造成性能損耗的一種機制,CAS操作包含三個操作數——內存位置(V)、預期原值(A)和新值(B)。如果內存位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新爲新值。否則,處理器不做任何操作。無論哪種情況,它都會在CAS指令之前返回該位置的值。CAS有效地說明了“我認爲位置V應該包含值A;如果包含該值,則將B放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。

參考

CyC2018/CS-Notes

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章