初探 JUC 并发编程:Java 中的并发队列 ConcurrentLinkedQueue 源码级解析

第七部分:Java 并发包中并发队列解析

7.1)ConcurrentLinkedQueue 原理探究

7.1.1)类图结构

ConcurrentLinkedQueue 底层通过单向链表的方式实现,其中有两个 volatile 类型的 Node 节点用来表示队列的首、尾节点。

    public ConcurrentLinkedQueue() {
        head = tail = new Node<E>(null);
    }

在默认的构造方法中,首和尾指向值为 null 的哨兵节点。新元素会被插入到队列的末尾,出队从队列对头获取第一个元素。

在 Node 节点中,维护着一个使用 volatile 变量修饰的 item 属性来存放节点的值;next 属性存储下一个节点的指针。

    private static class Node<E> {
        volatile E item;
        volatile Node<E> next;
    }

其内部使用 UnSafe 提供的 CAS 方法来保证出队和入队操作的原子性。

        // 通过 cas 操作设置节点的 item
        boolean casItem(E cmp, E val) {
            return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
        }

				// 通过 cas 操作设置 next 的值
        void lazySetNext(Node<E> val) {
            UNSAFE.putOrderedObject(this, nextOffset, val);
        }
        
				// 通过 cas 操作修改 next
        boolean casNext(Node<E> cmp, Node<E> val) {
            return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
        }

7.1.2) ConcurrentLinkedQueue 原理介绍

💡 offer 操作介绍

offer 操作是在队列的末尾添加一个元素,由于队列是无界队列,所以一定会返回 true ;当传入的值为 null 的时候,会抛出 NPE 异常。

这里先给出完整的代码和注释:

    public boolean offer(E e) {
		    // e 为空则抛出空指针异常
        checkNotNull(e);
        
        // 构造 node 节点
        final Node<E> newNode = new Node<E>(e);

				// 从尾节点开始插入
        for (Node<E> t = tail, p = t;;) {
            Node<E> q = p.next;
            // p 的下一个节点为空,说明 p 是最后一个节点
            if (q == null) {
            // 通过 CAS 操作设置 p 节点的 next
                if (p.casNext(null, newNode)) {
                    // 每插入两个节点的时候会执行这个方法
                    if (p != t) 
                        casTail(t, newNode);  // Failure is OK.
                    return true;
                }
            }
            
            // 当 p 节点(指向的是尾部节点,发生自旋的的时候)
            else if (p == q)
                // 多线程操作,由于 poll 操作移除元素之后可能会引发链表自旋
                // 通过这里找到新的 head
                // 下面这段代码的逻辑是这样的:
                // 如果 tail 在操作之前被改变了,就将其变为新的 tail
                // 反之则将其赋值为 head 节点
                p = (t != (t = tail)) ? t : head;
                
            else
                // 重新寻找尾节点
                p = (p != t && t != (t = tail)) ? t : q;
        }
    }

在进入方法时,首先检测传入的参数是否为 null ,如果是则直接抛出 NPE 异常;检测完节点后就构造一个新的节点 newNode

然后构造了一个无限循环

for (Node<E> t = tail, p = t;;)

这个循环唯一的出口是通过 CAS 操作成功插入节点后,这也就解释了上面说的这个方法一定会返回 true,下面来具体看这三个 if 分支分别处理的是什么情况:

第一个 if 分支

p.next 为 null 的时候,p 在循环的开始被赋值为了 tail 也就是尾节点,通过这个条件就确定了 p 此时为尾节点;但是多线程情况下会出现改变 tail 的情况,前面提到过 tail 是一个被 volatile 关键字修饰的变量,线程对于它的修改对于其他线程是可见的,每次获取 tail 都是获取最新的值。

然后尝试通过 CAS 操作来设置尾节点的 next ,每插入两个节点会重置一次 tail 的值,但是这里对于 tail 的修改没有要求一定要成功,也就是在多线程的环境下不一定保证 tail 就是真正的尾节点,但是在各种方法中都有充分的安全措施来弥补这个问题,继续看下去就能理解。

