skydiver
skydiver
4月前 · 6 人阅读

ReentrantLock 介绍

一个可重入的互斥锁,它具有与使用{synchronized}方法和语句访问的隐式监视器锁相同的基本行为和语义,但它具有可扩展的能力。

一个ReentrantLock会被最后一次成功锁定(lock)的线程拥有,在还没解锁(unlock)之前。当锁没有被其他线程拥有的话,一个线程执行『lock』方法将会返回,获取锁成功。一个方法将会立即的返回,如果当前线程已经拥有了这个锁。可以使用『isHeldByCurrentThread』和『getHoldCount』来检查当前线程是否持有锁。

这个类的构造方法会接受一个可选的“fairness”参数。当该参数设置为true时,在发生多线程竞争时,锁更倾向将使用权授予给最长等待时间的线程。另外,锁不保证任何特定的访问顺序。程序在多线程情况下使用公平锁来访问的话可能表现出较低的吞吐量(如,较慢;经常慢很多)与比使用默认设置相比,但是在获取锁上有较小的时间差异,并保证不会有饥饿(线程)。然而需要注意的是,公平锁并不保证线程调度的公平性。(也就是说,即使使用公平锁,也无法确保线程调度器是公平的。如果线程调度器选择忽略一个线程,而该线程为了这个锁已经等待了很长时间,那么就没有机会公平地处理这个锁了)
还需要注意的是,没有时间参数的『tryLock()』方法是没有信誉的公平设置。它将会成功如果锁是可获取的,即便有其他线程正在等待获取锁。

除了对Lock接口的实现外,这个类还定义了一系列的public和protected方法用于检测lock的state。这些方法中的某些方法仅用于检测和监控。

这个类的序列化行为同lock内置的行为是一样的:一个反序列化的锁的状态(state)是未锁定的(unlocked),无论它序列化时的状态(state)是什么。

这个锁支持同一个线程最大递归获取锁2147483647(即,Integer.MAX_VALUE)次。如果尝试获取锁的次数操作了这个限制,那么一个Error获取lock方法中抛出。

AbstractQueuedSynchronizer

ReentrantLock的公平锁和非公平锁都是基于AbstractQueuedSynchronizer(AQS)实现的。ReentrantLock使用的是AQS的排他锁模式,由于AQS除了排他锁模式还有共享锁模式,本文仅对ReentrantLock涉及到的排他锁模式部分的内容进行介绍,关于共享锁模式的部分会在 CountDownLatch 源码浅析一文中介绍。

AQS提供一个框架用于实现依赖于先进先出(FIFO)等待队列的阻塞锁和同步器(信号量,事件等)。这个类被设计与作为一个有用的基类,一个依赖单一原子值为代表状态的多种同步器的基类。子类必须将修改这个状态值的方法定义为受保护的方法,并且该方法会根据对象(即,AbstractQueuedSynchronizer子类)被获取和释放的方式来定义这个状态。根据这些,这个类的其他方法实现所有排队和阻塞的机制。子类能够维护其他的状态属性,但是只有使用『getState』方法、『setState』方法以及『compareAndSetState』方法来原子性的修改 int 状态值的操作才能遵循相关同步性。

等待队列节点类 ——— Node

等待队列是一个CLH锁队列的变体。CLH通常被用于自旋锁(CLH锁是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。)。我们用它来代替阻塞同步器,但是使用相同的基本策略,该策略是持有一些关于一个线程在它前驱节点的控制信息。一个“status”字段在每个节点中用于保持追踪是否一个线程需要被阻塞。一个节点会得到通知当它的前驱节点被释放时。队列中的每一个节点都作为一个持有单一等待线程的特定通知风格的监视器。状态字段不会控制线程是否被授予锁等。一个线程可能尝试去获取锁如果它在队列的第一个。但是首先这并不保证成功,它只是给与了竞争的权力(也就是说,队列中第一个线程尝试获取锁时,并不保证一定能得到锁,它只是有竞争锁的权力而已)。所以当前被释放的竞争者线程可能需要重新等待获取锁。
(这里说的"队列中的第一个的线程"指的时,从队列头开始往下的节点中,第一个node.thread != null的线程。因为,AQS队列的head节点是一个虚节点,不是有个有效的等待节点,因此head节点的thread是为null的。)

