聊聊ReentrantLock的锁设计

前言

之前看过美团的一篇不可不说的Java“锁”事,对java锁的概念做了一次梳理,其实在java类中,ReentrantLock算是一个对锁概念运用的典范,看懂它的源码对锁的理解很有帮助。我也以ReentrantLock为原型,略加改动使之能在分布式环境中运行。

幕后功臣AQS

当我们看第一眼ReentrantLock源码,里面有一个Sync对象,它继承AbstractQueuedSynchronizer。

AbstractQueuedSynchronizer是一个抽象的队列式的同步框架,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock,CountDownLatch

下面代码复写了tryRelease方法,其余的一些方法是自定义方法,我先删除了。其实还有一个tryAcquire方法,当开发复写这两个方法,就可以完成一个锁的编码设计。

  • tryAcquire 尝试获取锁
  • tryRelease 尝试释放锁

是不是感觉有了这个万能的框架后,写一个锁很简单了?AbstractQueuedSynchronizer遵循 模板设计 模式,主骨架已经给你搭好,为了能串联起整个功能,你必须要复写必要的方法,不然直接调用AQS的方法,会抛出UnsupportedOperationException异常。

所以对ReentrantLock源码的研究也是对AQS的研究。

private final Sync sync;

    /**
     * Base of synchronization control for this lock. Subclassed
     * into fair and nonfair versions below. Uses AQS state to
     * represent the number of holds on the lock.
     */
    abstract static class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = -5179523762034025860L;
        
        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;
        }
    }

ReentrantLock的大致流程

ReentrantLock的获取锁流程

上锁的流程可以直接看AQS的acquire方法

  • tryAcquire: 不用多说了,先去尝试获取锁
  • addWaiter: 如果获取锁不成功,便将当前线程包装成Node对象,加入到FIFO队列。
  • acquireQueued: 这里是比较重要的逻辑,线程是否休眠的判断逻辑,线程休眠(wait)的逻辑,线程在队列里位置调整的逻辑。这个方法最重要的还是让线程休眠,等待唤醒。
public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

然后根据 && 的短路特性,我们把上面的步骤总结一下:

先尝试获取锁(tryAcquire),发现获取失败,则加入等待队列(addWaiter),并且判断是否需要休眠,是的话则休眠,不是则重试获取锁(acquireQueued)。

ReentrantLock的释放锁流程

  • tryRelease 释放锁的方法
  • unparkSuccessor 如果存在的话,唤醒后继节点
    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

释放锁的步骤简单:先尝试释放锁,成功的话唤醒head节点的后继节点.

详解ReentrantLock

我想根据锁的特性(可重入,公平锁,自旋)来 拆解 讲解ReentrantLock,可以加深对锁的理解。

乐观 or 悲观 ?

乐观锁与悲观锁是一种广义上的概念,在ReentrantLock的实现中,我们要从整体和部分两部分来说。

从整体来看,它是一个悲观锁,因为它是一个独占锁,当一个线程持有它并释放它前,其他线程是不能读或写共享资源的。

但从部分来看,ReentrantLock的实现方式有用到乐观锁(CAS)。

在AQS中,它维护了一个state变量(代表共享资源),ReentrantLock利用state变量来维护锁的获取状态,看代码

        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            //初始状态下,state为0,代表锁还没有线程获取。
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                //当锁获取成功时,state变成1(acquires为1)
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }

			。。。。。

上图的部分代码可以知道,state初始化为0,表示未锁定状态。线程
调用时,会调用tryAcquire()独占该锁并将state设为acquires值(默认为1)。此后,其他线程再tryAcquire()时就会失败,直到线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。

如何保证只有当state为0时更新才能成功?这就是compareAndSetState方法去做的事,compareAndSetState使用一种比较交换的技术(CAS Compare And Swap)来鉴别线程冲突,一旦检测到冲突的产生,更新会失败,并且线程不会阻塞挂起(跟悲观锁的主要区别),而是返回操作结果,你后面需要重试或者报错都取决于你,它是一个原子操作,所以在多线程中,CAS可以保证同步,这也就是乐观锁的一个经典实现。

CAS的更多内容网上有一堆,大家可以拓展阅读。所以在对一个锁的实现进行定性时,不能陷入一种误区,就是这个锁一定是悲观 or 乐观,需要根据锁的内部实现方式,服务对象来综合判断。

可重入性

重入锁 意思是 同一个线程 能够 多次获取同一个锁,并且不产生死锁。可能会问什么情况会这个线程多次获取同一个锁?下面是最简单的示例,test方法调用test1方法,两个方法都用synchronized修饰,如果synchronized没有可重入的特性,线程执行到test1的时候会因为没有锁而卡住(test的锁还没有释放)。

    synchronized void test() {
        test1();
    }

    synchronized void test1() {

    }

ReentrantLock名字本身就表示它是一个可重入锁,继续从代码看看怎么实现的吧。

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

这个代码还是取的上面那个代码,只是这次我们得关注重心在 else if代码块中。

