慕課網實戰·高併發探索(十二):併發容器J.U.C -- AQS組件 鎖:ReentrantLock、ReentrantReadWriteLock、StempedLock

特別感謝:慕課網jimin老師的《Java併發編程與高併發解決方案》課程,以下知識點多數來自老師的課程內容。
jimin老師課程地址:Java併發編程與高併發解決方案


ReentrantLock

java中有兩類鎖,一類是Synchronized,而另一類就是J.U.C中提供的鎖。ReentrantLock與Synchronized都是可重入鎖,本質上都是lock與unlock的操作。接下來我們介紹三種J.U.C中的鎖,其中 ReentrantLock使用synchronized與之比對介紹。

ReentrantLock與synchronized的區別
  • 可重入性:兩者的鎖都是可重入的,差別不大,有線程進入鎖,計數器自增1,等下降爲0時纔可以釋放鎖
  • 鎖的實現:synchronized是基於JVM實現的(用戶很難見到,無法瞭解其實現),ReentrantLock是JDK實現的。
  • 性能區別:在最初的時候,二者的性能差別差很多,當synchronized引入了偏向鎖、輕量級鎖(自選鎖)後,二者的性能差別不大,官方推薦synchronized(寫法更容易、在優化時其實是借用了ReentrantLock的CAS技術,試圖在用戶態就把問題解決,避免進入內核態造成線程阻塞)
  • 功能區別:
    (1)便利性:synchronized更便利,它是由編譯器保證加鎖與釋放。ReentrantLock是需要手動釋放鎖,所以爲了避免忘記手工釋放鎖造成死鎖,所以最好在finally中聲明釋放鎖。
    (2)鎖的細粒度和靈活度,ReentrantLock優於synchronized
ReentrantLock獨有的功能
  • 可以指定是公平鎖還是非公平鎖,sync只能是非公平鎖。(所謂公平鎖就是先等待的線程先獲得鎖)
  • 提供了一個Condition類,可以分組喚醒需要喚醒的線程。不像是synchronized要麼隨機喚醒一個線程,要麼全部喚醒。
  • 提供能夠中斷等待鎖的線程的機制,通過lock.lockInterruptibly()實現,這種機制 ReentrantLock是一種自選鎖,通過循環調用CAS操作來實現加鎖。性能比較好的原因是避免了進入內核態的阻塞狀態。
要放棄synchronized?

從上邊的介紹,看上去ReentrantLock不僅擁有synchronized的所有功能,而且有一些功能synchronized無法實現的特性。性能方面,ReentrantLock也不比synchronized差,那麼到底我們要不要放棄使用synchronized呢?答案是不要這樣做。

J.U.C包中的鎖定類是用於高級情況和高級用戶的工具,除非說你對Lock的高級特性有特別清楚的瞭解以及有明確的需要,或這有明確的證據表明同步已經成爲可伸縮性的瓶頸的時候,否則我們還是繼續使用synchronized。相比較這些高級的鎖定類,synchronized還是有一些優勢的,比如synchronized不可能忘記釋放鎖。還有當JVM使用synchronized管理鎖定請求和釋放時,JVM在生成線程轉儲時能夠包括鎖定信息,這些信息對調試非常有價值,它們可以標識死鎖以及其他異常行爲的來源。

如何使用ReentrantLock?
//創建鎖:使用Lock對象聲明,使用ReentrantLock接口創建
private final static Lock lock = new ReentrantLock();
//使用鎖:在需要被加鎖的方法中使用
private static void add() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

分析一下源碼:

//初始化方面:
//在new ReentrantLock的時候默認給了一個不公平鎖
public ReentrantLock() {
    sync = new NonfairSync();
}
//也可以加參數來初始化指定使用公平鎖還是不公平鎖
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
內置函數(部分)

基礎特性:

  • tryLock():僅在調用時鎖定未被另一個線程保持的情況下才獲取鎖定。
  • tryLock(long timeout, TimeUnit unit):如果鎖定在給定的時間內沒有被另一個線程保持且當前線程沒有被中斷,則獲取這個鎖定。
  • lockInterruptbily:如果當前線程沒有被中斷的話,那麼就獲取鎖定。如果中斷了就拋出異常。
  • isLocked:查詢此鎖定是否由任意線程保持
  • isHeldByCurrentThread:查詢當前線程是否保持鎖定狀態。
  • isFair:判斷是不是公平鎖

Condition相關特性:

  • hasQueuedThread(Thread):查詢指定線程是否在等待獲取此鎖定
  • hasQueuedThreads():查詢是否有線程在等待獲取此鎖定
  • getHoldCount():查詢當前線程保持鎖定的個數,也就是調用Lock方法的個數
Condition的使用

Condition可以非常靈活的操作線程的喚醒,下面是一個線程等待與喚醒的例子,其中用1234序號標出了日誌輸出順序

