Java并发容器ConcurrentLinkedQueue解析
作者:世俗ˊ
ConcurrentLinkedQueue简介
在单线程编程中常用的集合类,如ArrayList和HashMap等,但是这些类都不是线程安全的类。为了保证线程安全,可以使用Vector作为替代,但其通过使用synchronized独占锁在方法级别实现线程安全,从而使多线程执行变为串行化,效率较低。另外,也可以使用Collections.synchronizedList(List<T> list)将ArrayList转换为线程安全的,但仍然是通过synchronized修饰方法实现的,依然不是高效的方式。
针对队列这种常用的数据结构,在解决线程安全问题时,Doug Lea大师提供了ConcurrentLinkedQueue,这是一个线程安全的队列。根据类名可以看出,它基于链表实现队列的数据结构。
Node
Node节点是用于链表数据结构中,构成ConcurrentLinkedQueue的基本单元。节点包含了两个字段:一个是item,用于存储元素数据;另一个是next,用于指向下一个节点,从而实现链表结构。
在ConcurrentLinkedQueue中,由于要支持并发操作,因此使用了volatile关键字对节点的item和next字段进行修饰。volatile关键字可以保证在多线程环境下的可见性,从而避免了出现脏读等线程安全问题。
在ConcurrentLinkedQueue的构造函数中,会初始化头尾节点,同时将head和tail指向同一个初始节点。这个节点的item为空(null),表示队列中还没有任何元素。
通过不断添加和删除节点,就可以实现ConcurrentLinkedQueue,该队列是线程安全的,由于使用了无锁算法(CAS操作),因此具有较高的吞吐量。
操作Node的几个CAS操作
在ConcurrentLinkedQueue中,对Node节点的CAS操作(关于CAS操作可以看这篇文章),有以下几个方法:
- casItem(E cmp, E val): 用于比较并交换节点的数据域item。这个操作会比较节点的item域是否等于cmp,如果相等则将其替换为val。返回true表示交换成功,返回false表示交换失败。
- lazySetNext(Node<E> val): 用于延迟设置节点的下一个节点指针next。这个操作会将节点的next字段设置为val,但不保证立即可见。即使在执行这个操作之后,其他线程读取到的节点的next值可能仍然是旧值。这种操作通常在性能上比强制可见性要好。
- casNext(Node<E> cmp, Node<E> val): 用于比较并交换节点的下一个节点指针next。这个操作会比较节点的next域是否等于cmp,如果相等则将其替换为val。返回true表示交换成功,返回false表示交换失败。
//更改Node中的数据域item boolean casItem(E cmp, E val) { return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val); } //更改Node中的指针域next void lazySetNext(Node<E> val) { UNSAFE.putOrderedObject(this, nextOffset, val); } //更改Node中的指针域next boolean casNext(Node<E> cmp, Node<E> val) { return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val); }
需要注意的是,这些方法中都是通过调用sun.misc.Unsafe类的相关方法来实现CAS操作。Unsafe类是Java底层提供的一个类,用于支持直接操作内存和并发操作,它提供了一些原子性操作的方法,包括compareAndSwapObject()等方法。这些方法利用处理器指令集的CMPXCHG指令来实现原子性的比较和交换操作。
CAS操作在并发编程中非常重要,它可以避免使用锁机制而实现线程安全,提高并发性能。然而,需要注意的是CAS操作并不是适用于所有情况,有时候可能会存在ABA问题等需要注意的情况。
offer方法
1. 单个线程offer:当只有一个线程执行offer操作时,它会将元素插入到队列的末尾。由于没有其他线程执行poll操作,所以队列的长度会不断增长。
2. 多个线程offer:当多个线程同时执行offer操作时,它们会将元素插入到队列的末尾。因为offer操作总是在队列的末尾进行,而poll操作总是在队列的队头进行,所以这两类线程并不会相互影响。
3. 部分线程offer,部分线程poll:
- offer速度快于poll:如果offer操作的速度比poll操作快,那么队列的长度会越来越长。因为offer节点总是在队列的末尾插入,而poll节点总是在队列的队头删除,所以两类线程之间没有交集,可以看作是一个"单线程offer"的情况。
- offer速度慢于poll:如果poll操作的速度比offer操作快,那么队列的长度会越来越短。此时,offer线程和poll线程会存在交集,即在某一时刻会发生同时操作的节点,这个时刻被称为临界点。在临界点时,需要考虑在offer方法中的处理逻辑。
在多线程环境下,ConcurrentLinkedQueue的offer方法可以保证并发安全,无需额外的同步措施。它能够高效地支持并发插入操作,并且遵循FIFO(先进先出)特性。
import java.util.concurrent.ConcurrentLinkedQueue; public class main { public static void main(String[] args) { // 创建一个ConcurrentLinkedQueue对象 ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>(); // 单个线程offer Thread singleThreadOffer = new Thread(() -> { for (int i = 0; i < 5; i++) { queue.offer(i); System.out.println("单个线程offer元素:" + i); } }); // 多个线程offer Thread multipleThreadsOffer = new Thread(() -> { for (int i = 5; i < 10; i++) { queue.offer(i); System.out.println("多个线程offer元素:" + i); } }); // 部分线程offer,部分线程poll Thread mixedThreads = new Thread(() -> { for (int i = 0; i < 5; i++) { if (i % 2 == 0) { queue.offer(i); System.out.println("部分线程offer元素:" + i); } else { Integer element = queue.poll(); System.out.println("部分线程poll元素:" + element); } } }); // 启动线程 singleThreadOffer.start(); multipleThreadsOffer.start(); mixedThreads.start(); // 等待线程执行完毕 try { singleThreadOffer.join(); multipleThreadsOffer.join(); mixedThreads.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
上述代码演示了三种场景:
- 单个线程执行offer操作,将元素逐个插入队列;
- 多个线程同时执行offer操作,将元素逐个插入队列;
- 部分线程执行offer操作,部分线程执行poll操作,实现元素的插入和移除。
在每个线程中,通过queue.offer(i)方法将元素插入队列,使用queue.poll()方法从队列移除元素。每个操作都会打印出相应的信息以便观察结果。
ConcurrentLinkedQueue的offer和poll方法可以安全地在多线程环境下使用,并且遵循FIFO(先进先出)特性。
poll方法
- 在单线程下,poll操作会先判断队头节点的数据是否为空,如果不为空,则直接返回数据并将该节点出队;否则需要遍历队列寻找数据不为空的节点,并更新队头的指向。
- 在多线程情况下,需要注意处理多个线程同时poll和offer的情况,保证操作的正确性。同时,在判断队列是否为空的时候,不能仅仅依靠poll返回值是否为null,而应该使用isEmpty方法进行判断。
import java.util.Queue; import java.util.concurrent.*; public class main { // 创建一个ConcurrentLinkedQueue对象 private static Queue<Integer> queue = new ConcurrentLinkedQueue<>(); public static void main(String[] args) throws InterruptedException { // 将数字放入队列中 queue.offer(1); queue.offer(2); queue.offer(3); // 从队列中取出元素并输出 Integer value = queue.poll(); System.out.println("取出的值为:" + value); System.out.println("当前队列中的元素为:" + queue.toString()); // 创建一个线程池 ExecutorService executor = Executors.newCachedThreadPool(); // 循环10次,每次提交一个任务到线程池中 for (int i = 0; i < 10; i++) { executor.execute(() -> { // 将数字放入队列中 queue.offer((int) (Math.random() * 100)); // 从队列中取出元素并输出 Integer result = queue.poll(); System.out.println(Thread.currentThread().getName() + " 取出的值为:" + result); System.out.println("当前队列中的元素为:" + queue.toString()); }); } // 关闭线程池 executor.shutdown(); // 等待线程池中的任务执行完成 executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); } }
在这个综合代码示例中,首先在单个线程环境下将数字放入队列中并取出元素,并输出队列的当前状态。接着,在多线程环境下,我们创建了一个线程池并提交了10个任务,每个任务都会随机生成一个数字放入队列中,然后再从队列中取出元素并输出,最后输出队列的当前状态。可以看到,在单个线程和多个线程的情况下,使用ConcurrentLinkedQueue的poll方法都能够保证队列操作的线程安全性,并且在多线程环境下能够提高程序的并发性能。
HOPS的设计
通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:
HOPS的设计通过延迟更新的方式,减少CAS操作的频率,从而提升入队操作的性能。具体来说,tail的更新被延迟至插入节点之后,只有当tail指向的节点的下一个节点不为null时才会进行真正的队尾节点定位和更新操作。同样地,head的更新也被延迟至删除节点之后,只有当head指向的节点的item域为null时才会进行队头节点定位和更新操作。
这种延迟更新策略的设计意图是为了减少CAS操作对性能的影响。如果每次都立即更新tail或head,那么大量的入队或出队操作都需要执行CAS操作来更新tail或head,从而对性能产生较大的负担。通过延迟更新,在一定程度上减少了CAS操作的频率,提升了入队操作的效率。
尽管延迟更新会增加在循环中定位队尾节点的操作,但整体而言,读取操作的性能要远远高于写操作。因此,增加的在循环中定位尾节点的操作性能损耗相对较小。
总结来说,HOPS设计中延迟更新的策略通过减少CAS操作的频率,提升了入队操作的性能,同时在读取操作性能与写入操作性能的权衡中取得了较好的平衡。
扩展知识
tail和head是ConcurrentLinkedQueue队列中两个重要的指针。它们分别指向队列中的尾节点和头节点。
当需要往队列中插入元素时,我们需要使用tail指针指向的节点作为插入节点的前驱节点,并通过CAS(compare-and-swap)操作将插入节点加在其后面。如果插入节点成功地加入了队列尾部,则需要使用CAS操作将tail指针指向新的队尾节点。
当需要从队列中删除元素时,我们需要使用head指针指向的节点作为要删除的节点,并通过CAS操作将其指向下一个节点。如果删除成功,则需要使用updateHead方法将head指针指向真正的队头节点。
需要注意的是,tail和head指针的更新会存在延迟,只有在特定条件下才会进行更新。具体来说,当tail指向的节点的下一个节点不为null时,会执行定位队列真正的队尾节点的操作,并通过CAS操作完成tail的更新;当head指向的节点的item域为null时,会执行定位队列真正的队头节点的操作,并通过updateHead方法完成head的更新。这种延迟更新策略可以有效地减少CAS操作的频率,提高队列的性能。
到此这篇关于Java并发容器ConcurrentLinkedQueue解析的文章就介绍到这了,更多相关Java的ConcurrentLinkedQueue内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!