当getState()返回>0时,说明这个线程已经持有该锁了,但是还没有释放锁,然后他它会先判断当前线程是不是持有该锁的线程(两个线程不相等当然不能执行操作),这是可重入的前提,必须是同一个线程,

current == getExclusiveOwnerThread()

判断完没毛病后,就是简单的对state变量进行加法操作,你重入了2次,state就为2,重入3次就是3。可以看到这部分拿锁操作连CAS都没用,效率会非常快。

在解锁方面,大家也应该想到了,就是对state-1,直到state为0

		//releases 默认为1
        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            //当然也会判断一下当前锁的持有线程是不是跟  当前执行的线程一致
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            //如果state减为0,则释放锁,否则只是更新一下state变量,重入了几次,就要解锁几次。
            if (c == 0) {
                free = true;
                //锁持有线程清空
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

可重入锁解决了实际使用可能的死锁问题,而且性能消耗比线程第一次获取锁小很多.到目前为止,我们已经看完了ReentrantLock的门面设计,说前面的都是门面,因为ReentrantLock还有一个重要的组成部分还没介绍,就是先进先出的队列,用于暂存被睡眠的线程,我们由公平锁来引出它。

公平 or 非公平

让我们回到获取锁的流程,tryAcquire方法如果失败了,就会被丢进一个等待队列,先从addWaiter开始。

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

addWaiter 和 enq方法都被我摘出来。当一个节点获取锁失败时,会被放入队列中,队列本身的插入逻辑与一般维护队列的代码没什么区别,但怎么保证线程安全性?还是利用了CAS来保证。

在enq中,如果这个队列本身不存在,会进行一个循环(enq),先建一个头节点,这个头节点没有绑定线程信息,然后需要加入队列的节点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;
    }

    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

addWaiter结束以后,就轮到acquireQueued。这里的逻辑有点绕,刚进去就是一个死循环

当前线程node称为 节点A

  1. 先判断 节点A前置节点 是不是头节点
    1.1 是的话说明可以直接 尝试获取 锁(tryAcquire),获取成功,则 节点A 设为Head节点,前置节点脱离链表(p.next = null)
    1.2 不是的话进入 <2> 步骤
  2. shouldParkAfterFailedAcquire
    2.1 先判断 前置节点 是不是已经是 **请求释放(Node.SIGNAL) ** 的状态,是的话 返回true,接着直接执行 parkAndCheckInterrupt 方法挂起 节点A 所持有的当前线程。
    2.2 如果 前置节点 不是 **请求释放(Node.SIGNAL) ** 的状态,且waitStatus状态值>0,说明 前置节点 已经被取消,就需要找前置节点的前置,一直找到waitStatus状态 <=0为止(do while 循环)作为 节点A 的新前置节点。
    2.3 如果 前置节点 不是 **请求释放(Node.SIGNAL) ** 的状态,且waitStatus状态值<=0 ,说明 前置节点 的waitStatus状态需要被设置成 请求释放(Node.SIGNAL) 的状态,但是现在不能让它挂起,而是继续<1>的步骤。
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);
        }
    }

    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

经过上面的循环,节点A 有两个归宿,一个是拿锁成功返回,一个是被挂起,而挂起后怎么被 唤醒再继续 上面的步骤<1>的,玄机在解锁的代码。

假设tryRelease返回为true

  1. 头节点 Head,头节点非空且状态不是初始态(0),进入唤醒流程
  2. 头节点 状态如果 < 0,重置头节点为0(CAS)
  3. 取头节点的 后继节点 ,判断 后继节点 的waitStatus是不是取消态(waitStatus>0),是的话重新找离头节点最近的后继节点,从尾节点往前找
  4. 最后唤醒这个 后继节点
    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        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);
    }

在唤醒和睡眠的代码里,逻辑比较绕的,就是对waitStatus状态的变更,而总结一句话就是:

唤醒前,总会把 Head头节点 设为 0(初始态),并且唤醒节点成功获取锁之后会取代头节点位置(acquireQueued setHead(node))
睡眠前,总会把前置节点设为 -1 (请求释放(Node.SIGNAL) )

介绍完AQS对队列的使用,我们可以看到对公平锁和非公平锁的使用

公平锁: 按照字面意思理解很简单,下一个处理的线程一定是按照队列排序的,即使是新来的线程,也必须先进队列再操作。

FairSync是ReentrantLock的公平锁实现类,里面的tryAcquire方法跟NonfairSync(非公平锁)唯一不同的就是多了一个hasQueuedPredecessors判断

hasQueuedPredecessors的意思就是每个新来的线程必要要先判断 队列里是不是已经有等待的线程,如果自己不是队列里第一个线程,就不允许尝试获取锁。

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

非公平锁: 首先要更正一个误区,即使是非公平锁,也大致保持着公平的特性,如果没有新来的线程,对锁的获取也是先来后到的原则(队列来保证),只是如果有新来的线程,就可以插队,而插队不插队的判断逻辑就是有没有使用hasQueuedPredecessors方法(NonfairSync实现类是没有使用hasQueuedPredecessors方法)

你可能感兴趣的:(多线程)