抽象队列同步器AQS应用之Lock详解

一、ReentrantLock的使用

public class AQSLock {

    private static int count = 0;

    private static ReentrantLock lock = new ReentrantLock(true);

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);

        // 10个线程每一个线程加一千次
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                try {
                    countDownLatch.await();
                    for (int j = 0; j < 1000; j++) {
                        lock.lock();
                        try {
                            count++;
                        }catch (Exception e){

                        }finally {
                            lock.unlock();
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }

        // 让所有的线程休眠等待所有的线程创建完成
        Thread.sleep(1000);

        //所有的线程同步去执行
        countDownLatch.countDown();

        // 所有的线程执行累加操作
        Thread.sleep(2000);

        // 输出执行的结果
        System.out.println(count);
    }
}

在上面的代码中不管我们执行多少次都可以得到一个正确的值。我们都知道出现线程不安全的原因是多个线程同时去修改线程之前共享的资源,导致的相互执行结果的覆盖最终出现得到的结果不确定,也就出现了线程不安全。上面我们通过使用Java提供的ReentrantLock保证在同一个时刻只有一个线程去访问共享的资源从而实现线程安全。那ReentrantLock是如何保证线程安全的呢?

二、ReentrantLock原理解析

  1. 首先我们模拟一下ReentrantLock在器内部是如何实现线程之间访问同步的呢?我们使用的业务逻辑通常是这样的:
lock.lock()
// 执行业务代码
lock.unlock() 

根据上述的代码我们可以得到这样的一个结论就是,只会有一个线程可以获取到锁进入业务逻辑的执行。假如在此处我们有三个线程分别是T1、T2、T3同时去获取锁。T1成功的获取到锁然后执行自己的业务逻辑。那在这里就会存在业务,那没有获取到锁的线程呢?那肯定都是在lock.lock()中等着。那他们是在哪儿咋等着呢?最简单的方法就是一直去自旋(死循环的装逼说法)获取锁呗。如下所示:

while(true){
    if(lock success){
        // 跳出循环
        break;
    }
}

但是对CPU来说死循环的优先级是很高的。这样的做法很快就会把CPU占满。这样的做法似乎不太优雅。那这个时候我们首先会想到能不能使用线程休眠的方法Thread.sleep()。但是这个似乎不太可行。为何呢?是因为我不知道等待获取锁的线程到底需要休眠多长时间合适?难道还要去计算业务逻辑需要执行执行多长时间,这个方法被否决了。那使用线程的Thread.yeild()方法呢?只要等待就让出CPU的使用权,这个貌似也不好会造成CPU的浪费。那我们就需要一个不但能够按需唤醒还能够在阻塞的时候不会占用CPU的方法。那在Java中刚好提供了一个这样的方法,那就是LockSupport.park()线程在遇到这个调用的时候直接就自己阻塞,在一次调用LockSupport.unpark(thread)线程才可以继续执行。
解决了上述问题之后还一个问题就是这行代码if(lock success)。在竞争锁的时候会有多个线程同时获取的锁,但是只有一个线程获取成功。对于一个线程来说这是一个原子操作,要么成功获取到锁,要么失败。我们知道在Java中实现这样的操作那就是CAS。这是一个基于汇编指令cmpxchg指令封装的操作,在汇编层面保证操作的原子性。
还有最后的一个问题:有多个线程同时来获取锁,但是呢只有一个线程会获取锁成功。其他的都需要等待。那等待线程肯定需要一个容器来存放这些线程的引用。但是天然有排队特性那不就是队列么?天生就拥有FIFO的特性。

总结一下:假如我们需要模拟实现一个这样的锁那主流程是这样的:使用自旋不断的尝试获取锁、使用Java提供的LockSupport阻塞和唤醒线程、使用CAS保证获取锁的正确性、使用队列存放等待获取锁的线程。

  1. ReentrantLock源码解析:
    public ReentrantLock() {
        sync = new NonfairSync();
    }
    
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

