Java線程-Lock學習(五)

一、前言

??上一篇線程文章中,我們了解了通過synchronized關鍵字來實現線程同步,而本篇文章,我們來學習下Lock相關的內容。從Java 5之后,在java.util.concurrent.locks包下提供了另外一種方式來實現同步訪問,那就是Lock。

1 為什么要有Lock?
  1. 前面我們知道,使用synchronized關鍵字能夠實現線程同步的功能,如果一個線程獲取了對應的鎖,那么相關線程便只能一直等待,直到獲取線程鎖的線程釋放掉鎖,而如果獲取線程鎖的線程由于IO或者其他各種原因阻塞了,那其他線程便只能等著了,毫無疑問,這將會極大的影響程序的執行效率。
  2. 再舉個例子:當多個線程讀取同一個文件時,正常來說,讀操作和寫操作會發生沖突現象,寫操作和寫操作會發生沖突現象,但是讀操作和讀操作不會發生沖突現象。如果使用synchronized來處理的話,那么當多個線程都只是進行讀操作的話,就會出現問題,根據synchronized的特性,同一個時間段內,只能有一個線程進行讀取,其他線程將會進入等待中。

??而上面這兩個問題,都可以通過Lock來解決,Lock還可以知道線程有沒有成功獲取到鎖,也就是Lock提供了比synchronized更多的功能。

二. Lock體系

1. Lock接口

??首先,Lock不是Java的關鍵字,Lock是一個接口,Lock和其相關的實現構成了Lock體系,并且Lock體系是Java 5.0 才引入的。我們先來看一下Lock接口中的方法列表,由于Lock中的每個方法都很重要,所以我們一會再單獨介紹下每個方法:

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

這里先說下,Lock鎖的釋放不同于synchronized,synchronized方法或代碼塊執行完成之后或發生異常之后,JVM會自動讓線程釋放鎖,而Lock則必須需要用戶去手動釋放鎖,如果沒有主動釋放鎖,則可能會導致死鎖現象,這點需要注意。

下面我們來挨個看下這些方法的用途:

1.1 lock方法

??lock方法用來獲取鎖,如果鎖被其他線程獲取,則該線程進行等待;由于Lock必須要手動去釋放鎖,所以使用Lock的通常做法是在try{} catch{} finally中進行,然后將鎖的釋放放到finally塊中執行:

Lock lock = ...;
lock.lock();
try{
    //處理任務
}catch(Exception e){

}finally{
    lock.unlock();   //釋放鎖
}
1.2 tryLock方法
  1. tryLock方法也是用于獲取鎖,該方法有返回值,返回值為true,說明獲取鎖成功,返回false則表示獲取鎖失敗(比如鎖被其他線程占用中),該方法無論如何都會立刻返回,在拿不到鎖時不會一直等待;
  2. tryLock(long time, TimeUnit unit)方法和tryLock方法類似,區別在于這個方法在拿不到鎖時會等待一定的時間,在時間期限之內如果還拿不到鎖,就返回false,而如果一開始拿到鎖或者在等待期間內拿到了鎖,則返回true;所以,一般通過tryLock來獲取鎖是這樣的:
  3. 有的時候tryLock鎖的這種獲取方式也稱為可定時,可輪詢的,與lock方法相比,它具有更完善的錯誤恢復機制,可以有效避免死鎖的發生。
Lock lock = ...;
if(lock.tryLock()) {
    try{
        //處理任務
    }catch(Exception ex){

    }finally{
        lock.unlock();   //釋放鎖
    }
}else {
    //如果不能獲取鎖,則直接做其他事情
}
1.3 lockInterruptibly方法

??這個方法也是用來獲取鎖的,不過這個方法有些特殊。當通過這個方法去獲取鎖時,如果沒有獲取到,線程進入等待狀態,但該線程能夠響應中斷,即中斷線程的等待狀態。也就使說,當兩個線程同時通過lock.lockInterruptibly()想獲取某個鎖時,假若此時線程A獲取到了鎖,而線程B將進入等待,那么對線程B調用threadB.interrupt()方法能夠中斷線程B的等待過程。

