21.Lock锁原理

Posted 纵横千里,捭阖四方

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了21.Lock锁原理相关的知识,希望对你有一定的参考价值。

目录

1 获得锁过程

1.1 如何调用公平或者非公平锁

1.2 Lock层次如何获得锁

1.3 AQS层次如何获得锁

1.3.1 非公平锁的处理

1.3.2 公平锁的处理

1.4 获取锁失败的线程如何处理

 1.5 等待队列中的线程如何再次被唤醒

 2 释放锁过程


理解AQS是打开JUC的钥匙,而如果要知道如何开门,那必须搞清楚Lock加锁和释放锁的整个过程到底是怎样的。

理解了AQS的功能之后,我们再来看一下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();

其中look就是加锁,而tryLock就是尝试加锁,释放锁就是unlock,condition的问题,我们后面再说。

在Lock接口上的注释也注明了,实现该接口主要是单个类:重入锁ReentrantLock、读写锁ReadWriteLock,另外从jdk8开始又增加了优化的读写锁StampedLock。本文,我们就重点分析重入锁的实现原理。

抢锁时 如果被占用,但是是自己,则更新一下计数+1,如果释放就更新一下计数–1。基本流程图:

在上面的图示中,Sync是ReentrantLock类里的抽象内部类,继承了AQS。我们知道AQS提供了线程的阻塞和唤醒的功能,sync会继承AQS来实现对应场景的功能来进一步服务业务的需求。Sync有两个具体的实现:

NofairSync和FairSync是非公平和公平锁,这两个锁也是ReentrantLock的静态内部类。因此ReentrantLock本身能实现公平和非公平两种情况。

接下来我们就来看一下获得和释放锁是如何进行的。

1 获得锁过程

1.1 如何调用公平或者非公平锁

 这个在初始化的的时候设置参数就可以了,创建锁的代码如下:

 static Lock lock=new ReentrantLock();

此时是公平还是非公平呢?我们看构造方法的定义:

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

这就说明了默认情况下ReentrantLock()锁是非公平锁,我们可以通过传入fair参数决定使用公平还是非公平锁。

1.2 Lock层次如何获得锁

我们上一章介绍过AQS获得独占锁的入口是acquire()方法,那公平和非公平锁在调用acquire()之前要干什么呢?FairSync里是直接调用:

 final void lock() 
    acquire(1);
 

而NonfairSync的抢占逻辑是:不管有没有线程排队,先通过CAS抢占资源,如果成功就得到了锁,如果失败就调用acquire()方法执行锁竞争逻辑。

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

compareAndSetState()方法里的state是AQS中的一个属性,它在不同的实现中所表达的含义是不一样的。对重入锁的实现中,state表示同步状态,具体来说有两个含义:

  • 当state=0,表示无锁状态。

  • state>0,表示已经有线程获得了锁,如果state大于1,就表示发生了多次重入,例如state=5就代表重入了5次。之后没释放一次,state就减一,知道state=0,其他线程才可以继续抢占锁。

compareAndSet()的原理我们在前面讲过,这里再重申一下。一个Java对象可以看成一段内存,每个字段都要哪找一定的顺序放在这段内存里,通过这个方法可以准确地告诉你某个字段相对于对象的起始内存的字节偏移量(stateOffset)。而compareAndSet()方法就是借助Unsafe类来找到对象在内存中的具体位置,并且是硬件级别的原子操作,这种操作可以保证线程安全。

1.3 AQS层次如何获得锁

在上面的Lock层,几乎没做太多本质的工作,大部分还是交给AQS的acquire()来处理了,那该方法又如何获得锁的呢?

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

这个方法虽然看似简单,但是包含的逻辑不少:

  • 通过tryAcquire()方法获得锁,如果成功则返回true,否则返回false。

  • 如果tryAcquire()返回false,则说明当前锁被占用,只能通过addWaiter()方法将当前线程封装成一个Node并添加到AQS同步队列中。

  • acquireQueued()方法的工作是将Node作为参数,并通过自旋来获得锁。

如果再看AQS里tryAcquire()和nofairTryAcquire()方法代码,就会发现只是抛出一个异常:

protected boolean tryAcquire(int arg) 
    throw new UnsupportedOperationException();

这里我们会有个疑问:什么要抛出异常,直接设计成接口不行或抽象方法不行吗?这里抛异常是为了让子类来进一步实现,例如公平锁和非公平锁对该方法的实现就不一样。 这也是模板模式的一种实现方式,也值得在工程开发中遇到类似问题时参考。

那具体如何实现的呢,我们就来看一下公平锁FairSync和非公平锁NonfairSync是如何做的吧。

1.3.1 非公平锁的处理

在NonfairSync中,貌似啥也没做就转手了:

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

那就再看看nonfairTryAcquire()里是怎么做的吧:

final boolean nonfairTryAcquire(int acquires) 
    final Thread current = Thread.currentThread();//获取当前执行的线程
    int c = getState();//获得state的值
    if (c == 0) //表示无锁
    //通过CAS替换state的值,如果成功则表示获取了锁
        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;

上面的实现逻辑是:

  • 首先判断当前锁的状态,c=0表示无锁,在无锁状态下通过compareAndSetState()方法修改state抢占锁资源。

  • 如果失败就返回false,啥也不做。如果成功就返回true,并且通过setExclusiveOwnerThread()将AQS中的exclusiveOwnerThread设置为当前线程,方便后续重入等使用。

  • 而current==getExclusiveOwnerThread(),则判断抢占到锁的线程和当前线程是同一个线程,是线程重入,因此直接增加重入次数并保存到state字段中。这里的getExclusiveOwnerThread()就是返回当前的独占线程。

protected final Thread getExclusiveOwnerThread() 
    return exclusiveOwnerThread;

1.3.2 公平锁的处理

而在公平锁中,tryAcquire()的实现与nonfairTryAcquire()的实现基本一致,只在c=0无锁判断时增加了一个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;
            
        
        else if (current == getExclusiveOwnerThread()) 
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        
        return false;
    

而hasQueuedPredecessors()的实现就是一个简单的链表访问:

public final boolean hasQueuedPredecessors() 
    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());

这里我们介绍了公平和非公平两种情况下如何判断是否该让新来的线性获取锁。如果失败,则调用AQS的addWaiter()方法把当前线程封装成Node加入到同步队列中。

1.4 获取锁失败的线程如何处理

上面我们介绍了公平和非公平锁是如何竞争锁的,那竞争失败的锁是如何处理的呢,这就是addWaiter()的作用,我们继续看。

在Lock层如何处理锁我们看到,入参mode表示当前结点的状态,调用addWaiter(Node.EXCLUSIVE)传递的参数是Node.EXCLUSIVE,表示独占状态,上面的代码做的事情是:

private Node addWaiter(Node mode) 
//将当前线程封装成一个结点。
    Node node = new Node(Thread.currentThread(), mode);
    //tail是AQS中表示同步队列队尾的属性,默认是null
    Node pred = tail;
    //如果tail不为空,则表示队列中存在结点
    if (pred != null) 
    //把当前线程的Node的prev指向到tail
        node.prev = pred;
        //这里通过cas把node加到AQS队列中,也就是设置为tail
        if (compareAndSetTail(pred, node)) 
            pred.next = node;
            return node;
        
    
    //这里说明队列是空的,此时将node直接添加到同步队列中即可
    enq(node);
    return node;

上面代码的工作过程就是:

  • 第一步:将当前线程封装成Node并存储,后续可以直接从结点中得到线程,在通过unpark(thread)来唤醒。

  • 第二步:通过pred是否为空,判断当前链表是否为已经完成初始化,如果已经完成,则通过compareAndSetTail()操作把当前线程的Node设置为tail结点,并建立双向关联。

  • 第三步:如果链表还没有初始化或者CAS添加失败,也就是存在线程竞争,则调用eng()方法来完成添加操作。

而上面enq()方法就是一个入队操作,初始化这里采用了自旋锁完成同步队列的初始化,并把当前结点添加到同步队列中。

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()方法执行完成之后,AQS的整体结构是:

 1.5 等待队列中的线程如何再次被唤醒