为了排队进入一个CLH锁,你可以原子性的拼接节点到队列中作为一个新的队尾;对于出队,你只要设置头字段。(即,入队操作时新的节点会排在CLH锁队列的队尾,而出队操作就是将待出队的node设置为head。由此可见,在AQS中维护的这个等待队列,head是一个无效的节点。初始化时head是一个new Node()节点;在后期的操作中,需要出队的节点就会设置到head中。)

          +------+  prev +-----+       +-----+
     head |      | <---- |     | <---- |     |  tail
          +------+       +-----+       +-----+

插入到一个CLH队列的请求只是一个对“tail”的单个原子操作,所以有一个简单的从未入队到入队的原子分割点。类似的,出队调用只需要修改“head”。然而,节点需要更多的工作来确定他们的后继者是谁,部分是为了处理由于超时和中断而导致的可能的取消。
(也就是说,一个node的后继节点不一定就是node.next,因为队列中的节点可能因为超时或中断而取消了,而这些取消的节点此时还没被移除队列(也许正在移除队列的过程中),而一个node的后继节点指的是一个未被取消的有效节点,因此在下面的操作中你就会发现,在寻找后继节点时,寻找的都是当前节点后面第一个有效节点,即非取消节点。)

“prev”(前驱)连接(原始的CLH锁是不使用前驱连接的),主要用于处理取消。如果一个节点被取消了,它的后驱(通常)会重连接到一个未被取消的前驱。

另外我们使用“next”连接去实现阻塞机制。每个节点的线程ID被它们自己的节点所持有,所以前驱节点通知下一个节点可以被唤醒,这是通过遍历下一个链接(即,next字段)来确定需要唤醒的线程。后继节点的决定必须同‘新入队的节点在设置它的前驱节点的“next”属性操作(即,新入队节点为newNode,在newNode的前驱节点preNewNode进行preNewNode.next = newNode操作)’产生竞争。一个解决方法是必要的话当一个节点的后继看起来是空的时候,从原子更新“tail”向前检测。(或者换句话说,next链接是一个优化,所以我们通常不需要反向扫描。)

取消引入了对基本算法的一些保守性。当我们必须为其他节点的取消轮询时,我们不需要留意一个取消的节点是在我们节点的前面还是后面。它的处理方式是总是根据取消的节点唤醒其后继节点,允许它们去连接到一个新的前驱节点,除非我们能够标识一个未被取消的前驱节点来完成这个责任。

volatile int waitStatus;

状态属性,只有如下值:
① SIGNAL:
static final int SIGNAL = -1;
这个节点的后继(或者即将被阻塞)被阻塞(通过park阻塞)了,所以当前节点需要唤醒它的后继当它被释放或者取消时。为了避免竞争,获取方法必须首先表示他们需要一个通知信号,然后再原子性的尝试获取锁,如果失败,则阻塞。
也就是说,在获取锁的操作中,需要确保当前node的preNode的waitStatus状态值为’SIGNAL’,才可以被阻塞,当获取锁失败时。(『shouldParkAfterFailedAcquire』方法的用意就是这)
② CANCELLED:
static final int CANCELLED = 1;
这个节点由于超时或中断被取消了。节点不会离开(改变)这个状态。尤其,一个被取消的线程不再会被阻塞了。
③ CONDITION:
static final int CONDITION = -2;
这个节点当前在一个条件队列中。它将不会被用于当做一个同步队列的节点直到它被转移到同步队列中,转移的同时状态值(waitStatus)将会被设置为0。(这里使用这个值将不会做任何事情与该字段其他值对比,只是为了简化机制)。
④ PROPAGATE:
static final int PROPAGATE = -3;
一个releaseShared操作必须被广播给其他节点。(只有头节点的)该值会在doReleaseShared方法中被设置去确保持续的广播,即便其他操作的介入。
⑤ 0:不是上面的值的情况。
这个值使用数值排列以简化使用。非负的值表示该节点不需要信号(通知)。因此,大部分代码不需要去检查这个特殊的值,只是为了标识。
对于常规的节点该字段会被初始化为0,竞争节点该值为CONDITION。这个值使用CAS修改(或者可能的话,无竞争的volatile写)。

