15、synchronized和ReentrantLock有什麼區別呢?(高併發編程----1)

目錄

synchronized 和 ReentrantLock 有什麼區別?有人說 synchronized 最慢,這話靠譜嗎?

典型回答

1 用法比較

2 特性比較

3 注意事項

考點分析

知識擴展

首先,我們需要理解什麼是線程安全。

線程安全需要保證幾個基本特性:

synchronized

再來看看 ReentrantLock。


從今天開始,我們將進入 Java 併發學習階段。軟件併發已經成爲現代軟件開發的基礎能力,而 Java 精心設計的高效併發機制,正是構建大規模應用的基礎之一,所以考察併發基本功也成爲各個公司面試 Java 工程師的必選項。

 

synchronized 和 ReentrantLock 有什麼區別?有人說 synchronized 最慢,這話靠譜嗎?

典型回答

synchronized 是 Java 內建的同步機制,所以也有人稱其爲 Intrinsic Locking,它提供了互斥的語義和可見性,當一個線程已經獲取當前鎖時,其他試圖獲取的線程只能等待或者阻塞在那裏。

在 Java 5 以前,synchronized 是僅有的同步手段,在代碼中, synchronized 可以用來修飾方法,也可以使用在特定的代碼塊兒上,本質上 synchronized 方法等同於把方法全部語句用 synchronized 塊包起來。

ReentrantLock,ReentrantLock是Lock的實現類,是一個互斥的同步器,通常翻譯爲再入鎖,是 Java 5 提供的鎖實現,它的語義和 synchronized 基本相同。再入鎖通過代碼直接調用 lock() 方法獲取,代碼書寫也更加靈活。與此同時,ReentrantLock 提供了很多實用的方法,能夠實現很多 synchronized 無法做到的細節控制,比如可以控制 fairness,也就是公平性,或者利用定義條件等。但是,編碼中也需要注意,必須要明確調用 unlock() 方法釋放,不然就會一直持有該鎖。

synchronized 和 ReentrantLock 的性能不能一概而論,早期版本 synchronized 在很多場景下性能相差較大,在後續版本進行了較多改進,synchronized 在低競爭場景中表現可能優於 ReentrantLock。

在多線程高競爭條件下,ReentrantLock比synchronized有更加優異的性能表現。
 

1 用法比較

Lock使用起來比較靈活,但是必須有釋放鎖的配合動作
Lock必須手動獲取與釋放鎖,而synchronized不需要手動釋放和開啓鎖
Lock只適用於代碼塊鎖,而synchronized可用於修飾方法、代碼塊等


 

2 特性比較

ReentrantLock的優勢體現在:

  • 具備嘗試非阻塞地獲取鎖的特性:當前線程嘗試獲取鎖,如果這一時刻鎖沒有被其他線程獲取到,則成功獲取並持有鎖。
  • 能被中斷地獲取鎖的特性:與synchronized不同,獲取到鎖的線程能夠響應中斷,當獲取到鎖的線程被中斷時,中斷異常將會被拋出,同時鎖會被釋放。
  • 超時獲取鎖的特性:在指定的時間範圍內獲取鎖;如果截止時間到了仍然無法獲取鎖,則返回。
     

3 注意事項

在使用ReentrantLock類的時,一定要注意三點:

  • 在finally中釋放鎖,目的是保證在獲取鎖之後,最終能夠被釋放。
  • 不要將獲取鎖的過程寫在try塊內,因爲如果在獲取鎖時發生了異常,異常拋出的同時,也會導致鎖無故被釋放。
  • ReentrantLock提供了一個newCondition的方法,以便用戶在同一鎖的情況下可以根據不同的情況執行等待或喚醒的動作。

 

考點分析

今天的題目是考察併發編程的常見基礎題,我給出的典型回答算是一個相對全面的總結。

對於併發編程,不同公司或者面試官面試風格也不一樣,有個別大廠喜歡一直追問你相關機制的擴展或者底層,有的喜歡從實用角度出發,所以你在準備併發編程方面需要一定的耐心。

我認爲,鎖作爲併發的基礎工具之一,你至少需要掌握:

  •   理解什麼是線程安全。
  •   synchronized、ReentrantLock 等機制的基本使用與案例。


