1. 程式人生 > >慕課網實戰·高併發探索(十二):併發容器J.U.C -- AQS元件 鎖:ReentrantLock、ReentrantReadWriteLock、StempedLock

慕課網實戰·高併發探索(十二):併發容器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();
}

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會自動釋放死鎖。