volatile Node prev

连接到前驱节点,当前节点/线程依赖与这个节点waitStatus的检测。分配发生在入队时,并在出队时清空(为了GC)。并且,一个前驱的取消,我们将短路当发现一个未被取消的节点时,未被取消的节点总是存在因为头节点不能被取消:只有在获取锁操作成功的情况下一个节点才会成为头节点。一个被取消的线程绝不会获取成功,一个线程只能被它自己取消,不能被其他线程取消。

volatile Node next

连接到后继的节点,该节点是当前的节点/线程释放唤醒的节点。分配发生在入队时,在绕过取消的前驱节点时进行调整,并在出队列时清空(为了GC的缘故)。一个入队操作(enq)不会被分配到前驱节点的next字段,直到tail成功指向当前节点之后(通过CAS来将tail指向当前节点。『enq』方法实现中,会先将node.prev = oldTailNode;在需要在CAS成功之后,即tail = node之后,再将oldTailNode.next = node;),所以当看到next字段为null时并不意味着当前节点是队列的尾部了。无论如何,如果一个next字段显示为null,我们能够从队列尾向前扫描进行复核。被取消的节点的next字段会被设置为它自己,而不是一个null,这使得isOnSyncQueue方法更简单。

volatile Thread thread

这个节点的入队线程。在构建时初始化,在使用完后清除。

Node nextWaiter

链接下一个等待条件的节点,或者一个指定的SHARED值。因为只有持有排他锁时能访问条件队列,所以我们只需要一个简单的单链表来维持正在等待条件的节点。它们接下来会被转换到队列中以去重新获取锁。因为只有排他锁才有conditions,所以我们使用给一个特殊值保存的字段来表示共享模式。
也就是说,nextWaiter用于在排他锁模式下表示正在等待条件的下一个节点,因为只有排他锁模式有conditions;所以在共享锁模式下,我们使用’SHARED’这个特殊值来表示该字段。

源码分析

初始化
ReentrantLock lock = new ReentrantLock(true)
ReentrantLock lock = new ReentrantLock()

或

ReentrantLock lock = new ReentrantLock(false)


lock
public void lock() {
    sync.lock();
}

获取锁。
如果其他线程没有持有锁的话,获取锁并且立即返回,设置锁被持有的次数为1.
如果当前线程已经持有锁了,那么只有锁的次数加1,并且方法立即返回。
如果其他线程持有了锁,那么当前线程会由于线程调度变得不可用,并处于休眠状态直到当前线程获取到锁,此时当前线程持有锁的次数被设置为1次。

  • 公平锁『lock()』方法的实现:
    『FairSync#lock()』
final void lock() {
    acquire(1);
}

调用『acquire』在再次尝试获取锁失败的情况下,会将当前线程入队至等待队列。该方法会在成功获取锁的情况下才会返回。因此该方法是可能导致阻塞的(线程挂起)。

  • 非公平锁『lock()』方法的实现:
    『NonfairSync#lock()』
final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

① 尝试获取锁,若『compareAndSetState(0, 1)』操作成功(这步操作有两层意思。第一,当前state为0,说明当前锁没有被任何线程持有;第二,原子性的将state从’0’修改为’1’成功,说明当前线程成功获取了这个锁),则说明当前线程成功获取锁。那么设置锁的持有者为当前线程(『setExclusiveOwnerThread(Thread.currentThread())』)。
那么此时,AQS state为1,锁的owner为当前线程。结束方法。
② 如果获取锁失败,则调用『acquire』在再次尝试获取锁失败的情况下,会将当前线程入队至等待队列。该方法会在成功获取锁的情况下才会返回。因此该方法是可能导致阻塞的(线程挂起)。

  • 公共方法『AbstractQueuedSynchronizer#acquire』
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