更近一步,你還需要:

  •   掌握 synchronized、ReentrantLock 底層實現;理解鎖膨脹、降級;理解偏斜鎖、自旋鎖、輕量級鎖、重量級鎖等概念。
  •   掌握併發包中 java.util.concurrent.lock 各種不同實現和案例分析。


知識擴展

首先,我們需要理解什麼是線程安全。

我建議閱讀 Brain Goetz 等專家撰寫的《Java 併發編程實戰》(Java Concurrency in Practice),雖然可能稍顯學究,但不可否認這是一本非常系統和全面的 Java 併發編程書籍。按照其中的定義,線程安全是一個多線程環境下正確性的概念,也就是保證多線程環境下共享的、可修改的狀態的正確性,這裏的狀態反映在程序中其實可以看作是數據

 

換個角度來看,如果狀態不是共享的,或者不是可修改的,也就不存在線程安全問題,進而可以推理出保證線程安全的兩個辦法:

  •   封裝:通過封裝,我們可以將對象內部狀態隱藏、保護起來。
  •   不可變:還記得我們在專欄第 3 講強調的 final 和 immutable 嗎,就是這個道理,Java 語言目前還沒有真正意義上的原生不可變,但是未來也許會引入。


線程安全需要保證幾個基本特性:

  •   原子性,簡單說就是相關操作不會中途被其他線程干擾,一般通過同步機制實現。
  •   可見性,是一個線程修改了某個共享變量,其狀態能夠立即被其他線程知曉,通常被解釋爲將線程本地狀態反映到主內存上。
  •   有序性,是保證線程內串行語義,避免指令重排等。

 

synchronized

可能有點晦澀,那麼我們看看下面的代碼段,分析一下原子性需求體現在哪裏。這個例子通過取兩次數值然後進行對比,來模擬兩次對共享狀態的操作。你可以編譯並執行,可以看到,僅僅是兩個線程的低度併發,就非常容易碰到 former 和 latter 不相等的情況。這是因爲,在兩次取值的過程中,其他線程可能已經修改了 sharedState。