public static void main(String[] args) {
    ReentrantLock reentrantLock = new ReentrantLock();
    Condition condition = reentrantLock.newCondition();//創建condition
    //線程1
    new Thread(() -> {
        try {
            reentrantLock.lock();
            log.info("wait signal"); // 1
            condition.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("get signal"); // 4
        reentrantLock.unlock();
    }).start();
    //線程2
    new Thread(() -> {
        reentrantLock.lock();
        log.info("get lock"); // 2
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        condition.signalAll();//發送信號
        log.info("send signal"); // 3
        reentrantLock.unlock();
    }).start();
}

(這裏對等待隊列不熟悉的,請回顧我的上一篇文章中講解的AQS等待隊列:高併發探索(十一):併發容器J.U.C – AQS組件CountDownLatch、Semaphore、CyclicBarrier
輸出過程講解:

1、線程1調用了reentrantLock.lock(),線程進入AQS等待隊列,輸出1號log
2、接着調用了awiat方法,線程從AQS隊列中移除,鎖釋放,直接加入condition的等待隊列中
3、線程2因爲線程1釋放了鎖,拿到了鎖,輸出2號log
4、線程2執行condition.signalAll()發送信號,輸出3號log
5、condition隊列中線程1的節點接收到信號,從condition隊列中拿出來放入到了AQS的等待隊列,這時線程1並沒有被喚醒。
6、線程2調用unlock釋放鎖,因爲AQS隊列中只有線程1,因此AQS釋放鎖按照從頭到尾的順序,喚醒線程1
7、線程1繼續執行,輸出4號log,並進行unlock操作。

讀寫鎖:ReentrantReadWriteLock讀寫鎖

在沒有任何讀寫鎖的時候纔可以取得寫入鎖(悲觀讀取,容易寫線程飢餓),也就是說如果一直存在讀操作,那麼寫鎖一直在等待沒有讀的情況出現,這樣我的寫鎖就永遠也獲取不到,就會造成等待獲取寫鎖的線程飢餓。
平時使用的場景並不多。

public class LockExample3 {

    private final Map<String, Data> map = new TreeMap<>();
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();//讀鎖
    private final Lock writeLock = lock.writeLock();//寫鎖

    //加讀鎖
    public Data get(String key) {
        readLock.lock();
        try {
            return map.get(key);
        } finally {
            readLock.unlock();
        }
    }
    //加寫鎖
    public Data put(String key, Data value) {
        writeLock.lock();
        try {
            return map.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }

    class Data {}
}

票據鎖:StempedLock

它控制鎖有三種模式(寫、讀、樂觀讀)。一個StempedLock的狀態是由版本和模式兩個部分組成。鎖獲取方法返回一個數字作爲票據(stamp),他用相應的鎖狀態表示並控制相關的訪問。數字0表示沒有寫鎖被鎖寫訪問,在讀鎖上分爲悲觀鎖和樂觀鎖。

樂觀讀:
如果讀的操作很多寫的很少,我們可以樂觀的認爲讀的操作與寫的操作同時發生的情況很少,因此不悲觀的使用完全的讀取鎖定。程序可以查看讀取資料之後是否遭到寫入資料的變更,再採取之後的措施。

如何使用?

//定義
private final static StampedLock lock = new StampedLock();
//需要上鎖的方法
private static void add() {
    long stamp = lock.writeLock();
    try {
        count++;
    } finally {
        lock.unlock(stamp);
    }
}

分析一下源碼:

class Point {
        private double x, y;
        private final StampedLock sl = new StampedLock();

        void move(double deltaX, double deltaY) {
            long stamp = sl.writeLock();
            try {
                x += deltaX;
                y += deltaY;
            } finally {
                sl.unlockWrite(stamp);
            }
        }

        //下面看看樂觀讀鎖案例
        double distanceFromOrigin() { // A read-only method
            long stamp = sl.tryOptimisticRead(); //獲得一個樂觀讀鎖
            double currentX = x, currentY = y;  //將兩個字段讀入本地局部變量
            if (!sl.validate(stamp)) { //檢查發出樂觀讀鎖後同時是否有其他寫鎖發生?
                stamp = sl.readLock();  //如果沒有,我們再次獲得一個讀悲觀鎖
                try {
                    currentX = x; // 將兩個字段讀入本地局部變量
                    currentY = y; // 將兩個字段讀入本地局部變量
                } finally {
                    sl.unlockRead(stamp);
                }
            }
            return Math.sqrt(currentX * currentX + currentY * currentY);
        }

        //下面是悲觀讀鎖案例
        void moveIfAtOrigin(double newX, double newY) { // upgrade
            // Could instead start with optimistic, not read mode
            long stamp = sl.readLock();
            try {
                while (x == 0.0 && y == 0.0) { //循環,檢查當前狀態是否符合
                    long ws = sl.tryConvertToWriteLock(stamp); //將讀鎖轉爲寫鎖
                    if (ws != 0L) { //這是確認轉爲寫鎖是否成功
                        stamp = ws; //如果成功 替換票據
                        x = newX; //進行狀態改變
                        y = newY;  //進行狀態改變
                        break;
                    } else { //如果不能成功轉換爲寫鎖
                        sl.unlockRead(stamp);  //我們顯式釋放讀鎖
                        stamp = sl.writeLock();  //顯式直接進行寫鎖 然後再通過循環再試
                    }
                }
            } finally {
                sl.unlock(stamp); //釋放讀鎖或寫鎖
            }
        }
    }

如何選擇鎖?

1、當只有少量競爭者,使用synchronized
2、競爭者不少但是線程增長的趨勢是能預估的,使用ReetrantLock
3、synchronized不會造成死鎖,jvm會自動釋放死鎖。

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