在排他模式下尝试获,忽略中断。该方法的实现至少执行一次『tryAcquire』方法,如果成功获取锁则返回。否则,线程将入队等待队列中,可能会重复阻塞和解除阻塞,以及调用『tryAcquire』直到成功获取锁。这个方法能被用于实现『Lock#lock』
① 执行『tryAcquire』来尝试获取锁,如果成功(即,返回true)。则返回true退出方法;否则到第②步
② 执行『acquireQueued(addWaiter(Node.EXCLUSIVE), arg)』
『addWaiter(Node.EXCLUSIVE)』:为当前线程创建一个排他模式的Node,并将这个节点加入等待队列尾部。
『acquireQueued』:已经入队的节点排队等待获取锁。
③ 如果在尝试获取锁的过程中发现线程被标志位了中断。因为是通过『Thread.interrupted()』方法来检测的当前线程是否有被标志位中断,该方法会清除中断标志,所以如果线程在尝试获取锁的过程中发现被标识为了中断,则需要重新调用『Thread.currentThread().interrupt();』重新将中断标志置位。
该方法是排他模式下获取锁的方法,并且该方法忽略中断,也就说中断不会导致该方法的结束。首先,会尝试通过不公平的方式立即抢占该锁(『tryAcquire』),如果获取锁成功,则结束方法。否则,将当前线程加入到等待获取锁的队列中,如果当前线程还未入队的话。此后就需要在队列中排队获取锁了,而这就不同于前面非公平的方式了,它会根据FIFO的公平方式来尝试获取这个锁。而这个方法会一直“阻塞”直到成功获取到锁了才会返回。注意,这里的“阻塞”并不是指线程一直被挂起这,它可能被唤醒,然后同其他线程(比如,那么尝试非公平获取该锁的线程)竞争这个锁,如果失败,它会继续被挂起,等待被唤醒,再重新尝试获取锁,直到成功。
同时注意,关于中断的操作。因为该方法是不可中断的方法,因此若在该方法的执行过程中线程被标志位了中断,我们需要确保这个标志位不会因为方法的调用而被清除,也就是我们不处理中断,但是外层的逻辑可能会对中断做相关的处理,我们不应该影响中断的状态,即,“私自”在不处理中断的情况下将中断标志清除了。


先继续来看公平锁和非公平锁对『tryAcquire』方法的实现
tryAcquire 这类型的方法都不会导致阻塞(即,线程挂起)。它会尝试获取锁,如果失败就返回false。

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

公平版本的『tryAcquire』方法。当前线程没有权限获取锁,除非递归调用到没有等待者了,或者当前线程就是第一个尝试获取锁的线程(即,等待队列中没有等待获取锁的线程)。
① 获取AQS state,即,当前锁被获取的次数。如果为’0’,则说明当前锁没有被任何线程获取,那么执行『hasQueuedPredecessors』方法判断当前线程之前是否有比它等待更久准备获取锁的线程:
a)如果有则方法结束,返回false;
b)如果没有,则说明当前线程前面没有另一个比它等待更久的时间在等待获取这个锁的线程,则尝试通过CAS的方式让当前的线程获取锁。如果成功则设置持有锁的线程为当前线程(『setExclusiveOwnerThread(current)』),然后方法结束返回true。
② 如果AQS state > 0,则说明当前锁已经被某个线程所持有了,那么判断这个持有锁的线程是否就是当前线程(『current == getExclusiveOwnerThread()』),如果是的话,尝试进行再次获取这个锁(ReentrantLock是一个可重入的锁)如果获取锁的次数没有超过上限的话(即,c + acquires > 0),则更新state的值为最终该锁被当前线程获取的次数,然后方法结束返回true;否则,如果当前线程获取这个锁的次数超过了上限则或抛出Error异常。再者如果当前线程不是持有锁的线程,则方法结束返回false。