首先我们看到ReentrantLock有两种模式一个是公平模式、一个是非公平模式。但是继续向下看我们发现他们两都是继承自Sync。而Sync又是继承自AbstractQueuedSynchronizer这个也是我们今天的主角AQS
2.1首先我们看看加锁的过程:

    // 在ReetrantLock中的方法
    public void lock() {
        sync.lock();
    }
    // 最终是调用了FairSync中的
    final void lock() {
            acquire(1);
        }
    // 我们看看acquire
        public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }    

那我们看看在tryAcquire中发生了啥?如下所示:
获取锁

protected final boolean tryAcquire(int acquires) {
    // 首先是获取的当前的线程的一个引用,方便在后面操作
    final Thread current = Thread.currentThread();
    // 从信号量中后去当前竞争的情况,这是一个定义在AQS中的成员变量。用于记录当前的所有的状态。
    // 在不同的模式中的state有不同的取值。在独占的模式中这个值的初始化化为0
    int c = getState();
    // 假如获取到当前的值是0。在这里会有两种情况c=0。第一种就是刚初始化的时候还没有线程来获取锁
    //第二种就是获取的锁的线程在执行完成之后释放锁之后c也可能为0
    if (c == 0) {
         // 当c=0的时候首先会判断是否有排队的线程。详见判断排队线程的逻辑。
         // hasQueuedPredecessors返回了false。那么取反就是为真那就会执行
         // compareAndSetState将state使用CAS的方式修改为1。那在这里为何
         //需要使用CAS呢?我们想一想在这里当多个线程同时通过第一层判断进去的
         //时候都会去修改这个状态。但是为了保证修改的原子性使用了CAS
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
			//然后设置持有独占锁的线程为当前线程
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 在c != 0的情况就会执行这里逻辑。在c != 0会有两种情况。
    // 第一种:这个线程已经持有这个锁
    // 第二中:其他线程持有了这个锁
    // 在这里假如是当前的线程持有这个锁。那就直接修改的status的状态。
    // 那为何在这里没有使用CAS?原因很简单就是这个只会有一个线程进去
    // 不存在多线程并发修改的问题。那小伙伴可能又会问你的state不是一个
    // 全局共享的变量么。是因为state使用了volatile关键字。底层使用了MESI协议保证不同线程之	间的可见性
    // 也是在这里实现了重入
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    // 获取锁失败返回false
    return false;
}

判断是否有排队线程

public final boolean hasQueuedPredecessors() {	
	// 在这里的tail和head是定义在AQS中的两个变量。用于表示表示CLH队列的头节点以及尾节点
	// 只要是CLH队列尾空。那么初始化的时候tail == null & head == null 。在这里的判断
	// 条件h != t 返回false。所以整个方法就返回了false
    Node t = tail;
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

在上面我们看了获取锁的逻辑:可以总结为如下的流程图:
在这里插入图片描述
2.2在获取锁失败之后,就像我们在上面模拟的线程就会进入阻塞队列

进入阻塞队列的逻辑如下:

// 排队的逻辑
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))

// 进入阻塞队列。请先看构建等待队列的节点
final boolean acquireQueued(final Node node, int arg) {
   boolean failed = true;
   try {
       boolean interrupted = false;
       // 又是死循环
       for (;;) {
       		// 获取当前节点的前驱节点
           final Node p = node.predecessor();
           // 如果当前节点的前驱节点就是头节点。那么会再一次去尝试获取锁。那为何会在这里
           //再一次的去获取锁呢?原因很简单线程的状态切换的会消耗资源。我们都知道在Linux中
           //分为ULT和KLT。而线程的切换是需要从用户空间切换到内核空间,操作系统会把描述线程的
           //堆栈一起转入内核空间造成资源的使用。所以能不切换就不切换。在这里请求原因是我在正
           //准备切换的时候持有锁的线程释放了锁,刚好可以去获取能获取到那么就不用去阻塞
           if (p == head && tryAcquire(arg)) {
               // 当前的线程获取到了锁,那自然是需要把当前线程从等待队列中移除。那如何去移除
               // 只需要把节点中的prev以及thraed设置为null。让后将head指向该节点。
               setHead(node);
               // 然后将
               p.next = null; // help GC
               failed = false;
               return interrupted;
           }
           // 要是入队的节点不是head的下一个节点执行如下的逻辑。
           // 详见修改节点的前驱节点的状态
           // 当shouldParkAfterFailedAcquire返回true之后执行
           //parkAndCheckInterrupt。在parkAndCheckInterrupt调用LockSupport将线程阻塞
           // 返回操作之后的结构
           if (shouldParkAfterFailedAcquire(p, node) &&
               parkAndCheckInterrupt())
               interrupted = true;
       }
   } finally {
       if (failed)
           cancelAcquire(node);
   }
}