public class ThreadSafeSample extends Thread{
    public int sharedState;
    public void nonSafeAction() {
        while (sharedState < 100000) {
            int former = sharedState++;
            int latter = sharedState;
            if (former != latter - 1) {
                System.out.printf("Observed data race, former is " +
                        former + ", " + "latter is " + latter);
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadSafeSample sample = new ThreadSafeSample();
        Thread threadA = new Thread(){
            public void run(){
                sample.nonSafeAction();
            }
        };
        Thread threadB = new Thread(){
            public void run(){
                sample.nonSafeAction();
            }
        };
        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
    }
}

下面是在我的電腦上的運行結果:
C:\>c:\jdk-9\bin\java ThreadSafeSample
Observed data race, former is 13097, latter is 13099

將兩次賦值過程用 synchronized 保護起來,使用 this 作爲互斥單元,就可以避免別的線程併發的去修改 sharedState。

synchronized (this) {
    int former = sharedState ++;
    int latter = sharedState;
    // …
}

如果用 javap 反編譯,可以看到類似片段,利用 monitorenter/monitorexit 對實現了同步的語義

11: astore_1
12: monitorenter
13: aload_0
14: dup
15: getfield      #2                  // Field sharedState:I
18: dup_x1
…
56: monitorexit

我會在下一講,對 synchronized 和其他鎖實現的更多底層細節進行深入分析。

代碼中使用 synchronized 非常便利,如果用來修飾靜態方法,其等同於利用下面代碼將方法體囊括進來:

synchronized (ClassName.class) {}

 

再來看看 ReentrantLock。

Lock的公平鎖和非公平鎖

Lock lock=new ReentrantLock(true);//公平鎖
Lock lock=new ReentrantLock(false);//非公平鎖

公平鎖指的是線程獲取鎖的順序是按照加鎖順序來的,而非公平鎖指的是搶鎖機制,先lock的線程不一定先獲得鎖。

你可能好奇什麼是再入?它是表示當一個線程試圖獲取一個它已經獲取的鎖時,這個獲取動作就自動成功,這是對鎖獲取粒度的一個概念,也就是鎖的持有是以線程爲單位而不是基於調用次數。Java 鎖實現強調再入性是爲了和 pthread 的行爲進行區分。

再入鎖可以設置公平性(fairness),我們可在創建再入鎖時選擇是否是公平的。

ReentrantLock fairLock = new ReentrantLock(true);//公平鎖

這裏所謂的公平性是指在競爭場景中,當公平性爲真時,會傾向於將鎖賦予等待時間最久的線程。公平性是減少線程“飢餓”(個別線程長期等待鎖,但始終無法獲取)情況發生的一個辦法。

 

如果使用 synchronized,我們根本無法進行公平性的選擇,其永遠是不公平的,這也是主流操作系統線程調度的選擇。通用場景中,公平性未必有想象中的那麼重要,Java 默認的調度策略很少會導致 “飢餓”發生。與此同時,若要保證公平性則會引入額外開銷,自然會導致一定的吞吐量下降。所以,我建議只有當你的程序確實有公平性需要的時候,纔有必要指定它。

我們再從日常編碼的角度學習下再入鎖。爲保證鎖釋放,每一個 lock() 動作,我建議都立即對應一個 try-catch-finally,典型的代碼結構如下,這是個良好的習慣。

ReentrantLock fairLock = new ReentrantLock(true);// 這裏是演示創建公平鎖,一般情況不需要。

fairLock.lock();

try {
    // do something
} finally {
     fairLock.unlock();
}

ReentrantLock 相比 synchronized,因爲可以像普通對象一樣使用,所以可以利用其提供的各種便利方法,進行精細的同步操作,甚至是實現 synchronized 難以表達的用例,如:

  •   帶超時的獲取鎖嘗試。
  •   可以判斷是否有線程,或者某個特定線程,在排隊等待獲取鎖。
  •   可以響應中斷請求。
  •   ...


這裏我特別想強調條件變量(java.util.concurrent.Condition),如果說 ReentrantLock 是 synchronized 的替代選擇,Condition 則是將 wait、notify、notifyAll 等操作轉化爲相應的對象,將複雜而晦澀的同步操作轉變爲直觀可控的對象行爲。

 

Condition類和Object類

  • Condition類的awiat方法和Object類的wait方法等效
  • Condition類的signal方法和Object類的notify方法等效
  • Condition類的signalAll方法和Object類的notifyAll方法等效

 

條件變量最爲典型的應用場景就是標準類庫中的 ArrayBlockingQueue 等。我們參考下面的源碼,首先,通過再入鎖獲取條件變量:

/** Condition for waiting takes */
private final Condition notEmpty;

/** Condition for waiting puts */
private final Condition notFull;

public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}

兩個條件變量是從同一再入鎖創建出來,然後使用在特定操作中,如下面的 take 方法,判斷和等待條件滿足:

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0)
            notEmpty.await();
        return dequeue();
    } finally {
        lock.unlock();
    }
}

當隊列爲空時,試圖 take 的線程的正確行爲應該是等待入隊發生,而不是直接返回,這是 BlockingQueue 的語義,使用條件 notEmpty 就可以優雅地實現這一邏輯。

那麼,怎麼保證入隊觸發後續 take 操作呢?請看 enqueue 實現:

private void enqueue(E e) {
    // assert lock.isHeldByCurrentThread();
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    items[putIndex] = e;
    if (++putIndex == items.length) putIndex = 0;
    count++;
    notEmpty.signal(); // 通知等待的線程,非空條件已經滿足
}

 

通過 signal/await 的組合,完成了條件判斷和通知等待線程,非常順暢就完成了狀態流轉。注意,signal 和 await 成對調用非常重要,不然假設只有 await 動作,線程會一直等待直到被打斷(interrupt)。

從性能角度,synchronized 早期的實現比較低效,對比 ReentrantLock,大多數場景性能都相差較大。但是在 Java 6 中對其進行了非常多的改進,可以參考性能對比,在高競爭情況下,ReentrantLock 仍然有一定優勢。我在下一講進行詳細分析,會更有助於理解性能差異產生的內在原因。在大多數情況下,無需糾結於性能,還是考慮代碼書寫結構的便利性、可維護性等。

今天,作爲專欄進入併發階段的第一講,我介紹了什麼是線程安全,對比和分析了 synchronized 和 ReentrantLock,並針對條件變量等方面結合案例代碼進行了介紹。下一講,我將對鎖的進階內容進行源碼和案例分析。

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