另外需要注意下:

  1. 該方法的操作略微復雜些,由于該方法會拋出InterruptedException異常,所以需要兩個try塊(而如果在操作的過程中向上拋出了InterruptedException,則使用一個標準的try-finally即可);
  2. 當一個線程已經獲取到了鎖之后,是不會被interrupt方法中斷的,這個前面學習的時候已經講過。
  3. 因此當通過lockInterruptibly()方法獲取某個鎖時,如果不能獲取到,只有進行等待的情況下,是可以響應中斷的。而用synchronized修飾的話,當一個線程處于等待某個鎖的狀態,是無法被中斷的,只有一直等待下去;
private static void test() throws InterruptedException {
    lock.lockInterruptibly();
    try {
        // 執行邏輯
    } finally {
        lock.unlock();
    }
}

這里再說下,定時的tryLock方法同樣能響應中斷。所以如果需要一個定時的和可中斷的獲取操作時,可以使用tryLock方法。

1.4 unlock方法

??這個方法就不多說了,用于釋放鎖,由于Lock必須要手動釋放鎖,所以調用該方法的時候,最好放到finally塊中,這樣無論程序是否正常執行結束都將釋放鎖。

1.5 newCondition方法

該方法用于創建一個Condition對象,至于Condition對象,后面會學習到,這里就暫且不介紹了。

2. ReentrantLock類

??接下來,我們來看下Lock的實現類:ReentrantLock,意思是可重入鎖。該類在實現了Lock接口中的方法之外,還額外提供了一些方法,我們先通過一些例子來了解一下方法的使用。

2.1 lock方法測試
public class ThreadTest  {
    public static void main(String[] args) {
        new Thread(() -> test(Thread.currentThread())).start();
        new Thread(() -> test(Thread.currentThread())).start();
    }
    private static void test(Thread thread) {
        Lock lock = new ReentrantLock();
        lock.lock();
        try {
            System.out.println("線程" + thread.getName() + "得到鎖");
        } finally {
            System.out.println("線程"+ thread.getName() + "釋放鎖");
            lock.unlock();
        }
    }
}

首先,我們先看下上面的簡單例子,打印結果(每次調用可能不同):

線程Thread-0得到鎖
線程Thread-1得到鎖
線程Thread-1釋放鎖
線程Thread-0釋放鎖

可以看到,結果似乎和我們預想的不太一樣,兩個線程都得到了鎖,這是由于lock變量的問題,因為lock是一個局部變量,所以兩個線程都new 了一個lock,lock對象不同,那么自然通過lock得到的鎖也是不同的鎖,也不會發生沖突啥的。我們稍作改變:

public class ThreadTest  {
    private static Lock lock = new ReentrantLock();
    public static void main(String[] args) {
        new Thread(() -> test(Thread.currentThread())).start();
        new Thread(() -> test(Thread.currentThread())).start();
    }
    private static void test(Thread thread) {
        lock.lock();
        try {
            System.out.println("線程" + thread.getName() + "得到鎖");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println("線程"+ thread.getName() + "釋放鎖");
            lock.unlock();
        }
    }
}

這次我們可以debug調試下看下結果。

2.2 tryLock方法測試

我們對上面的test方法稍作調整,來簡單看下tryLock的使用:

private static void test(Thread thread) {
    if (lock.tryLock()) {
        try {
            System.out.println("線程" + thread.getName() + "得到鎖");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println("線程"+ thread.getName() + "釋放鎖");
            lock.unlock();
        }
    } else {
        System.out.println("線程" + thread.getName() + "沒有獲取到鎖,立即返回!");
    }
}

打印結果:

線程Thread-0得到鎖
線程Thread-1沒有獲取到鎖,立即返回!
線程Thread-0釋放鎖

其他相關方法就不多說了,我們來簡單看下ReentrantLock的公平鎖屬性。

2.3 ReentrantLock的公平性

我們來看下ReentrantLock的源碼:

private final Sync sync;

static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;

    /**
     * Performs lock.  Try immediate barge, backing up to normal
     * acquire on failure.
     */
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
}

static final class FairSync extends Sync {
    private static final long serialVersionUID = -3000897897090466540L;

    final void lock() {
        acquire(1);
    }
}