修改节点的前驱节点的状态

    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    	// 我们在前面说过waitStatus有五种状态分别是
    	// SIGNAL = -1
    	// CANCELLED =  1;
    	// CONDITION = -2;
    	// PROPAGATE = -3;
    	// 0 初始化的状态
    	// 在初始化的时候都没有对waitStatus进行复制。那他的默认初始化的值是0
        int ws = pred.waitStatus;
        // 当第一轮循环结束的时候,第二轮循环开始的时候。会执行到此处。直接返回true
        if (ws == Node.SIGNAL)
            return true;
        if (ws > 0) {
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
        	// 当初始化的值为0的之后。会执行这里逻辑
        	// 将前驱节点中的waitStatus修改为Node.SIGNAL。也就是-1
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

构建等待队列的节点

// 在AQS中的模式一共有两种。一个是独占static final Node EXCLUSIVE = null;
// 另外的一种是共享static final Node SHARED = new Node();
// 在这里的ReetrantLock使用的独占模式
private Node addWaiter(Node mode) {
	// 使用当前请求的线程以及的mode构建一个Node节点。那Node节点是啥呢?请看Node详解
    Node node = new Node(Thread.currentThread(), mode);
    // 在初始化的时候tail以及head都是null
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 当pred == null的时候执行入队的逻辑。详见初始化入队逻辑
    enq(node);
    return node;
}
``
**Node详解**
```java
// Node是描述的CLH的节点的一种数据结构。简单的来说就是和数据结构中的
//双向链表是非常相似的。但是呢与双向链表不同的是,他多个两个指针head、tail
//用于标识当前队列中等待的数据的情况
static final class Node {
	// 描述接待你的两种状态中的共享状态。在CountDownLatch以及CyclicBarrier会使用到
    static final Node SHARED = new Node();
   // 描述节点中的独占状态
    static final Node EXCLUSIVE = null;
    // 表示当前节点的生命周期中的取消。是由于异常等原因引起的
    static final int CANCELLED =  1;
    // 描述当前节点是可以被唤醒的
    static final int SIGNAL    = -1;
    // 条件特性
    static final int CONDITION = -2;
    // 描述传播的特性
    static final int PROPAGATE = -3;
    // 这个取值就是上述的四种加上初始化时候的0这五种状态
    volatile int waitStatus;
    // 当前节点的前驱指针
    volatile Node prev;
    // 当前节点的后继指针
    volatile Node next;
    //当前处于等待状态的线程
    volatile Thread thread;

    Node nextWaiter;
}

始化入队逻辑

private Node enq(final Node node) {
	// 看看又是死循环。装逼点说就是自旋
    for (;;) {
    	// CLH队列还没有初始化的时候head == null & tail == null
        Node t = tail;
        // 第一次初始化
        if (t == null) { 
        	// 通过CAS的方式将头部的节点初始化为空节点。初始化为空节点的原因是为了方便在后main操作。
        	//有头节点的链表操作复杂度比没有的会低很多。在这里初始化完成之后如图1所示
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            // 在第一遍循环初始化好了头节点,在一次判断t == null。不成立,执行如下逻辑
            // node的前驱指向初始化的节点
            node.prev = t;
            // 通过CAS的方式将tail节点指向新入队的节点
            if (compareAndSetTail(t, node)) {
                // 将t的后续节点指向新的节点。在修改完成之后的节点如图二所示
                t.next = node;
                return t;
            }
        }
    }
}

图一
在这里插入图片描述
图二
在这里插入图片描述
上述的说了这么多。简单来说就是当一个线程获取锁失败之后这个线程会被放入到阻塞队列。
那在这个过程中的流程图如下所示:
在这里插入图片描述
到此为止没有成功获取到锁的线程就已经成功的进入CLH队列。接下来应该就是如何唤醒这些在阻塞队列中的线程
2.3 唤醒在阻塞队列中的线程
唤醒等待队列中的线程肯定是在调用lock.unlock()的时候释放。那如何去释放锁的呢?
ReentrantLock中释放锁的方法

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

最终是通过调用AQS中的release释放的锁。如下所示:

public final boolean release(int arg) {
	// 尝试去释放锁,那释放锁的具体的逻辑是啥呢?详见释放锁的逻辑
   if (tryRelease(arg)) {
       Node h = head;
       // 头节点不为null说明队列中还有等待获取锁的线程。waitStatus。在前面阻塞的时候最后是将
       // waitStaus设置为-1。在这里-1不等于0执行unparkSuccessor。那在这个里main都执行了啥呢?
       if (h != null && h.waitStatus != 0)
           unparkSuccessor(h);
       return true;
   }
   return false;
}
private void unparkSuccessor(Node node) {
	// 获取当前的节点的waitStatus
    int ws = node.waitStatus;
    //如果当前节点的waitStatus小于0。使用CAS的方式将这个值修改为0。
    // 有没有感觉很奇怪。在进入阻塞队列的时候这个值的变化如下:0 -> -1
    // 当运行到这的时候变化为 -1 -> 0
    // 其实运行很简单:想想当前是公平锁的模式,只要线程被唤醒之后一定会获取到锁。但是在多线程非公平锁
    //模式下呢?一个线程被唤醒之后不一定能获取到锁。为何保证在这里将值从 -1 -> 0是让阻塞的线程
    // 继续使用自旋的方式去获取锁。
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

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

释放锁的逻辑

// 简单总结就是修改的信号量的值。然后将当前持有锁的那个变量修改为null
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;
}

上述内容可以简单总结为:在多线程非公平的模式下,需要持续的将获取锁失败的线程从阻塞状态改变不停的尝试获取锁。同时unpark被park的线程。

到此为止ReetrantLock获取锁、阻塞、释放锁的全部流程已经完成。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java中的AQS(AbstractQueuedSynchronizer)是实现锁和同步器的一种重要工具。在AQS中,一个节点表示一个线程,依次排列在一个双向队列中,同时使用CAS原子操作来保证线程安全。当多个线程对于同一资源竞争时,一个节点会被放置在队列的尾部,其他线程则在其之前等待,直到该资源可以被锁定。 当一个线程调用lock()方法进行锁定时,它会首先调用tryAcquire()方法尝试获取锁。如果当前资源尚未被锁定,则该线程成功获取锁,tryAcquire()返回true。如果当前资源已被锁定,则线程无法获取锁,tryAcquire()返回false。此时该线程就会被加入到等待队列中,同时被加入到前一个节点的后置节点中,即成为它的后继。然后该线程会在park()方法处等待,直到前一个节点释放了锁,再重新尝试获取锁。 在AQS中,当一个节点即将释放锁时,它会调用tryRelease()方法来释放锁,并唤醒后置节点以重试获取锁。如果当前节点没有后置节点,则不会发生任何操作。当一个线程在队列头部成功获取锁和资源时,该线程需要使用release()方法释放锁和资源,并唤醒等待队列中的后置节点。 总之,AQS中的锁机制是通过双向等待队列实现的,其中节点表示线程,使用CAS原子操作保证线程安全,并在tryAcquire()和tryRelease()方法中进行锁定和释放。该机制保证了多线程环境下资源的正确访问和线程的安全执行。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值