Java并发编程-浅谈ReentranLock

502 阅读6分钟

1. 产生背景

在Java中已经有内置锁synchronized的情况下,为什么还需要引入ReentranLock呢?

synchronized是基于 JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

基于这样一个背景,Doug Lea在JDK1.5中提供了API层面的互斥锁ReentranLock,实现了可重入、可中断、公平锁等特性。在synchronized优化之前,synchronized的性能比起ReentranLock还是有差距的。

2. 简单使用

public class ReentrantLockSimple {

    private ReentrantLock lock = new ReentrantLock();

    public void syncIncr() {
        try {
            lock.lock();
            // todo 模拟业务,不释放锁
            Thread.sleep(10_000);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantLockSimple simple = new ReentrantLockSimple();
        // 模拟并发场景
        new Thread(simple::syncIncr).start();
        new Thread(simple::syncIncr).start();
    }
}

3. 源码分析

3.1 ReentrantLock结构

首先,RentrantLock对象结构,有个大致的了解。

public class ReentrantLock implements Lock, java.io.Serializable {
     
    private final Sync sync;
    
    // 同步控制器(AQS)
    abstract static class Sync extends AbstractQueuedSynchronizer {
        ...
    }
    // 非公平锁(实现)
    static final class NonfairSync extends Sync {
        ...
    }
    // 公平锁(实现)
    static final class FairSync extends Sync {
        ...
    }
    
    // 构造器:默认非公平锁
    public ReentrantLock() {
        sync = new NonfairSync();
    }
    
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
}

3.2 加锁 lock.lock()

其实,ReentrantLock中的方法都是由成员对象sync完成的。

所以,我们直接进入 NonfairSync.lock()

final void lock() {
    // CAS设置状态值,如果成功,当前线程设为独占线程,意味着获取到了锁
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

// AbstractQueuedSynchronizer.java
public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
        selfInterrupt();
    }    
}

接下来,分析 acquire()中的三个重点方法:

  1. tryAcquire():尝试获取锁,由子类重写(这里实现了可重入、公平锁特性);
  2. addWaiter():AQS中维护了一个链表,将当前线程包装成一个Node节点,入队;
  3. acquireQueued():以独占不可中断模式获取已经在队列中的线程。

3.2.1 tryAcquire

