Java进阶篇--并发容器之ConcurrentLinkedQueue

2023-10-21 03:28

本文主要是介绍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的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/251598

相关文章

Spring Boot中WebSocket常用使用方法详解

《SpringBoot中WebSocket常用使用方法详解》本文从WebSocket的基础概念出发,详细介绍了SpringBoot集成WebSocket的步骤,并重点讲解了常用的使用方法,包括简单消... 目录一、WebSocket基础概念1.1 什么是WebSocket1.2 WebSocket与HTTP

SpringBoot+Docker+Graylog 如何让错误自动报警

《SpringBoot+Docker+Graylog如何让错误自动报警》SpringBoot默认使用SLF4J与Logback,支持多日志级别和配置方式,可输出到控制台、文件及远程服务器,集成ELK... 目录01 Spring Boot 默认日志框架解析02 Spring Boot 日志级别详解03 Sp

java中反射Reflection的4个作用详解

《java中反射Reflection的4个作用详解》反射Reflection是Java等编程语言中的一个重要特性,它允许程序在运行时进行自我检查和对内部成员(如字段、方法、类等)的操作,本文将详细介绍... 目录作用1、在运行时判断任意一个对象所属的类作用2、在运行时构造任意一个类的对象作用3、在运行时判断

java如何解压zip压缩包

《java如何解压zip压缩包》:本文主要介绍java如何解压zip压缩包问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录Java解压zip压缩包实例代码结果如下总结java解压zip压缩包坐在旁边的小伙伴问我怎么用 java 将服务器上的压缩文件解压出来,

SpringBoot中SM2公钥加密、私钥解密的实现示例详解

《SpringBoot中SM2公钥加密、私钥解密的实现示例详解》本文介绍了如何在SpringBoot项目中实现SM2公钥加密和私钥解密的功能,通过使用Hutool库和BouncyCastle依赖,简化... 目录一、前言1、加密信息(示例)2、加密结果(示例)二、实现代码1、yml文件配置2、创建SM2工具

Spring WebFlux 与 WebClient 使用指南及最佳实践

《SpringWebFlux与WebClient使用指南及最佳实践》WebClient是SpringWebFlux模块提供的非阻塞、响应式HTTP客户端,基于ProjectReactor实现,... 目录Spring WebFlux 与 WebClient 使用指南1. WebClient 概述2. 核心依

Spring Boot @RestControllerAdvice全局异常处理最佳实践

《SpringBoot@RestControllerAdvice全局异常处理最佳实践》本文详解SpringBoot中通过@RestControllerAdvice实现全局异常处理,强调代码复用、统... 目录前言一、为什么要使用全局异常处理?二、核心注解解析1. @RestControllerAdvice2

Spring IoC 容器的使用详解(最新整理)

《SpringIoC容器的使用详解(最新整理)》文章介绍了Spring框架中的应用分层思想与IoC容器原理,通过分层解耦业务逻辑、数据访问等模块,IoC容器利用@Component注解管理Bean... 目录1. 应用分层2. IoC 的介绍3. IoC 容器的使用3.1. bean 的存储3.2. 方法注

Spring事务传播机制最佳实践

《Spring事务传播机制最佳实践》Spring的事务传播机制为我们提供了优雅的解决方案,本文将带您深入理解这一机制,掌握不同场景下的最佳实践,感兴趣的朋友一起看看吧... 目录1. 什么是事务传播行为2. Spring支持的七种事务传播行为2.1 REQUIRED(默认)2.2 SUPPORTS2

怎样通过分析GC日志来定位Java进程的内存问题

《怎样通过分析GC日志来定位Java进程的内存问题》:本文主要介绍怎样通过分析GC日志来定位Java进程的内存问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、GC 日志基础配置1. 启用详细 GC 日志2. 不同收集器的日志格式二、关键指标与分析维度1.