执行addWaiter()把线程添加到链表之后,是不可能什么都不做的,一定是一边等待一边监听,一旦条件满足就会再次去竞争锁,该过程具体是怎么做的呢?

首先回到Lock锁,我们发现上面的addWaiter()完成之后会将处理完的结点返回出来,而该node又是acquireQueued()方法的入参:

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

而这里的acquireQueued()方法,就是执行监听的入口,具体代码:

final boolean acquireQueued(final Node node, int arg) 
    boolean failed = true;
    try 
        boolean interrupted = false;
        for (;;) 
        //获取当前结点的prev结点
            final Node p = node.predecessor();
            //如果是head结点,说明有资格去争抢锁
            if (p == head && tryAcquire(arg)) 
                setHead(node);
                //获取锁成功,即ThreadA已经释放了锁,然后设置head为threadB,让B获得执行权限
                //将原head结点从链表中删除。
                p.next = null; // help GC
                failed = false;
                return interrupted;
            
            //如果ThreadA还没有释放锁,此时threadB在执行tryAcquire()方法时会返回false。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                //返回当前线程在等待过程中是否中断过。
                interrupted = true;
        
     finally 
        if (failed)
            cancelAcquire(node);
    

从上面我们可以看到,acquireQueued()方法主要有两个作用:

  • 利用自旋尝试通过tryAcquire()方法抢占锁,抢占的条件是当前节点的前一个节点是头结点p==head。

  • 当抢不到锁时,不能让线程一直自旋重试,如果竞争失败就调用parkAndCheckInterrupt()方法阻塞当前线程。

在acquireQueued()方法中,如果threadA还没有释放锁,如果threadB和threadC来争抢资源肯定会失败的,失败之后会调用shouldParkAfterFailedAcquire()方法来修改节点状态以及决定是否应该被挂起。具体代码如下:

 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) 
//前置节点的waitStatus
        int ws = pred.waitStatus;
        //只需要等待其他前置节点的线程被释放
        if (ws == Node.SIGNAL)
            return true;
        //说明prev节点取消了排队,直接移除该点
        if (ws > 0) 
            do 
                node.prev = pred = pred.prev;
             while (pred.waitStatus > 0);//从双向链表中移除CANCELLED节点
            pred.next = node;
         else 
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        
        return false;
    

我们前面已经提到过Node的5种状态:CANCELLED、SIGNAL、CONDITION、PROPAGATE和默认状态0。我们再看一遍其含义:

  • 0:初始状态。

  • CANCELLED:如果在同步队列中等待的线程等待超时或者被中断,那么需要从同步队列中取消该Node。该状态的结点不会再被操作。

  • SIGNAL:只要前置节点释放锁,就会通知标记为SIGNAL状态的后序节点。

  • CONDITION:与CONDITION条件有关,下一章再解释。

  • PROPAGATE:在共享模式下,PROPAGATE状态的线程处于可运行状态。

通过上面的分析可以看到,shouldParkAfterFailedAcquire()方法的作用是检查当前结点的前置结点状态,如果是SIGNAL,表示线程处于正常的等待状态,因此可以放心地阻塞,否则需要通过compareAndSetWaitStatus()修改前置结点的状态为SIGNAL。这么做的目的是把非正常状态的结点移除,确保在同步队列中每个等待的线程状态都是正常的。

在acquireQueued()中,如果shouldParkAfterFailedAcquire()返回了true,该如何将线程挂起呢?这就是 parkAndCheckInterrupt()方法干的事情。

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

很明显,这里通过LockSupport将线程挂起了,线程变成waiting状态。

注意这里返回的是Thread.interrupted(),就是一个线程是否被中断过的信号。在前面介绍关闭线程时说过,被阻塞的线程除正常唤醒外,通过调用该线程的interrupt()方法也可以。由于线程处于阻塞状态,所以如果要被中断唤醒,那么它同样需要竞争锁。当竞争到锁之后,才能继续响应之前的中断操作,发送该信号就是为其服务的。

如下图,ThreadB和C在同步队列中,此时ThreadD调用C的interrupt()方法,该方法会唤醒ThreadC,但是唤醒之后并不能立刻响应中断,而是再次竞争锁,只有竞争到锁之后才能响应中断事件。

