JDK1.8 鎖的實現與原理(一) 自己實現一個鎖

Java中的鎖機制

在學習Java的併發編程中, 鎖機制是一個重點和難點, 在Java中併發常用到的鎖相關的主要是synchronized關鍵字和java.util.concurrent.locks類, 兩者的區別包括

  • synchronized是關鍵字, 依賴JVM實現鎖機制, Lock是JDK中的一個接口, 其實現最典型的是ReentrantLock
  • synchronized的實現是編譯期加入了管程的機制, ReentrantLock的實現是依賴底層的AbstractQueuedSynchronizer(AQS), 而AQS又使用到了Java中的CAS機制.
  • synchronized不需要手動釋放, 在臨界區的代碼出現異常時, 也能夠正確的進行自動解鎖. Lock的實現類需要手動上鎖和解鎖, 爲了保證代碼出現異常時能夠釋放鎖, 需要將代碼段包在try-finally語句中
  • Lock實現類比synchonized更加靈活, 支持tryLock方法能夠設置超時時間, 也可以控制被阻塞的線程是否能夠相應中斷, synchonized不行
  • Lock的實現類中讀寫鎖可以提高讀操作效率, 實現類也能夠知道是否獲得了鎖
  • 在競爭不激烈時, 兩者相差不大, 競爭激烈的情況下, Lock性能遠優於synchonized
  • synchronized是可重入鎖
  • synchronized不可中斷, Lock可中斷

說了怎麼多, 不如動手寫點代碼, 從代碼中瞭解併發編程中應該如何用鎖, 以及一個鎖大致是怎麼實現.

一個併發操作的Demo

import java.util.LinkedList;
import java.util.List;

public class TestConcurrent {
    public static void main(String[] args) throws InterruptedException {
        Share share = new Share();
        List<Thread> tList = new LinkedList<>();
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(
              ()-> {
                  for (int j = 0; j < 10000; j++)
                    share.incr();
              }  
            );
            tList.add(t);
            t.start();
        }
        for (Thread t: tList) {
            t.join();
        }
        System.out.println("i in share is " + share.i);
    }
}

class Share {
    int i = 0;
    public void incr() {
        i++;
    }
}

// output: i in share is 39980

這樣的結果顯然是錯的, 這是由於i++不是一個原子操作, 多個線程併發對i進行操作時, 交錯進行, 一個線程從堆中拷貝了一個副本到虛擬機棧操作, 在完成操作前, 其他線程修改了對象的成員變量, 而該線程在操作完後把以舊值爲基礎的計算結果直接覆蓋到主存中, 造成了加少了的現象.

用atomic實現併發控制(無阻塞)

首先想到的是把i++變成一個原子操作. Java中已經提供了這樣的一系列類位於Java.util.concurrent.atomic包中. 於是我們可以把以上的代碼修改爲

/*
TestConcurrent類代碼不變
*/
class Share {
    AtomicInteger i = new AtomicInteger();
    public void incr() {
        i.incrementAndGet();
    }
}

AtomicInteger類使用unsafe類中實現的基於CAS的操作保證了對變量操作的原子性.

要講清楚Java中的CAS無鎖機制, 又必須講一講JMM(JVM的內存模型), 感興趣的參考文章末尾的簡述CAS無鎖機制

CAS機制實現的, 大體上就是一個自旋鎖(忙等)的功能, 當競爭不激烈, 臨界區小的時候, 上下文切換的開銷大於忙等開銷, 此時選擇自旋鎖更好, 但是當競爭激烈的時候, CAS機制很難通過, 會導致效率降低, 這時基於阻塞的鎖更好.

用lock實現併發控制

class Share {
    int i;
    Lock lock = new ReentrantLock();
    public void incr() {
        lock.lock();
        try { // 當發生異常時, 能夠釋放鎖, 否則會導致死鎖.
            i++;
        }
        finally {
            lock.unlock();
        }
    }
}

lock的實現也不難, 主要就是注意要用try-finally語句把臨界區給包起來.ReentrantLock是JDK實現的可重入鎖類. 可重入是指獲得鎖的線程可以再次進入臨界區(例如遞歸情形), 此時只增加可重入鎖中的計數.

鎖主要做的工作就是兩個部分, 一是判斷自己是否能夠被某個線程獲得, 二是當線程請求獲得鎖而得不到時, 將這個線程掛到自己(鎖即是資源)的等待隊列中. 所以鎖內部還需要一個線程安全的隊列, 用來記錄掛在自己這裏的線程.

