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

相关文章

SpringBoot中HTTP连接池的配置与优化

《SpringBoot中HTTP连接池的配置与优化》这篇文章主要为大家详细介绍了SpringBoot中HTTP连接池的配置与优化的相关知识,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一... 目录一、HTTP连接池的核心价值二、Spring Boot集成方案方案1:Apache HttpCl

Spring Boot项目打包和运行的操作方法

《SpringBoot项目打包和运行的操作方法》SpringBoot应用内嵌了Web服务器,所以基于SpringBoot开发的web应用也可以独立运行,无须部署到其他Web服务器中,下面以打包dem... 目录一、打包为JAR包并运行1.打包为可执行的 JAR 包2.运行 JAR 包二、打包为WAR包并运行

Java进行日期解析与格式化的实现代码

《Java进行日期解析与格式化的实现代码》使用Java搭配ApacheCommonsLang3和Natty库,可以实现灵活高效的日期解析与格式化,本文将通过相关示例为大家讲讲具体的实践操作,需要的可以... 目录一、背景二、依赖介绍1. Apache Commons Lang32. Natty三、核心实现代

Spring Boot 常用注解整理(最全收藏版)

《SpringBoot常用注解整理(最全收藏版)》本文系统整理了常用的Spring/SpringBoot注解,按照功能分类进行介绍,每个注解都会涵盖其含义、提供来源、应用场景以及代码示例,帮助开发... 目录Spring & Spring Boot 常用注解整理一、Spring Boot 核心注解二、Spr

SpringBoot实现接口数据加解密的三种实战方案

《SpringBoot实现接口数据加解密的三种实战方案》在金融支付、用户隐私信息传输等场景中,接口数据若以明文传输,极易被中间人攻击窃取,SpringBoot提供了多种优雅的加解密实现方案,本文将从原... 目录一、为什么需要接口数据加解密?二、核心加解密算法选择1. 对称加密(AES)2. 非对称加密(R

详解如何在SpringBoot控制器中处理用户数据

《详解如何在SpringBoot控制器中处理用户数据》在SpringBoot应用开发中,控制器(Controller)扮演着至关重要的角色,它负责接收用户请求、处理数据并返回响应,本文将深入浅出地讲解... 目录一、获取请求参数1.1 获取查询参数1.2 获取路径参数二、处理表单提交2.1 处理表单数据三、

java变量内存中存储的使用方式

《java变量内存中存储的使用方式》:本文主要介绍java变量内存中存储的使用方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录1、介绍2、变量的定义3、 变量的类型4、 变量的作用域5、 内存中的存储方式总结1、介绍在 Java 中,变量是用于存储程序中数据

如何合理管控Java语言的异常

《如何合理管控Java语言的异常》:本文主要介绍如何合理管控Java语言的异常问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录1、介绍2、Thorwable类3、Error4、Exception类4.1、检查异常4.2、运行时异常5、处理方式5.1. 捕获异常

Spring Boot集成SLF4j从基础到高级实践(最新推荐)

《SpringBoot集成SLF4j从基础到高级实践(最新推荐)》SLF4j(SimpleLoggingFacadeforJava)是一个日志门面(Facade),不是具体的日志实现,这篇文章主要介... 目录一、日志框架概述与SLF4j简介1.1 为什么需要日志框架1.2 主流日志框架对比1.3 SLF4

Spring Boot集成Logback终极指南之从基础到高级配置实战指南

《SpringBoot集成Logback终极指南之从基础到高级配置实战指南》Logback是一个可靠、通用且快速的Java日志框架,作为Log4j的继承者,由Log4j创始人设计,:本文主要介绍... 目录一、Logback简介与Spring Boot集成基础1.1 Logback是什么?1.2 Sprin