從源碼中我們可以看到,ReentrantLock定義了兩個靜態內部類,NonfairSync和FairSync,分別用來實現非公平鎖及公平鎖,另外,ReentrantLock定義了一個sync屬性,用來表示該Lock的公平性,可以通過isFair方法來判斷該Lock是否是公平鎖:

public final boolean isFair() {
    return sync instanceof FairSync;
}

而要指定Lock是否是公平鎖,我們可以通過其中的一個構造方法來實現:

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

public ReentrantLock() {
    sync = new NonfairSync();
}

而默認的構造方法,構造的是非公平鎖。另外,ReentrantLock還有一些方法:

isFair()                  //判斷鎖是否是公平鎖
isLocked()                //判斷鎖是否被任何線程獲取了
isHeldByCurrentThread()   //判斷鎖是否被當前線程獲取了
hasQueuedThreads()        //判斷是否有線程在等待該鎖

另外還有一些方法是用于監控的方法,這里暫不多說了。

我們來看下java.util.concurrent.locks包中其他需要了解的接口或實現。

3. ReadWriteLock及ReentrantReadWriteLock

??ReadWriteLock 是個接口,表示讀寫鎖,ReentrantReadWriteLock則是該接口的實現。ReadWriteLock接口只有兩個方法,分別是獲取讀鎖和獲取寫鎖,也就是說將文件的讀寫操作分開,分成2個鎖來分配給線程,從而使多個線程可以同時進行讀操作:

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

我們先來看下通過ReadWriteLock實現多個線程同時進行讀操作:

public class ThreadTest {
    private static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    public static void main(String[] args) {
        new Thread(() -> test(Thread.currentThread())).start();
        new Thread(() -> test(Thread.currentThread())).start();
    }

    private static void test(Thread thread) {
        rwl.readLock().lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.println("線程" + thread.getName() + "正在進行讀操作");
            }
            System.out.println("線程" + thread.getName() + "讀操作完畢");
        } finally {
            rwl.readLock().unlock();
        }
    }
}

來看下結果,為了篇幅,省略了一些打印結果:

線程Thread-0正在進行讀操作
線程Thread-0正在進行讀操作
線程Thread-1正在進行讀操作
線程Thread-1正在進行讀操作
線程Thread-0正在進行讀操作
線程Thread-0正在進行讀操作
線程Thread-1讀操作完畢
線程Thread-0正在進行讀操作
線程Thread-0讀操作完畢

可以看到,多個線程同時進行了讀操作。不過需要注意的是:

  1. 除了多個線程可以同時獲取讀鎖之外,其他情況,多個線程都將只有一個線程能獲取到鎖,其他線程則進入等待,比如一個線程已經獲取了讀鎖,另一個線程想要獲取寫鎖,則會進入等待,直到讀鎖釋放。
  2. ReentrantReadWriteLock也支持公平性,可以設置該鎖是公平鎖或者非公平鎖。

而讀鎖與寫鎖之間是可以有多種交互方式的,比如:

  1. 釋放優先:當一個寫鎖釋放時,并且隊列中同時存在讀線程和寫線程,那么應該優先選擇讀線程還是寫線程,還是最先發出請求的線程?
  2. 讀線程插隊:如果鎖是由讀線程持有,但有寫線程正在等待,那么新的讀線程能否立即獲得訪問權,還是應該在寫線程后面等待?如果運行讀線程插隊到寫線程之前,那么有可能造成寫線程發生饑餓問題;
  3. 重入性:讀鎖與寫鎖是否是可重入的?
  4. 降級:如果一個線程持有寫鎖,那么它能否在不釋放該鎖的情況下獲得讀鎖?這就可能使寫鎖被降級為讀鎖,同時不允許其他寫線程修改資源;
  5. 升級:讀鎖能否優于其他正在等待的讀線程和寫線程而升級為一個寫鎖?大多數的讀寫鎖都不支持升級,因為如果沒有顯示的升級操作,那么很容易造成死鎖(比如兩個讀線程試圖同時升級為寫鎖,那么二者都不會釋放讀鎖)。
4. TimeUnit類

