java高并发编程基础之AQS

简介: java高并发编程基础之AQS

引言

曾经有一道比较比较经典的面试题“你能够说说java的并发包下面有哪些常见的类?”大多数人应该都可以说出
CountDownLatch、CyclicBarrier、Sempahore多线程并发三大利器。这三大利器都是通过AbstractQueuedSynchronizer抽象类(下面简写AQS)来实现的,所以学习三大利器之前我们有必要先来学习下AQS

AQS是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架

AQS结构

说到同步我们如何来保证同步?大家第一印象肯定是加锁了,说到锁的话大家肯定首先会想到的是Synchronized。
Synchronized大家应该基本上都会使用,加锁和释放锁都是jvm 来帮我们实现的,我们只需要简单的加个 Synchronized关键字就可以了。
用起来超级方便。但是有没有一种情况我们设置一个锁的超时时间Synchronized就有点实现不了,这时候我们就可以用ReentrantLock来实现,ReentrantLock是通过aqs来实现的,今天我们就通过ReentrantLock来学习一下aqs。

CAS && 公平锁和非公平锁

AQS里面用到了大量的CAS学习AQS之前我们还是有必要简单的先了解下CAS、公平锁和非公平锁。

CAS
  • CAS 全称是 compare and swap,是一种用于在多线程环境下实现同步功能的机制。CAS 操作包含三个操作数 -- 内存位置、预期数值和新值。CAS 的实现逻辑是将内存位置处的数值与预期数值想比较,若相等,则将内存位置处的值替换为新值。若不相等,则不做任何操作,这个操作是个原子性操作,java里面的AtomicInteger等类都是通过cas来实现的。
公平锁和非公平锁
  • 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,队列中第一个才能获得到锁。

优点:等待锁的线程不会饿死,每个线程都可以获取到锁。
缺点:整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

  • 非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
缺点:处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
文字有点拗口,我们来个实际的例子说明下。比如我们去食堂就餐的时候都要排队,大家都按照先来后到的顺序排队打饭,这就是公平锁。如果等到你准备拿盘子打饭的时候
直接蹦出了一个五大三粗的胖子插队到你前面,你看打不赢他只能忍气吞声让他插队,等胖子打完饭了又来个小个子也来插你队,这时候你没法忍了,直接大吼一声让他滚,这个
小个子只能屁颠屁颠到队尾去排队了这就是非公平锁。
我们先来看看AQS有哪些属性

// 头结点
private transient volatile Node head;

// 阻塞的尾节点,每个新的节点进来,都插入到最后,也就形成了一个链表
private transient volatile Node tail;

// 这个是最重要的,代表当前锁的状态,0代表没有被占用,大于 0 代表有线程持有当前锁
// 这个值可以大于 1,是因为锁可以重入,每次重入都加上 1
private volatile int state;

// 代表当前持有独占锁的线程,举个最重要的使用例子,因为锁可以重入
// reentrantLock.lock()可以嵌套调用多次,所以每次用这个来判断当前线程是否已经拥有了锁
// if (currentThread == getExclusiveOwnerThread()) {state++}
private transient Thread exclusiveOwnerThread; //继承自AbstractOwnableSynchronizer

下面我们来写一个demo分析下lock 加锁和释放锁的过程

   final void lock() {
            // 上来先试试直接把状态置位1,如果此时没人获取锁就直接
            if (compareAndSetState(0, 1))
                 // 争抢成功则修改获得锁状态的线程
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

cas尝试失败,说明已经有人再持有锁,所以进入acquire方法

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

tryAcquire方法,看名字大概能猜出什么意思,就是试一试。
tryAcquire实际上是调用了父类Sync的nonfairTryAcquire方法

  final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
             // 获取下当前锁的状态
            int c = getState();
            // 这个if 逻辑跟前面一进来就获取锁的逻辑一样都是通过cas尝试获取下锁
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            // 进入这个判断说明 锁重入了 状态需要进行+1
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                 // 如果锁的重入次数大于int的最大值,直接就抛出异常了,正常情况应该不存在这种情况,不过jdk还是严谨的
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            // 返回false 说明尝试获取锁失败了,失败了就要进行acquireQueued方法了
            return false;
        }

tryAcquire方法如果获取锁失败了,那么肯定就要排队等待获取锁。排队的线程需要待在哪里等待获取锁?这个就跟我们线程池执行任务一样,线程池把任务都封装成一个work,然后当线程处理任务不过来的时候,就把任务放到队列里面。AQS同样也是类似的,把排队等待获取锁的线程封装成一个NODE。然后再把NODE放入到一个队列里面。队列如下所示,不过需要注意一点head是不存NODE的。
在这里插入图片描述

接下来我们继续分析源码,看下获取锁失败是如何被加入队列的。
就要执行acquireQueued方法,执行acquireQueued方法之前需要先执行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;
            // cas 加入队列队尾
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        // 尾结点不为空 || cas 加入尾结点失败
        enq(node);
        return node;
    }

enq

接下来再看看enq方法

