本文主要是介绍Java进阶篇--并发容器之ConcurrentLinkedQueue,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
目录
ConcurrentLinkedQueue简介
Node
操作Node的几个CAS操作
offer方法
poll方法
HOPS的设计
扩展知识
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<>();// 单个线程offerThread singleThreadOffer = new Thread(() -> {for (int i = 0; i < 5; i++) {queue.offer(i);System.out.println("单个线程offer元素:" + i);}});// 多个线程offerThread multipleThreadsOffer = new Thread(() -> {for (int i = 5; i < 10; i++) {queue.offer(i);System.out.println("多个线程offer元素:" + i);}});// 部分线程offer,部分线程pollThread 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的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!