這里我們再來簡單說下TimeUnit類。首先,這是個枚舉類,也是java.util.concurrent包下的,該類一般有兩個作用:

  1. 作為時間的一個粒度,比如我們tryLock的第二個參數就是該類型,表示等待的時間單位是小時,分鐘亦或是其他類型的;
  2. 用于線程休眠,可用來代替Thread.sleep,相比Thread.sleep來說,可讀性更好,因為Thread.sleep的單位是毫秒,而該類的sleep方法則可以指定具體的時間單位;
4.1 枚舉元素及方法簡介

先看一下枚舉的元素:

TimeUnit.NANOSECONDS        時間單位是納秒
TimeUnit.MICROSECONDS       時間單位是微秒
TimeUnit.MILLISECONDS       時間單位是毫秒
TimeUnit.SECONDS            時間單位是秒
TimeUnit.MINUTES            時間單位是分鐘
TimeUnit.HOURS              時間單位是小時
TimeUnit.DAYS               時間單位是天

再來簡單看一下轉換方法:

public long toNanos(long d)               
public long toMicros(long d)              
public long toMillis(long d)  
public long toSeconds(long d) 
public long toMinutes(long d) 
public long toHours(long d)   
public long toDays(long d)    
public long convert(long d, TimeUnit u) 

這些方法見名知義,就不多說了,再來看一下使用方式:

//將天轉換為小時
//output: 24
System.out.println(TimeUnit.DAYS.toHours(1));
//將小時轉換為秒
//output: 3600
System.out.println(TimeUnit.HOURS.toSeconds(1));
// 休眠5秒
Thread.sleep(5 * 1000);
TimeUnit.SECONDS.sleep( 5 );

// 休眠5分鐘
Thread.sleep(5 * 60 * 1000);
TimeUnit.MINUTES.sleep(5);
4.2 TimeUnit的sleep方法

??查看下TimeUnit的sleep方法源碼可以知道,該方法底層實際是通過Thread.sleep方法來實現的,最終將單位都轉換為了毫秒:

public void sleep(long timeout) throws InterruptedException {
    if (timeout > 0) {
        long ms = toMillis(timeout);
        int ns = excessNanos(timeout, ms);
        Thread.sleep(ms, ns);
    }
}

所以,如果使用到Thread.sleep的時候,我們可以優先使用TimeUnit的sleep方法。

4.3 timedJoin 和 timedWait方法

另外,TimeUnit中還有兩個public方法,我們來簡單了解下:

public void timedWait(Object obj, long timeout)
        throws InterruptedException {
    if (timeout > 0) {
        long ms = toMillis(timeout);
        int ns = excessNanos(timeout, ms);
        obj.wait(ms, ns);
    }
}

public void timedJoin(Thread thread, long timeout)
        throws InterruptedException {
    if (timeout > 0) {
        long ms = toMillis(timeout);
        int ns = excessNanos(timeout, ms);
        thread.join(ms, ns);
    }
}

看源碼就可以看出,timeWait是對Object.wait方法的一個封裝,timedJoin則是對Thread.join方法的一個封裝,因為這兩個方法都支持類似于定時的操作,所以和sleep類似,封裝了兩個方法,同樣,都可以指定相應的時間單位:

TimeUnit.MINUTES.timedJoin(thread, 5);

synchronized (lock) {
    TimeUnit.MINUTES.timedWait(lock, 5);
}

三、ReentrantLock源碼

1. ReentrantLock的公平鎖的源碼

??ReentrantLock 在Java中是一個普通的類,實現方式是基于AQS(AbstractQueuedSynchronizer)來實現的,而AQS是 Java 并發包里實現鎖、同步的一個重要的基礎框架。接下來我們來簡單看下ReentrantLock中有關公平鎖和非公平鎖的實現源碼:

private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {...}

首先,該類中有一個基礎的鎖對象Sync,并且該鎖有兩個實現類,公平鎖(FairSync)和非公平鎖(NonfairSync),我們首先來看下公平鎖的實現:

final void lock() {
    acquire(1);
}
        
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