『AbstractQueuedSynchronizer#hasQueuedPredecessors』

public final boolean hasQueuedPredecessors() {
    // The correctness of this depends on head being initialized
    // before tail and on head.next being accurate if the current
    // thread is first in queue.
    Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

查询是否有线程已经比当前线程等待更长的时间在等待获取锁。
这个方法的调用等价于(但更高效与):
『getFirstQueuedThread() != Thread.currentThread() && hasQueuedThreads()』
即,可见如下任一情况,则说明当前线程前面没有比它等待更久的需要获取锁的线程:
a)当队列中第一个等待获取锁的线程是当前线程时
b)等待队列为空时。即,当前没有其他线程等待获取锁。
注意,因为中断和超时导致的取消可能发生在任何时候,该方法返回‘true’不能保证其他线程会比当前线程更早获得到锁。同样,由于队列是空的,在当前方法返回‘false’之后,另一个线程可能会赢得一个入队竞争。
这个方法被涉及用于一个公平的同步器,以避免闯入。如果这个方法返回’true’,那么像是一个(公平)同步器的『tryAcquire』方法应该返回’false’,以及『tryAcquireShared』方法需要返回一个负数值(除非这是一个可重入的锁,因为可重入锁,获取锁的结果还需要判断当前线程是否就是已经获取锁的线程了,如果是,则在没有超过同一线程可获取锁的次数上限的情况下,当前线程可以再次获取这个锁)。比如,一个公平的、可重入的、排他模式下的『tryAcquire』方法,可能看起来像是这样的:

返回:

a)true:如果当前线程前面有排队等待的线程

b)false:如果当前线程是第一个等待获取锁的线程(即,一般就是head.next);或者等待队列为空。

该方法的正确性依赖于head在tail之前被初始化,以及head.next的精确性,如果当前线程是队列中第一个等待获取锁的线程的时候。

① tail节点的获取一定先于head节点的获取。因为head节点的初始化在tail节点之前,那么基于当前的tail值,你一定能获取到有效的head值。这么做能保证接下来流程的正确性。举个反例来说明这么做的必要性:如果你按『Node h = head; Node t = tail;』的顺序来对h、t进行赋值,那么可能出现你在操作这两步的时候有其他的线程正在执行队列的初始化操作,那么就可能的带一个『h==null』,而『tail!=null』的情况(这种情况下,是不对的,因为tail!=null的话,head一定也不为null了),这使得『h != t』判断为true,认为当下是一个非空的等待队列,那么接着执行『s = h.next』就会抛出NPE异常了。但是当『Node t = tail; Node h = head;』按初始化相反的顺序来赋值的话,则不会有问题,因为我们保证了在tail取值的情况下,head的正确性。我们接下看下面的步骤,来说明为什么这么做就可以了。

② 在获取完t、h之后,我们接着先判断『h != t』,该判断的用意在于,判断当前的队列是否为空。如果为true则说明,当前队列非空。如果为false 则说明当前队列为空,为空的话,方法就直接结束了,并返回false。

但是请注意,当『h != t』为true时,其实是有两种情况的:

a)当tail和head都非空时,说明此时等待队列已经完成了初始化,head和tail都指向了其队列的头和队列的尾。

b)当“tail==null”同时“head != null”,则说明,此时队列正在被其他线程初始化,当前我们获取的h、t是初始化未完成的中间状态。但是没关系,下面的流程会对此请进行判断。

③ 当『h != t』返回’true’的话,继续判断『(s = h.next) == null || s.thread != Thread.currentThread()』。这里的两个判断分别对应了两种情况:

a)『(s = h.next) == null』返回’true’,则说明当获取的h、t为初始化的中间状态,因为第一个线程入队的时候,会先初始化队列,然后才对head的next值进行赋值,所以我们需要“s = h.next”是否为null进行判断,如果为’null’,则说明当前等待队列正在被初始化,并且有一个线程正在入队的操作中。所以此时方法直接结束,并且返回true。

b)如果『h != t』并且『(s = h.next) != null』,则说明当前线程已经被初始化好了,并且等待队列中的第一个等待获取锁的线程也已经入队了。那么接着我们就判断这个在等待队列中第一个等待获取锁的线程是不是当前线程『s.thread != Thread.currentThread()』,如果是的话,方法结束并返回false,表示当前线程前面没有比它等待更久获取这个锁的线程了;否则方法结束返回true,表示当前线程前面有比它等待更久希望获取这个锁的线程。

『AbstractQueuedSynchronizer#addWaiter』

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

根据给定的模式创建当前线程的节点,并将创建好的节点入队(加入等待队列尾部)。
首先在队列非空的情况下会尝试一次快速入队,也就是通过尝试一次CAS操作入队,如果CAS操作失败,则调用enq方法进行“自旋+CAS”方法将创建好的节点加入队列尾。
在排他模式下,将节点加入到锁的同步队列时,Node的mode(即,waitStatus)为’EXCLUSIVE’。waitStatus是用于在排他锁模式下当节点处于条件队列时表示下一个等待条件的节点,所以在加入到锁的同步队列中(而非条件队列),我们使用’EXCLUSIVE’这个特殊值来表示该字段。本文主要围绕共享锁模式的介绍,就不对其进行展开了,关于排他锁的内容会在“ReentrantLock源码解析”一文中介绍。

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

对父类AQS tryAcquire方法的重写。调用『nonfairTryAcquire(acquires)』方法,非公平的尝试获取这个可重入的排他锁


『nonfairTryAcquire』

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

执行不公平的『tryLock』。『tryAcquire』在子类中实现,但是都需要不公平的尝试在『tryLock』方法中。
① 获取state值,如果为’0’,则说明当前没有线程占用锁,那么调用CAS来尝试将state的值从0修改为’acquires’的值,
a)如果成功则说明当前线程成功获取到了这个不公平锁,那么通过『setExclusiveOwnerThread(current)』方法来标志当前线程为持有锁的线程,方法结束,返回true;
b)如果失败,则说明有其他线程先获取到了这个锁,那么当前线程获取锁失败。方法结束,返回false。
② "state != 0",则说明当前锁已经被某个线程所持有了,那么判断当前的线程是否就是持有锁的那个线程(『if (current == getExclusiveOwnerThread())』)。
a)如果持有锁的线程就是当前线程,因为ReentrantLock是一个可重入的锁,所以接下来继续判断预计递归获取锁的次数是否超过了限制的值(即,“nextc < 0”则说明预计递归获取锁的次数超过了限制值Integer.MAX_VALUE了),那么会抛出“Error”异常;否则将当前state的值设置为最新获取锁的次数(注意,这里不需要使用CAS的方式来修改state了,因为能操作到这里的一定就是当前持有锁的线程了,因此是不会发送多线程竞争的情况)。然后方法结束,返回true;
b)如果持有锁的线程不是当前线程,那么当前线程获取锁失败。方法结束,返回false。

  • 『FairSync#lock』 VS 『NonfairSync#lock』
    a)在公平锁的模式下,所有获取锁的线程必须是按照调用lock方法先后顺序来决定的,严格的说当有多个线程同时尝试获取同一个锁时,多个线程最终获取锁的先后顺序是由入队等待队列的顺序决定的,当然,第一个获取锁的线程是无需入队的,等待队列是用于存储那些尝试获取锁失败后的节点。并且按照FIFO的顺序让队列中的节点依次获取锁。
    b)在非公平模式下,当执行lock时,无论当前等待队列中是否有等待获取锁的线程了,当前线程都会尝试直接去获取锁。
收藏 0
评论