final boolean nonfairTryAcquire(int acquires) {
    // 当前线程
    final Thread current = Thread.currentThread();
    // 获取当前锁的状态
    int c = getState();
    if (c == 0) { 
        // 公平锁在下面的if中还必须要满足 !hasQueuedPredecessors(),字面意是队列中没有等候者。
        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;
}

当前方法无论是获取到了锁,还是重复加锁,都是返回true。上面的 acquire()就会直接结束,不会继续执行后续的入队操作。

非公平锁的特性就是在这个方法中体现出来的(其实lock方法中也是直接获取锁,更直接)。

3.2.2 addWaiter

注意,当前方法调用时,传入了一个参数:Node.EXCLUSIVE(独占模式)。这个参数实际上是起到了一个标识的作用,有两种类型:独占、共享。

private Node addWaiter(Node mode) {
    // 将当前线程包装成一个Node节点
    Node node = new Node(Thread.currentThread(), mode);
    // 当前Node节点加入到队尾,并与前tail建立引用关系
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        // CAS设置当前Node为队尾
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 死循环,直到入队成功
    enq(node);
    return node;
}

  private Node enq(final Node node) {
    // 循环调用,直至return
    for (;;) {
        Node t = tail;
        // 初始化,创建一个新的Node节点,占位队列中head和tail
        if (t == null) { 
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            // CAS设置队尾
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

addWaiter()的职责很简单,就是将当前线程包装成一个Node节点,然后设置为队尾(必要时初始化队列),通过死循环的形式保证一定入队成功。

下面,大致了解一下Node的结构(AbstractQueuedSynchronizer的内部类)

static final class Node {

    /*
     * 线程的等候状态(初始化为0):
     * CANCELLED(1):表明当前节点已取消;
     * SIGNAL(-1):表明当前节点等待被唤醒unparking;
     * CONDITION(-2):表明当前节点需要达到一定条件;
     * PROPAGATE(-3):传播
     */
    volatile int waitStatus;
    
    // 前后驱节点:标准的链表结构
    volatile Node prev;
    volatile Node next;
    
    // 当前持有线程
    volatile Thread thread;
}

3.2.3 acquireQueued

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);
    }
}

代码执行到这里,说明节点(当前线程)已经成功入队。如果当前节点就是第一位候选者,就会尝试去获取锁。但是锁有可能还没有释放掉(state != 0),获取锁失败,就会阻塞当前线程。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // 注意,这里获取的是前驱节点的等候状态
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * 如果前驱节点的状态是信号通知,意味着当前节点就可以安全的阻塞了
         */
        return true;
    if (ws > 0) {
        /*
         * 如果前驱节点的状态是取消,则不断的向前查找,执行某个前驱节点的状态不大于0
         * 将这个前驱节点与当前节点node建立前后引用,解绑了两者中间所有已经取消的节点
         */
        do {
            /**
             * 下面的一行代码等同于:
             * pred = pred.prev;
             * node.prev = pred;
             */
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /*
         * 代码执行到这里,说明状态值必定是 0 或者 -3(PROPAGATE),表明当前节点是需要被唤醒的
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

相信大家也看到了,这个方法中有段代码的写法太(bi)过(jiao)精(e)简(xin)。

image

其中,红色方块的pred和N1状态都是已取消的(状态值为1)。

首先,执行if判断时,发现pred的等候状态是已取消的,执行do代码块,将pred指向N1节点。执行while判断,发现N1节点的状态也是已取消的,再次执行do代码块,将pred指向N2节点。执行while判断,N2节点状态并不是已取消,循环结束。而在循环结束之前node的前驱指针已经指向了N2,循环结束后再将N2的后驱指针指向node,两者就建立了双向关联。

细心的你一定发现了,在acquireQueued()方法中的死循环上注释了一般只会执行三次,那么为什么说会执行三次呢?

假设现在lock锁已经被某个线程获取了,并且还在执行同步代码块,没有来得及释放锁。这时,线程A也来执行了业务方法,然后尝试获取锁,必然获取失败进而执行入队操作。此时,由当前线程包装的Node节点占据队尾,队头是初始化的一个“空”节点。
参数pred 实际上就是head节点(状态值为0)。第一次循环,执行else逻辑,将前驱节点pred的状态值设置为SIGNAL,注意返回的是false,这将导致当前方法所在if判断直接结束;第二次循环,首个if判断生效,返回true,执行parkAndCheckInterrupt(),阻塞线程;直到线程被唤醒后,开启第三次循环,获取锁成功,直接返回,结束死循环。

注意:以上描述,队列中不存在已取消的节点并且锁不会被争抢,故而说是一般情况下。

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

不得不说,AQS的方法名取的还是很贴切的,基本上见名知意了。阻塞当前线程并返回线程中断状态!

我们先来了解一下 LockSupport.park(this)的功能点:

  • 阻塞当前线程的执行,且不会释放当前线程占有的锁资源;
  • 可以被另一个线程调用 LockSupport.unpark()方法唤醒;
  • 底层调用 Unsafe的native方法。
// java.util.concurrent.locks.LockSupport
public static void park(Object blocker) {
    Thread t = Thread.currentThread();
    setBlocker(t, blocker);
    UNSAFE.park(false, 0L);  //打上断点,debug执行示例代码,10秒钟后线程被唤醒
    setBlocker(t, null);
}

3.2.4 总结

ReentranLock的一套加锁流程总结下来,就是尝试获取锁,获取成功,更新锁状态、设置独占线程;获取失败,将当前线程包装成一个Node节点,加入到AQS内部维护的一个链表的尾部,最后阻塞当前线程,直到被唤醒,再次尝试获取锁(非公平锁,存在被争抢的可能性)。

3.3 解锁 lock.unlock()

加锁时自我阻塞的线程是如何被唤醒的,触发的机制又是怎样的?

接下来,让我们一步步的分析ReentranLock的另一个重要组件。

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

// AbstractQueuedSynchronizer
public final boolean release(int arg) {
    // 尝试释放锁
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // 唤醒等候线程
            unparkSuccessor(h);
        return true;
    }
    return false;
}

解锁逻辑主要分为两部分:释放锁、唤醒阻塞线程。

3.3.1 tryRelease

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;
}

前后呼应了,可重入的特性!

需要注意的是加锁和解锁的次数要一致,不然将会导致队列中的等候线程无法被唤醒。

3.3.2 unparkSuccessor

private void unparkSuccessor(Node node) {
    /*
     * 如果状态为负数(即,可能需要信号),尝试清除预期信号 
     */
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    // 预唤醒节点(head的后驱节点)
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);
}

预唤醒的节点s 是node的后驱节点(FIFO)。

但是如果s 为空或者已取消时,就需要从队列中找出一个正常的节点。怎么找呢? 以队尾节点为起始点,向前遍历,最终s指向的是队列中最靠近 node的一个正常节点。

随后,通过调用 LockSupport.unpark()的方式,唤醒 s节点持有的线程。

至此,ReentranLock一套完整的加锁解锁流程就分析完毕了~