通过interrupt()方法唤醒线程的图示如下:

 2 释放锁过程

如果任务完成了,线程要释放掉锁,又是如何实现的呢?释放锁是从lock.unlock()开始的,实现如下:

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

public final boolean release(int arg) 
    if (tryRelease(arg)) //释放锁成功
        Node h = head;//得到AQS中的head节点
        if (h != null && h.waitStatus != 0)
        //如果head节点不为空,并且状态不是0,则唤醒
            unparkSuccessor(h);
        return true;
    
    return false;

上面的代码就是我们释放锁的框架,主要流程是:

  • 首先,尝试释放锁,如果失败,则直接返回false,如果成功则继续执行:

  • 获得AQS的头结点head,并根据其结果判断是否还有线程在等待,如何没有则返回true,结束。如果有,则继续执行:

  • 如果还有结点在等待,则执行unparkSuccessor()唤醒新的线程。

我们继续看上面的方法是如何实现的。首先,就是tryRelease()方法了,在ReetrantLock中,该方法通过修改state的只来释放锁资源:

protected final boolean tryRelease(int releases) 
    int c = getState() - releases;//releases表示要释放的次数
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) 
        free = true;
        setExclusiveOwnerThread(null);
    
    setState(c);
    return free;

这里的release表示什么呢?独占锁在加锁时状态会加1,在释放时会减1,同一个锁可以重入后,state值可能会不断增加,只有调用unlock()之后,一直达到unlock()的数量和lock()的数量一致才会完全释放锁。而如果需要释放的次数比较多,我们可以通过releases的标记,一次多减掉一些,效率更高。

锁资源释放完了之后,会通过unparkSuccessor()方法唤醒同步队列中的线程,代码如下:

private void unparkSuccessor(Node node) 
    int ws = node.waitStatus;//获得head节点的状态。
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);//设置head结点的状态为0
//得到head结点的下一个结点
    Node s = node.next;
    //cancelled状态
    if (s == null || s.waitStatus > 0) 
        s = null;
        //通过从尾部结点开始扫描,找到距离head最近的一个waitStatus<=0的节点
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    
    //如果next结点不为空,直接唤醒该线程即可
    if (s != null)
        LockSupport.unpark(s.thread);

在unparkSuccessor()主要是判断当前结点的状态,如果结点状态已失效,则从tail结点开始扫描,找到距离head最近结点且状态为SIGNAL的结点。满足要求则通过LockSupport.unpark()方法唤醒该结点。

思考

这里为什么要从tail开始向前扫描呢?这个是和enq()方法对应的。在enq()中,把一个新结点添加到链表的过程是:将新结点的prev指向tail。然后通过CAS将tail设置为新结点,因为CAS操作能保证线程的安全性。最后是t.next=node,目的是设置原tail的next结点指向新结点。

如果在CAS操作之后,t.next=node操作之前,存在其他线程调用unlock()方法从head开始往后遍历,由于t.next=node 还没执行,所以链表的关系还没建立完,就会导致遍历到t结点的时候被中断,而如果从tail往前遍历,就不会出现该问题。

我们再次回到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);
    

也就是说,ThreadA释放了锁之后,ThreadB 可以通过tryAcquire()方法来竞争锁资源。注意此时B不一定真能抢到,假如有其他线程执行了lock()方法,B就需要继续等待。假如B抢到了,那过程就是:

1.把ThreadB结点设置成head节点,并断开和原old结点的指向关系。

2.把原head结点的next结点指向null。

释放之后,同步队列中的结点状态发生了变化,正常情况下应该是head结点的下一个结点(也就是ThreadB),如果是非公平锁,有可能被其他线程抢走。

以上是关于21.Lock锁原理的主要内容,如果未能解决你的问题,请参考以下文章

『图解Java并发』面试必问的CAS原理你会了吗?

ReentrantLock原理ReentrantReadWriteLock原理

数据库锁的基本原理

分布式锁设计方案

数据库 锁机制

微服务架构之:Redisson分布式可重入锁原理