// 通过自旋和CAS一定要当前node加入队尾
private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            // 尾结点为空说明队列还是空的,还没有被初始化,所以初始化头结点,可以看到头结点的node 是没有绑定线程的也就是不存数据的
            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方法已经把获取锁的线程通过封装成一个NODE加入对列。上述方法的一个执行流程图如下:
在这里插入图片描述
,接下来就是继续执行acquireQueued方法

acquireQueued

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                 // 通过自旋去获取锁 前驱节点==head的时候去尝试获取锁,这个方法在前面已经分析过了。
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
               // 进入这个if说明node的前驱节点不等于head 或者尝试获取锁失败了
               // 判断是否需要挂起当前线程
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
               // 异常情况进入cancelAcquire,在jdk11的时候这个源码直接是catch (Throwable e){ cancelAcquire(node);} 简单明了
            if (failed)
                cancelAcquire(node);
        }
    }

setHead

这个方法每当有一个node获取到锁了,就把当前node节点设置为头节点,可以简单的看做当前节点获取到锁了就把当前节点”移除“(变为头结点)队列。

shouldParkAfterFailedAcquire

说到这个方法我们就要先看下NODE可能会有哪些状态在源码里面我们可以看到总共会有四种状态

  • CANCELLED:值为1,在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后的结点将不会再变化。
  • SIGNAL:值为-1,被标识为该等待唤醒状态的后继结点,当其前继结点的线程释放了同步锁或被取消,将会通知该后继结点的线程执行。说白了,就是处于唤醒状态,只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行。
  • CONDITION:值为-2,与Condition相关,该标识的结点处于等待队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
  • PROPAGATE:值为-3,与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        // 前驱节点状态 如果这个状态为-1 则返回true,把当前线程挂起
        if (ws == Node.SIGNAL)
            return true;
        // 大于0,说明状态为CANCELLED 
        if (ws > 0) {
            do {
               // 删除被取消的node(让被取消的node成为一个没有引用的node等着下次GC被回收)
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            // 进入这里只能是 0,-2,-3。NODE节点初始化的时候waitStatus默认值是0,所以只有这里才有修改waitStatus的地方
            // 通过cas 把前驱节点的状态设置为-1,然后返回false ,外面调用这个方法的是个循环,又会调用一次这个方法
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

parkAndCheckInterrupt

挂起当前线程,并且阻塞

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this); // 挂起当前线程,阻塞
    return Thread.interrupted();
}

在这里插入图片描述

解锁