在公平鎖的實現lock方法中,會先調用基礎類AbstractQueuedSynchronizer的acquire方法,然后該方法中會調用嘗試獲取鎖的方法tryAcquire:

  1. 這里第一步是先判斷同步狀態state的值是否是0,0表示目前沒有其他線程獲取鎖,當前線程可以嘗試獲取到該鎖;
  2. 而當前線程嘗試之前,會先通過 hasQueuedPredecessors 該方法來判斷AQS的隊列中是否有其他線程,如果有則不會嘗試獲取鎖,因為這時公平鎖的情況;
  3. 而如果隊列中沒有線程,就通過CAS方式將AQS的同步狀態state設置為1,也就是獲取鎖成功,然后通過setExclusiveOwnerThread 將當前線程置為獲得鎖的獨占線程;
  4. 而如果c不等于0,其實也就是大于0,說明鎖已經獲取成功了,然后判斷獲取鎖的線程是否是當前線程(getExclusiveOwnerThread),如果是當前線程,則將state 更新為state + 1(因為ReentrantLock支持可重入);

而如果通過 tryAcquire方法獲取鎖失敗,則先調用addWaiter將當前線程寫入隊列中,而寫入之前需要將當前線程封裝為一個Node對象,因為AQS 中的隊列是由 Node 節點組成的雙向鏈表實現的:

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

寫入隊列之后,然后通過acquireQueued方法將當前線程掛起(最終是通過LockSupport的park方法),進入等待狀態,等著被喚醒:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

??這里有一個循環自旋的操作,在掛起線程之前,先判斷該線程前面的節點是否是head節點,如果是,再次去嘗試獲取鎖,獲取成功后,將該節點設置為head節點,然后返回;如果上述條件不成立,調用shouldParkAfterFailedAcquire方法判斷是否應該把當前線程掛起,主要是通過節點的waitStatus字段來判斷,如果需要,調用LockSupport的part方法掛起線程。

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}
2. ReentrantLock的非公平鎖的源碼

非公平鎖(NonfairSync)與公平鎖的區別主要在于獲取鎖,公平鎖是一種順序的方式,而非公平鎖則是一種搶占式的方式,來看下源碼:

final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

直接通過CAS來嘗試獲取鎖,如果獲取鎖成功,然后將當前線程設置為獲得鎖的獨占線程。如果獲取不成功,同樣會調用acquire方法,然后會調用tryAcquire方法再次嘗試獲取鎖:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

不過這里不需要判斷隊列中是否有其他線程,也就是沒有hasQueuedPredecessors方法,而是直接嘗試獲取鎖。最后,釋放鎖,公平鎖和非公平鎖的方式是一致的,主要就是減少state的值:

public void unlock() {
    sync.release(1);
}

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // 喚醒被掛起的線程
            unparkSuccessor(h);
        return true;
    }
    return false;
}

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

釋放鎖的時候,先判斷當前線程是否是獲得鎖的線程,由于是重入鎖,所以需要將state的值減到0才認為完全釋放鎖,釋放鎖之后,通過unparkSuccessor方法來喚醒被掛起的線程。

3. 源碼總結
  1. ReentrantLock是通過AQS(AbstractQueuedSynchronizer)來實現的,AQS是Java并發包里一個重要的基礎框架,可以實現各種形式的鎖,這里只用到了獨占鎖;
  2. 在線程阻塞的時候,AQS本身會有自旋的操作,并非是獲取不到鎖就直接阻塞;
  3. 公平鎖的實現是底層維護了一個基于雙向鏈表的隊列結構;

源碼參考了:ReentrantLock 實現原理

四、總結

1. 使用synchronized還是ReentrantLock?

《Java并發編程實戰》的作者在書中寫道:

  1. 在synchronized無法滿足需求的情況下,ReentrantLock可以作為一種高級工具。當需要一些高級功能時才應該使用ReentrantLock,這些功能包括:可定時,可輪詢與可中斷的獲取鎖操作,公平隊列以及非塊結構的鎖。否則,還是應該優先使用synchronized;
  2. 未來更可能會一直提升synchronized而不是ReentrantLock的性能,因為synchronized是JVM的內置屬性,它可以執行一些優化,比如線程封閉的鎖對象消除優化等等。即使是性能方面,synchronized也不會遜色于ReentrantLock太多。
  3. 如果有其他方式可以實現,最好既不使用Lock/Condition,也不使用synchronized內部鎖,很多情況下,我們可以使用java.util.concurrent包中的一些操作,這些后續我們再說。

本文參考自:
《Java并發編程實戰》
《Java核心技術 卷I》
海子-Java并發編程:Lock

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容