自己寫一個實現Lock接口的類

有了以上對鎖的功能的分析, 我們自己可以做一個簡單鎖實現.

public class MyLock implements Lock {
    AtomicReference<Thread> owner = new AtomicReference<>(); // 線程擁有者
    // 等待鎖的線程隊列 選擇blockingQueue是因爲其是線程安全 
    // 選擇linked是因爲沒有隨機訪問但是有頻繁的增刪
    BlockingQueue<Thread> waiter = new LinkedBlockingDeque<>(); // 阻塞線程
    @Override
    public void lock() {
        while (!owner.compareAndSet(null, Thread.currentThread())) { 
            // 搶不到鎖的情況 -- 放入等待列表 -- 阻塞
            waiter.add(Thread.currentThread()); // 放入等待列表
            LockSupport.park(); // 靜態方法 讓當前線程自己阻塞自己
            waiter.remove(Thread.currentThread()); // 不會讓隊列不斷增加, 造成內存泄露
            // 爲什麼remove是寫在這裏? 很有意思, 當線程被喚醒時, 是從這一行開始執行的, 所以它退出了阻塞隊列, 這是併發編程中比較難理解的邏輯
        }
    }

    @Override
    public void unlock() {
        // 持有鎖的線程能夠成功
        if (owner.compareAndSet(Thread.currentThread(), null)) { // 爲什麼是if 因爲不存在競爭
            // 喚醒其他等待線程
            for (Object object : waiter) {
                Thread next = (Thread) object;
                LockSupport.unpark(next);
            }
        }
        /*
        還有其他需要實現的方法都可以不用實現 用默認值, 暫時用不到
        */
    }

各種鎖的實現, 大體都是按照上述流程實現的, 但是具體的細節有一些差異, 然後是鎖的一些概念.

  • 公平鎖:表示線程獲取鎖的順序是按照加鎖的順序來分配的,及先來先得,先進先出的順序。(喚醒阻塞隊列頭的線程)

  • 非公平鎖:表示獲取鎖的搶佔機制,是隨機獲取鎖的,和公平鎖不一樣的就是先來的不一定能拿到鎖(實現上喚醒所有被阻塞的線程)

  • 讀寫鎖, 讀寫互斥, 寫寫互斥, 用readLockwriteLock兩個鎖實現更好的讀效率

  • 鎖降級: 鎖降級指的是寫鎖降級成爲讀鎖。如果當前線程擁有寫鎖,然後將其釋放,最後再獲取讀鎖,這種分段完成的過程不能稱之爲鎖降級。鎖降級是指把持住(當前擁有的)寫鎖,再獲取到讀鎖,隨後釋放(先前擁有的)寫鎖的過程。

    雖然我們手寫了一個Lock, 但是這個類還有很多問題, 其中很明顯的bug就是LinkedBlockingDeque自身就是用ReentrantLock實現的, 雖然我們這裏沒有用到其真正的阻塞功能, 但是鎖實現裏面用到了已經實現的鎖怎麼也有點說不過去的. 因此下一步我們想要一探, 一個真正的鎖實現類ReentrantLock是怎麼解決線程安全的阻塞隊列這個問題的, 這裏就涉及到一個很關鍵的抽象類AQS.

簡述CAS無鎖機制

講CAS之前必須提到的是JVM的內存模型

JVM的內存結構和內存模型說的不是一個東西, 內存結構包括運行時數據區, 方法區, 堆, 虛擬機棧那些內容, 而內存模型和併發緊密相連, 主要是主內存, 工作內存等之間的關係, 以及Java線程的通信實現機制


上圖描述了JVM如何控制線程和內存打交道的, 線程只能從主內存中先拷貝一份數據到自己的工作內存, 然後寫自己的工作內存, 線程之間的通信也只能通過主內存而不能直接訪問別的線程的工作內存.

CAS做的事情就是在線程從主內存拉了一個副本過來, 完成計算, 在寫回的時候, 先比較下拉下來的副本原始值和主內存中還是否一樣, 如果一樣則通過一個原子操作把數據寫過去, 如果不一樣說明自己的副本已經過時了, 在一個while循環中重新從拉副本開始執行.

參考資料:
淺談偏向鎖、輕量級鎖、重量級鎖
synchronized 是可重入鎖嗎?爲什麼?
java的讀寫鎖中鎖降級的問題
CAS無鎖機制必須瞭解的JVM內存模型
面試必備之深入理解自旋鎖
Java doc Class ReentrantLock

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