加锁成功了,那锁用完了就应该释放锁了,释放锁重点看下unparkSuccessor这个方法就好了

 private void unparkSuccessor(Node node) {
          // 头结点状态
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        Node s = node.next;
        // s==null head的successor节点获取锁成功后,执行了head.next=null的操作后,解锁线程读取了head.next,因此s==null
        // head的successor节点被取消(cancelAcquire)时,执行了如下操作:successor.waitStatus=1 ; successor.next = successor;
        if (s == null || s.waitStatus > 0) {
            s = null;
            // 从尾节点开始往前找,找到最前面的非取消的节点 这里没有break 哦
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
             // 唤醒线程 ,唤醒的线程会从acquireQueued去获取锁
            LockSupport.unpark(s.thread);
    }

释放锁代码比较简单,基本都写在代码注释里面了,流程如下:
在这里插入图片描述
这段代码里面有一个比较经典的面试题:
如果头结点的下一个节点为空或者头结点的下一个节点的状态为取消的时候为什么要从后往前找,找到最前面非取消的节点?

  • node.prev = pred; compareAndSetTail(pred, node) 这两个地方可以看作Tail入队的原子操作,但是此时pred.next = node;还没执行,如果这个时候执行了unparkSuccessor方法,就没办法从前往后找了,所以需要从后往前找。
  • 在产生CANCELLED状态节点的时候,先断开的是Next指针,Prev指针并未断开,因此也是必须要从后往前遍历才能够遍历完全部的Node

总结

  • reentrantLock的获取锁和释放锁基本就讲完了,里面还涉及多比较多的细节,感兴趣的同学可以对着源码一行一行去debug试试。
  • 适当的了解aqs才能更好的学习CountDownLatch、CyclicBarrier、Sempahore,因为这三个利器都是基于aqs来实现的。

结束

  • 由于自己才疏学浅,难免会有纰漏,假如你发现了错误的地方,还望留言给我指出来,我会对其加以修正。
  • 如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。
  • 感谢您的阅读,十分欢迎并感谢您的关注。

站在巨人的肩膀上摘苹果:
https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html
https://javadoop.com/post/AbstractQueuedSynchronizer
https://www.cnblogs.com/yanlong300/p/10953185.html

目录
相关文章
|
1天前
|
缓存 负载均衡 安全
|
1天前
|
Java
"揭秘Java IO三大模式:BIO、NIO、AIO背后的秘密!为何AIO成为高并发时代的宠儿,你的选择对了吗?"
【8月更文挑战第19天】在Java的IO编程中,BIO、NIO与AIO代表了三种不同的IO处理机制。BIO采用同步阻塞模型,每个连接需单独线程处理,适用于连接少且稳定的场景。NIO引入了非阻塞性质,利用Channel、Buffer与Selector实现多路复用,提升了效率与吞吐量。AIO则是真正的异步IO,在JDK 7中引入,通过回调或Future机制在IO操作完成后通知应用,适合高并发场景。选择合适的模型对构建高效网络应用至关重要。
|
1天前
|
设计模式 算法 安全
Java编程中的设计模式:提升代码的可维护性和扩展性
【8月更文挑战第19天】在软件开发的世界里,设计模式是解决常见问题的一种优雅方式。本文将深入探讨Java编程语言中常用的几种设计模式,并解释如何通过这些模式来提高代码的可维护性和扩展性。文章不涉及具体的代码实现,而是侧重于理论和实践相结合的方式,为读者提供一种思考和改善现有项目的新视角。
|
1天前
|
安全 Java 测试技术
深入探讨Java安全编程的最佳实践,帮助开发者保障应用的安全性
在网络安全日益重要的今天,确保Java应用的安全性成为了开发者必须面对的课题。本文介绍Java安全编程的最佳实践,包括利用FindBugs等工具进行代码审查、严格验证用户输入以防攻击、运用输出编码避免XSS等漏洞、实施访问控制确保授权访问、采用加密技术保护敏感数据等。此外,还强调了使用最新Java版本、遵循最小权限原则及定期安全测试的重要性。通过这些实践,开发者能有效提升Java应用的安全防护水平。
5 1
|
1天前
|
Java 开发者
在Java编程中,if-else与switch作为核心的条件控制语句,各有千秋。if-else基于条件分支,适用于复杂逻辑;而switch则擅长处理枚举或固定选项列表,提供简洁高效的解决方案
在Java编程中,if-else与switch作为核心的条件控制语句,各有千秋。if-else基于条件分支,适用于复杂逻辑;而switch则擅长处理枚举或固定选项列表,提供简洁高效的解决方案。本文通过技术综述及示例代码,剖析两者在性能上的差异。if-else具有短路特性,但条件增多时JVM会优化提升性能;switch则利用跳转表机制,在处理大量固定选项时表现出色。通过实验对比可见,switch在重复case值处理上通常更快。尽管如此,选择时还需兼顾代码的可读性和维护性。理解这些细节有助于开发者编写出既高效又优雅的Java代码。
6 2
|
1天前
|
Java 开发者
在Java编程的广阔天地中,if-else与switch语句犹如两位老练的舵手,引领着代码的流向,决定着程序的走向。
在Java编程中,if-else与switch语句是条件判断的两大利器。本文通过丰富的示例,深入浅出地解析两者的特点与应用场景。if-else适用于逻辑复杂的判断,而switch则在处理固定选项或多分支选择时更为高效。从逻辑复杂度、可读性到性能考量,我们将帮助你掌握何时选用哪种语句,让你在编程时更加得心应手。无论面对何种挑战,都能找到最适合的解决方案。
6 1
|
1天前
|
搜索推荐 Java 程序员
在Java编程的旅程中,条件语句是每位开发者不可或缺的伙伴,它如同导航系统,引导着程序根据不同的情况做出响应。
在Java编程中,条件语句是引导程序根据不同情境作出响应的核心工具。本文通过四个案例深入浅出地介绍了如何巧妙运用if-else与switch语句。从基础的用户登录验证到利用switch处理枚举类型,再到条件语句的嵌套与组合,最后探讨了代码的优化与重构。每个案例都旨在帮助开发者提升编码效率与代码质量,无论是初学者还是资深程序员,都能从中获得灵感,让自己的Java代码更加优雅和专业。
5 1
|
1天前
|
Java
在Java编程的广阔天地中,条件语句是控制程序流程、实现逻辑判断的重要工具。
在Java编程中,if-else与switch作为核心条件语句,各具特色。if-else以其高度灵活性,适用于复杂逻辑判断,支持多种条件组合;而switch在多分支选择上表现优异,尤其适合处理枚举类型或固定选项集,通过内部跳转表提高执行效率。两者各有千秋:if-else擅长复杂逻辑,switch则在多分支选择中更胜一筹。理解它们的特点并在合适场景下使用,能够编写出更高效、易读的Java代码。
5 1
|
4天前
|
设计模式 算法 Java
Java编程中的设计模式:简化复杂性的艺术
在Java的世界中,设计模式如同一位智慧的导师,指引着开发者们在复杂的编码迷宫中找到出口。本文将深入浅出地探讨几种常见的设计模式,通过实例演示如何在Java项目实践中运用这些模式,从而提升代码的可维护性和扩展性。无论你是新手还是资深开发者,这篇文章都将为你打开一扇通往高效编码的大门。
12 1
|
1天前
|
安全 Java 程序员
阿里开发手册 嵩山版-编程规约 (四)OOP规约-Java程序员必看知识点!!!
《阿里开发手册 嵩山版》的OOP规约部分强调了面向对象编程的最佳实践,包括正确使用静态方法、覆写方法的注解、可变参数的使用、接口的稳定性、equals和compareTo方法的使用、BigDecimal的正确比较、包装类与基本数据类型选择、POJO类的属性和方法设计等,以提升代码的质量和维护性。
9 0