最后返回一个 true。


第二个 if 分支

在多线程下执行 poll 出队列的操作的时候,有可能会出现链表自旋的状况,也就是这个分支中出现的 p.next = p 情况;此时需要去重新设置头节点;但多线程情况下仍然很多线程同时操作这个链表,所以在修改之前先去判断链表的 tail 节点是否被重新设置,如果被重新设置了则将 p 赋值为这个新的节点。

p = (t != (t = tail)) ? t : head;

t != (t = tail) 的执行顺序是这样的:

  1. 首先,t 的当前值被拿来与新的 tail 进行比较。
  2. 接着,赋值操作 t = tail 执行。这会将 t 更新为新的 tail 的值。
  3. 最后,将更新后的 t 的值与原来的 t 进行比较。

第三个 if 分支

当链表状态正常且没有插入成功的情况下执行这个分支:

p = (p != t && t != (t = tail)) ? t : q;

在这里会重新去寻找尾节点,当 tail 节点在执行操作的时候被修改了则将 p 赋值为这个新的 tail,反之则将 p 赋值为 q,也就是 p.next


最后,让我们来分析一下上面的代码是如何确保线程安全性的:

  • 首先,链表的插入是用来原子操作 CAS 来执行,它是原子性的,多个线程同时尝试进行设置时,只有一个线程会成功。
  • 对于多线程下可能导致的自旋异常,采用了重新找新的 head 节点来解决。
  • 最后,在发现尾节点 t 在操作之前已经被改变时,会将当前节点 p 更新为新的尾节点 **t ,**确保了在多线程环境下链表的正确性。

因为上面的方法比较复杂,这里采用画图的形式来模拟两个节点插入的情况:

刚初始化的时候,head 和 next 都指向 item 为 null 的哨兵节点,此时我们执行一个插入的操作

				// 从尾节点开始插入
        for (Node<E> t = tail, p = t;;) {
            Node<E> q = p.next;
            // p 的下一个节点为空,说明 p 是最后一个节点
            if (q == null) {
            // 通过 CAS 操作设置 p 节点的 next
                if (p.casNext(null, newNode)) {
                    // 每插入两个节点的时候会执行这个方法
                    if (p != t) 
                        casTail(t, newNode);  // Failure is OK.
                    return true;
                }
            }

在这里插入图片描述

此时队列中没有任何元素,所以此时指向的 q == null 是成立的,此时执行插入操作设置下一个节点为新创建的节点

在这里插入图片描述

此时去判断 p 是否等于 t,此时发现是相等的,所以直接返回 true

然后我们来插入第二个节点,此时指针的指向和上面相同,但是此时发现 q 并不是 null,此时执行的就是这个 if 分支:

 						else
                // 重新寻找尾节点
                p = (p != t && t != (t = tail)) ? t : q;

此时 p 会指向 q,在进行下一次循环时,指针的指向是这样的:

在这里插入图片描述

此时执行完和第一次一样的插入逻辑之后,t 就不等于 p,此时就更新 tail 为新的节点,所以每当插入两个节点之后会更新一次 tail。

在这里插入图片描述

💡 add 操作

在链表的末尾添加一个元素,底层仍然是调用的 offer() 方法:

    public boolean add(E e) {
        return offer(e);
    }

💡 poll 操作

这个方法的作用是在队列的头部获取并移除一个元素,如果队列为空则会返回 null。

这里给出完整的代码和注释:

    public E poll() {
		    // goto 标记
        restartFromHead:
      
        for (;;) {
            for (Node<E> h = head, p = h, q;;) {
		            // 存储当前循环中节点的值
                E item = p.item;
								
								// 当前节点有值,且通过 CAS 将其变为 null
                if (item != null && p.casItem(item, null)) {
                    if (p != h) // 两次 poll 会更新一次头节点
                        updateHead(h, ((q = p.next) != null) ? q : p);
                    return item;
                }              
                // 队列为空则返回 null
                else if ((q = p.next) == null) {
                    updateHead(h, p);
                    return null;
                }
                // 如果当前节点被自引用了,重新寻找头节点
                else if (p == q)
                    continue restartFromHead;
                    
                else
                    p = q;
            }
        }
    }
    
    final void updateHead(Node<E> h, Node<E> p) {
        if (h != p && casHead(h, p))
            h.lazySetNext(h);
    }

在本方法中我们要注意 updateHead() 方法的使用时机,这个方法中会更新头节点 head 为传入的新节点,并且使原来的头节点 自旋。第一个 if 分支中的 unpdateHead 方法和上面的 offer 方法相同,也是在两次 poll 才会更新一次 head 节点;但与 offer 方法不同的是线·

方法最前面的是一个 goto 语句,使用 goto 语句可以跳到指定的位置,**goto**语句在过去的编程语言中曾经被广泛使用,但是它往往导致代码的可读性和可维护性变差,所以在编写代码的时候不建议使用,只需要看懂即可。

这里先来模拟一个线程弹出两个节点的情况

此时将 p 指向的节点的 item 修改为 null,并且返回 p 节点的值

在这里插入图片描述

执行第二次弹出的时候,发现 p 指向的 item 为 null,且队列不为空、没有出现自引用的情况,所以此时后移 p 到 next 的位置,此时正常执行前面的逻辑弹出节点,并且更新 head 的值。

在这里插入图片描述

最终会达到上图的效果,这是单个线程在正常情况下得到的结果,下面来模拟多个线程同时操控这个队列导致的其他情况。

如果一个线程执行下面这一步,也就是第一个 if 的时候,其他线程获取并且弹出了节点 1(将节点 2的 item 设置为了 null),假设这个线程此时因为某些原因(比如被中断)没有执行 updateHead 方法,那此时的结果如右图,此时其他线程遍历到第二个 null 节点的时候会将头节点放到其正确的位置上,也就是执行 updateHead 并且返回 null。

在这里插入图片描述

而当在执行 poll 操作的时候其他线程执行了 updateHead 方法可能会使得此节点指向的节点变为自旋节点,此时需要重新寻找 head 节点:

                // 如果当前节点被自引用了,重新寻找头节点
                else if (p == q)
                    continue restartFromHead;

上面的方法保证了 head 每两次弹出就会更新一次(即使在多线程的情况下),同时采用节点自旋的方法防止节点被重复的获取。

💡 peek 操作

peek 操作是在不移除元素的情况下获取队列头部的一个元素,如果队列为空则返回 null。

    public E peek() {
        restartFromHead:
        for (;;) {
            for (Node<E> h = head, p = h, q;;) {
                E item = p.item;
                // 可以获取到元素或者这是队列中最后一个位置(可以为 null)
                if (item != null || (q = p.next) == null) {
                // 更新头节点
                    updateHead(h, p);
                    return item;
                }
                else if (p == q)
                    continue restartFromHead;
                else
                    p = q;
            }
        }
    }

看完上面的 poll 方法,本方法还是比较好懂的

方法返回的条件为 当前节点的 item 有值 或者 当前节点为队列中最后一个节点,此时更新头节点的值并且返回 item。

如果出现 p == q 也就是自旋的时候,重新寻找头节点。

如果没有遍历到队列中有值的节点,且还有后续的节点就后移 p 指针继续寻找。

💡 size 操作

通过 size 方法计算队列中的元素个数,这个方法在并发环境下并不是很有用,因为 CAS 没有加锁,所以调用 size 函数的期间可能增加或删除元素,导致统计结果的不准确。

    public int size() {
        int count = 0;
        // 通过 first 方法来获取队列中的第一个元素,排除哨兵节点
        // succ 方法获取当前节点的 next 元素,如果自旋的话就返回头节点
        for (Node<E> p = first(); p != null; p = succ(p))
            if (p.item != null)
                if (++count == Integer.MAX_VALUE)
                    break;
        return count;
    }
    
    final Node<E> succ(Node<E> p) {
        Node<E> next = p.next;
        return (p == next) ? head : next;
    }

方法中首先调用 first 方法排除哨兵节点,获取真正的第一个节点,然后不断后移节点去计算 count 的值;此方法只能统计到 Integer.MAX_VALUE 即使队列是个无界队列;当统计完成后返回 count。

size 方法的实现逻辑比较简单,来看一下里面调用的 first 方法

    Node<E> first() {
        restartFromHead:
        for (;;) {
            for (Node<E> h = head, p = h, q;;) {
                boolean hasItem = (p.item != null);
                if (hasItem || (q = p.next) == null) {
                    updateHead(h, p);
                    return hasItem ? p : null;
                }
                else if (p == q)
                    continue restartFromHead;
                else
                    p = q;
            }
        }
    }

方法中会返回第一个不为 null 的节点,如果没有就返回 null。

到这里可以我们已经见到了 updateHead 的所有使用位置,来总结一下它的使用时机

  • 当执行 poll 方法的时候调用了两次
    • 第一次是头节点没有指向此时弹出节点的时候
    • 第二次是发现队列为空的时候
  • 在执行 peek 操作的时候调用了一次
    • 每次获取元素或者发现队列为空的时候会更新头节点
  • 最后就是在上面的 first 方法中调用了一次
    • 当找到第一个 item 不为 null 的节点或者发现队列为空的时候会执行一次

通过上面的方法,在多线程的情况下,也能保证 head 时刻在执行读操作的时候处于正确的位置。

💡 remove 操作

删除队列中的元素,如果存在多个则删除第一个,并且返回 true,没有找到则返回 false。

    public boolean remove(Object o) {
    // o 为空直接返回 false
        if (o != null) {
            Node<E> next, pred = null;
            for (Node<E> p = first(); p != null; pred = p, p = next) {
                boolean removed = false;
                E item = p.item;
                if (item != null) {
                // 如果相等则使用 CAS 设置为 null
                // 多线程情况下只有一个线程会成功,其他线程会继续查找
                // 是否有匹配的其他元素
                    if (!o.equals(item)) {
                        next = succ(p); // 获取 next 元素
                        continue;
                    }
                    removed = p.casItem(item, null);
                }

                next = succ(p);
                
                // 如果有后续的节点的话,前驱节点链接
                // p 节点指向的位置会因为无法到达而被销毁
                if (pred != null && next != null) // unlink
                    pred.casNext(p, next);
                if (removed)
                    return true;
            }
        }
        return false;
    }

💡 contains 操作

这个方法回去判断队列中是否有指定的对象,由于和 size 方法一样是遍历整个队列,所以结果不是那么精确,比如调用的时候元素在队列中,但是在遍历途中元素被删除,会返回 false。

    public boolean contains(Object o) {
        if (o == null) return false;
        for (Node<E> p = first(); p != null; p = succ(p)) {
            E item = p.item;
            if (item != null && o.equals(item))
                return true;
        }
        return false;
    }

方法的执行逻辑和 size 相同,这里不过多赘述。

7.1.3)总结

ConcurrentLinkedQueue 底层使用单线填表数据结构来保存队列元素,每个元素被封装成一个 Node 节点;队列通过 head 和 tail 来维护的,创建队列的时候头尾节点会指向哨兵节点,第一次执行 peek 或者 first 的时候才会把 head 指向第一个真正的队列元素。

由于没有加锁,所以 size 或者 contains 会导致结果不准确。

出队和入队的操作都是操作 tail 和 head 节点,保证在多线程的情况下的线程安全,只需要保证操作的可见性和原子性即可,由于两个属性都是 volatile 修饰的,保证了可见性,同时方法中使用 CAS 保证了原子性。

  • 27
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

*Soo_Young*

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值