Java多线程基础-18:线程安全的集合类与ConcurrentHashMap

Java标准库提供了很多集合类,但有一些集合类是线程不安全的,也就是说,在多线程环境下可能会出问题的。常用的ArrayList,LinkedList,HashMap,PriorityQueue等都是线程不安全的(Vector, Stack, HashTable是线程安全的,但实际并不建议用),它们在单线程的环境下没有问题,但如果在多线程环境下尤其是多个线程操作同一个集合类对象时,就可能出现麻烦。

如果这些单线程中的集合类确实需要在多线程中使用,该怎么办呢?思路有两个:

  1. 最直接的方式:使用锁,手动保证。如多个线程修改ArrayList对象,此时就可能有问题,就可以给修改操作进行加锁。但手动加锁的方式并不是很方便,因此标准库还提供了一些线程安全的集合类。
  2. 使用JUC提供的线程安全版本的集合类。

本文就第二点:线程安全的集合类来展开说明。

目录

一、多线程环境使用顺序表

1、Vector

2、Collections.synchronizedList(new ArrayList)

3、CopyOnWriteArrayList

二、多线程环境使用队列

三、多线程使用哈希表

***HashTable和ConcurrentHashMap的区别

1、加锁粒度不同,ConcurrentHashMap锁粒度更小

2、ConcurrentHashMap能更充分地利用CAS机制

3、ConcurrentHashMap优化了扩容策略

四、相关问题总结

1、ConcurrentHashMap的读是否要加锁,为什么?

2、ConcurrentHashMap的锁分段技术?

3、ConcurrentHashMap在jdk1.8做了哪些优化?

4、Hashtable和HashMap、ConcurrentHashMap 之间的区别?


一、多线程环境使用顺序表

1、Vector

如果要使用ArrayList,可以用Vector来代替。

Vector功能上和ArrayList类似,是一个可以实现随机存取的顺序表,区别在于Vector的关键方法都是带有synchronized的,它比直接使用ArrayList更线程安全。

特别注意:虽然Vector线程安全,但它作为一个“上古时期”的集合类,实际上已经被Java官方标记为不推荐使用了。

2、Collections.synchronizedList(new ArrayList)

synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List,Collections.synchronizedList()可以理解成一个“壳”,使用这个“壳”套一下你想用的List集合类对象,它就可以让被套的集合类对象变成线程安全的。

原理也很简单:这个方法使得被包裹的集合类对象的关键操作都带上 synchronized。

这样一来,如果使用时不需要考虑线程安全,那么直接使用集合类对象本身(如new ArrayList)即可;如果要考虑线程安全,给原来的集合类对象套一个壳,套完壳之后对象里的各种方法还能继续用,而内部也会帮我们把锁加好。

3、CopyOnWriteArrayList

CopyOnWrite容器即写时拷贝的容器。CopyOnWriteArrayList则是一个基于ArrayList的支持写时拷贝的集合类。

写时拷贝容器与用加锁的思路不同,没有加锁而是通过多个线程修改不同变量这一方式实现线程安全的(出现线程安全问题的条件之一是多个线程修改同一变量,写时拷贝破坏了这个条件)。

当我们修改一个容器的时候,不直接修改当前容器本身,而是先将当前容器进行Copy, 复制出一个新的容器,然后对新的容器进行修改。修改完成之后,再将原容器的引用指向新的容器。

这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为在当前容器上并不执行任何修改操作。

因此,CopyOnWrite容器也是一种读写分离的思想,读和写采用不同的容器。可以简单将写时拷贝理解为:一要修改的时候就拷贝一份,过程如下:

假设此时要把arr1中的2改为100:

注意:在复制和修改副本时,别的线程如果读取arr1,读的还是旧的那一份内容。当已经对副本执行过修改操作后,直接将arr1指向副本内容,而旧的部分则被丢弃:

这样一来,修改操作就完成了。写时拷贝机制和顺序表的扩容操作有些类似,而由于这样的引用赋值操作本身就是原子的,写时拷贝就可以不用加锁也完成修改,保证线程安全。

写时拷贝机制也叫“双缓冲”,在日常中也是比较常见的。如显示器显示画面,也是通过上述类似的过程完成的。

我们知道,显示器中的画面是显卡来渲染的,显卡的任务就是不停地画图。如果显卡的频率为60hz,那就表示它1s要画60张图。

显卡在画图过程中显然是要消耗一定时间的,它的过程就类似于写时拷贝:显示器这边在显示上一张图,而显卡在偷偷地画下一张图;等到显卡的下一张画好了,来一个迅速的替换,这一显示器上的画面就到了下一帧。

由于换图的时候下一帧已经准备好了,所以整体的画面切换也就比较流畅。如果当前画面太复杂了(比如有4k分辨率的画面),而显卡又太烂跟不上,那它画图就画不过来了;如果在规定时间内没来得及画完,此时就可能出现掉帧或画面撕裂的情况(卡成PPT)。

CopyOnWriteArrayList的特点是虽然不加锁,没有锁竞争的开销,但是拷贝开销很大,因此适合于修改不频繁、元素个数少的场景。


二、多线程环境使用队列

多线程使用队列,直接有BlockingQueue:

1、BlockingQueue是一个接口,真正实现的类有:

  • ArrayBlockingQueue:基于数组实现的阻塞队列。
  • LinkedBlockingQueue:基于链表实现的阻塞队列。
  • PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列。
  • TransferQueue:最多只包含一个元素的阻塞队列。

2、put 方法用于阻塞式的入队列,take 用于阻塞式的出队列。

3、BlockingQueue 也有 offer,poll,peek 等方法,但是这些方法不带有阻塞特性。

阻塞队列的经典应用是生产者消费者模型。具体介绍见:Java多线程基础-9:代码案例之阻塞队列


三、多线程使用哈希表

HashMap 本身不是线程安全的,在多线程环境下使用哈希表可以使用:

  1. HashTable
  2. ConcurrentHashMap

查看HashTable源码可知,它和上面的Vector类似,是直接给关键方法加synchronized(相当于给this加锁):

实际中HashTable并不是一个好的选择,ConcurrentHashMap是HashTable的全面升级版本,官方推荐使用。如果多线程中需要用到哈希表,应重点使用这个。

***HashTable和ConcurrentHashMap的区别

1、加锁粒度不同,ConcurrentHashMap锁粒度更小

锁粒度即加锁操作所涵盖的代码的范围,进一步可以引申出某个加锁操作触发锁冲突的频率。

HashTable是直接针对 Hashtable 对象本身加锁,也即对整个哈希表加锁,因此任何的增删改查操作都会触发加锁,也就都可能会有锁竞争。

我们知道,哈希表结构一般是数组+链表,插入元素到哈希表的流程是先根据key计算出hash值得到数组下标,然后把这个要插入的新元素挂在对应下标的链表上(Java的HashMap还会在链表太长的时候把链表变成红黑树)。

思考一个场景:此时如果有两个线程,分别要插入一个元素到哈希表中,线程1插入的元素对应在下标为1的链表上,而线程2插入的元素对应在下标为2的链表上,此时会产生线程安全问题吗?

显然,这属于两个线程修改不同的变量,是线程安全的。但此时,一把全局的锁的弊病就显现出来了:虽然两个线程的插入操作各不相干,但由于所有的关键方法都是对同一个锁对象this加锁,造成明明两个操作没有线程安全问题,但线程之间还是会针对同一个锁对象产生锁竞争,产生阻塞等待!

实际上,没有必要把锁加得如此勤快。相比之下,ConcurrentHashMap就科学多了。

ConcurrentHashMap不是只有一把锁,而是给每一个链表都发了一把锁(把每个链表的头节点分别放入synchronized里面)。这样一来,每次进行操作都是针对对应链表的锁对象进行加锁;操作不同的链表就是针对不同的锁加锁,因此此时不会产生锁冲突。

以ConcurrentHashMap的put操作为例,伪代码如下:

void put(String key, String value) {
    //先找到对应的头节点
    int index = hashCode(key);
    Node head = getHead(index);
    synchronized(head) {
        //执行链表插入节点的操作
    }
}

观察源码可以验证,伪代码的思路是正确的:

对于HashTable和ConcurrentHashMap锁粒度的区别,可以理解为HashTable是只有一个“坑”,所有人都来抢这一个“坑”,所以竞争就大,冲突的概率就大;而ConcurrentHashMap则是有很多个“坑”,数组长度有多大就有多少个“坑”,这样一来冲突的概率就小了,这也就导致了大部分的加锁操作是没有锁冲突的。没有锁冲突就没有阻塞等待,那么这里甚至可以直接用偏向锁来搞定……总之,这里加锁操作的开销就微乎其微了,效率是很高的。

上述内容也即锁粒度的不同,是ConcurrentHashMap和HashTable之间最大的区别,通过对加锁粒度的调整使得ConcurrentHashMap的并发能力大大提升。

(补充:上述内容是从 Java 8 开始的。在 Java 1.7 及其之前,ConcurrentHashMap使用的是“分段锁”。它的目的和上述是类似的,只是它相当于是好几个链表共用同一把锁。这个设定其实不太科学,因为不仅效率不够高,代码写起来也麻烦。只要了解到分段锁是ConcurrentHashMap历史上的一种实现方式即可。)

2、ConcurrentHashMap能更充分地利用CAS机制

什么是CAS机制:Java多线程基础-12:详解CAS算法

CAS机制最大的作用是实现无锁编程。在ConcurrentHashMap中做了一些改进:部分操作如获取/更新元素个数,就可以直接使用CAS完成而不必加锁了。

CAS也能保证线程安全,且往往比锁更高效。不过CAS并不会经常使用,因为它的适用范围有限,不像锁那么广,有的操作如维护一个整数变量值的变化就比较适合CAS。

总之,ConcurrentHashMap更充分地利用了CAS,能提高它自身的效率。

3、ConcurrentHashMap优化了扩容策略

对于HashTable,如果元素太多,就会涉及到扩容,这里就涉及哈希表的负载因子的概念。

负载因子=整个哈希表中已存的总元素个数 ➗ 数组的长度,即平均每个哈希桶(链表)上有多少个元素。

当负载因子达到阈值(比如0.75),就需要把哈希表扩容。扩容需要重新申请内存空间并且搬运元素(把元素从旧的哈希表上删掉,然后插入新的哈希表)。可见,如果当前哈希表内元素非常多(比如有上亿个),搬运一次成本就非常高,从而导致这一次put操作非常卡顿。put操作原本是O(1)的,但当出发扩容操作并引发卡顿时效率会非常低,作为用户我们又无法预知哪一次put会触发卡顿,因此这个事情还是比较让人头疼的。

ConcurrentHashMap采用了化整为零的策略。它并不会试图一次性把所有元素都搬运过去,而是每次搬运一部分,是一个渐进的过程。当put触发扩容时,此时仍然会直接创建更大的内存空间,但并不会直接把所有元素都搬运到新的哈希表上,而是只搬运一小部分(速度较快)。此时相当于同时存在新旧两份哈希表(新表指扩容后的哈希表,旧表指扩容前的):

  1. 插入元素,直接往新的哈希表中插入。
  2. 删除元素,看待删元素在新表还是旧表。先在新表中查找待删元素,如果元素在新表中被找到并成功删除,则删除操作完成;如果新表里没找到,就在旧表里找并删除。
  3. 查找元素,在新表旧表中都查,步骤和删除操作类似。

并且,在每次操作过程中都会再搬运一部分。就和蚂蚁搬家一样,虽然每次搬的少,但多搬几次,也就很快搬完了。通过这样化整为零的方式,就避免了某一次扩容操作特别卡顿。

具体流程见这位大佬的博客:ConcurrentHashMap1.8 - 扩容详解

当然,ConcurrentHashMap避免卡顿、保持程序运行稳定的代价是消耗了更多的空间。

(另外,如果要在多线程环境下使用Set,也可以借助ConcurrentHashMap,只使用key而不使用value(如将value全置0)即可。)


四、ConcurrentHashMap相关问题总结

1、ConcurrentHashMap的读是否要加锁,为什么?

读操作没有加锁,目的是为了进一步降低锁冲突的概率。为了保证读到刚修改的数据, 搭配了volatile 关键字。

2、ConcurrentHashMap的锁分段技术?

这个是 Java1.7 中采取的技术,Java1.8 中已经不再使用了。简单的说就是把若干个哈希桶分成一个 “段” (Segment),针对每个段分别加锁。

它的目的也是为了降低锁竞争的概率。当两个线程访问的数据恰好在同一个段上的时候,才触发锁竞争。

3、ConcurrentHashMap在jdk1.8做了哪些优化?

取消了分段锁,直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)。

将原来数组 + 链表的实现方式改进成数组 + 链表 / 红黑树的方式。当链表较长的时候(大于等于8个元素)就转换成红黑树。

4、Hashtable和HashMap、ConcurrentHashMap 之间的区别?

  • HashMap:线程不安全,key 允许为 null。
  • Hashtable:线程安全,使用 synchronized 锁整个 Hashtable 对象,效率较低。key 不允许为 null。
  • ConcurrentHashMap:线程安全,使用 synchronized 锁每个链表头结点,锁冲突概率低;充分利用 CAS 机制;优化了扩容方式。key 不允许为 null。

ConcurrentHashMap源码分析见这位大佬的博客:ConcurrentHashMap源码分析(JDK8版本)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/353738.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【C语言/数据结构】排序(选择排序,推排序,冒泡排序)

🌈个人主页:秦jh__https://blog.csdn.net/qinjh_?spm1010.2135.3001.5343🔥 系列专栏:《数据结构》https://blog.csdn.net/qinjh_/category_12536791.html?spm1001.2014.3001.5482 ​​​​ 目录 选择排序 选择排序 ​编辑…

【开源】基于JAVA语言的学生综合素质评价系统

目录 一、摘要1.1 项目介绍1.2 项目录屏 二、功能模块2.1 学生功能2.2 教师功能2.3 教务处功能 三、系统展示四、核心代码4.1 查询我的学科竞赛4.2 保存单个问卷4.3 根据类型查询学生问卷4.4 填写语数外评价4.5 填写品德自评问卷分 五、免责说明 一、摘要 1.1 项目介绍 基于J…

美睫师睫毛嫁接零基础学习,日式美睫与开花嫁接实战教学

一、教程描述 大家都说女人的钱好挣,这是因为每个女人在每年,都要花很多钱来打扮自己。本套教程是关于日式美睫和开花嫁接的,从零基础学习到店铺经营都有涉及,就做美睫和睫毛嫁接这两项业务,月收入万元以上应该问题不…

ubuntu 22.04 安装mysql-8.0.34

ubuntu 22.04 安装mysql-8.0.34 1、基础安装配置 更新软件包: sudo apt update查看可用软件包: sudo apt search mysql-server安装最新版本: sudo apt install -y mysql-server或者,安装指定版本: sudo apt inst…

202|读书笔记《金融的本质:伯南克四讲美联储》

今天跟朋友聊天💬,说已经没人看书了📖 我想, 还是会有人读书的吧。 ​ 一、美联储的起源和使命 1. 第一讲:美国南北战争结束后的40年间,美国经历了6次大的银行体系恐慌,促使其于1913年成立美联储…

第八篇【传奇开心果系列】beeware的toga开发移动应用示例:实现消消乐安卓手机小游戏

传奇开心果博文系列 系列博文目录beeware的toga开发移动应用示例系列博文目录一、项目目标二、安装依赖三、初步实现四、扩展思路五、实现游戏逻辑示例代码六、实现界面设计示例代码七、实现增加关卡和难度示例代码八、实现存档和排行榜示例代码九、实现添加特殊方块和道具示例…

C语言第十一弹---函数(下)

​ ✨个人主页: 熬夜学编程的小林 💗系列专栏: 【C语言详解】 【数据结构详解】 函数 1、嵌套调用和链式访问 1.1、嵌套调用 1.2、链式访问 2、函数的声明和定义 2.1、单个文件 2.2、多个文件 2.3、static 和 extern 2.3.1、static…

第六课:Prompt

文章目录 第六课:Prompt1、学习总结:Prompt介绍预训练和微调模型回顾挑战 Pre-train, Prompt, PredictPrompting是什么?prompting流程prompt设计 课程ppt及代码地址 2、学习心得:3、经验分享:4、课程反馈:5、使用Mind…

08. BI - 万字长文,银行如何做贷款违约的预测,特征处理及学习

本文为 「茶桁的 AI 秘籍 - BI 篇 第 08 篇」 文章目录 课程回顾案例分析案例实战 Hi, 你好。我是茶桁。 课程回顾 上节课,咱们讲了一个股票的指标:MACD。在趋势行情里面它应该还是有效的指标。它比较忌讳动荡行情,比如说它一会上…

###C语言程序设计-----C语言学习(5)#

前言:感谢您的关注哦,我会持续更新编程相关知识,愿您在这里有所收获。如果有任何问题,欢迎沟通交流!期待与您在学习编程的道路上共同进步! 一. 主干知识的学习 1.switch语句 switch语句可以处理多分支选…

ubuntu 22 安装 node,npm,vue

1:安装 nodejs sudo apt update curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt update && sudo apt install -y nodejs node -v 2:安装npm sudo npm install n -g npm -v 3:安装vite npm install vite -g 4:运行vue 把项目拷贝到…

公考之判断推理(一、图形推理)

一、前言 判断推理这一题型主要具体分为四种题型: 1.图形推理 2.类比推理 3.定义判断 4.逻辑判断每种题型做题方法又不一样。 才本文采用总分的形式结构。 每一小标题的下面紧接着就是总结。二、图形推理常见的命题形式 图形推理常见的命题形式: 1.…

鸿蒙ArkUI 宫格+列表+HttpAPI实现

鸿蒙ArkUI学习实现一个轮播图、一个九宫格、一个图文列表。然后请求第三方HTTPAPI加载数据,使用了axios鸿蒙扩展库来实现第三方API数据加载并动态显示数据。 import {navigateTo } from ../common/Pageimport axios, {AxiosResponse } from ohos/axiosinterface IDa…

ASP.NET Core基础之用扩展方法封装服务配置

阅读本文你的收获 了解C#中的扩展方法机制学会在ASP.NET Core 中,用扩展方法封装服务配置,使得代码更加简洁 一、什么是扩展方法 扩展方法使能够向现有类型添加方法,而无需创建新的派生类型、重新编译或以其他方式修改原始类型。 扩展方法…

从 React 到 Qwik:开启高效前端开发的新篇章

1. Qwik Qwik 是一个为构建高性能的 Web 应用程序而设计的前端 JavaScript 框架,它专注于提供即时启动性能,即使是在移动设备上。Qwik 的关键特性是它采用了称为“恢复性”的技术,该技术消除了传统前端框架中常见的 hydration 过程。 恢复性是一种序列化和恢复应用程序状态…

HbuilderX报错“Error: Fail to open IDE“,以及运行之后没有打开微信开发者,或者运行没有反应的解决办法

开始 问题:HbuilderX启动时,打开微信开发者工具报错"Error: Fail to open IDE",以及运行之后没有打开微信开发者,或者运行没有反应的解决办法! 解决办法: 按照步骤一步一步完成分析,除非代码报错,否则都是可以启动的 第一步:检查HbuildX是否登录账号 第二步:检查微信…

背后的魔术师----jsp

作为一名对技术充满热情的学习者,我一直以来都深刻地体会到知识的广度和深度。在这个不断演变的数字时代,我远非专家,而是一位不断追求进步的旅行者。通过这篇博客,我想分享我在某个领域的学习经验,与大家共同探讨、共…

嵌入式软件工程师面试题——2025校招社招通用(C/C++)(四十四)

说明: 面试群,群号: 228447240面试题来源于网络书籍,公司题目以及博主原创或修改(题目大部分来源于各种公司);文中很多题目,或许大家直接编译器写完,1分钟就出结果了。但…

Linux编译实时内核和打补丁

目录 1.Linux内核2.实时内核3.编译实时内核3.1 准备3.2 获取内核源码3.3 编译3.4 设置GRUB确保启动到实时内核 4.给内核打补丁5.安装新的内核 1.Linux内核 https://github.com/torvalds/linux Linux内核是Linux操作系统的核心部分,它是操作系统的基本组成部分&…

研发日记,Matlab/Simulink避坑指南(七)——数据溢出钳位Bug

文章目录 前言 背景介绍 问题描述 分析排查 解决方案 总结归纳 前言 见《研发日记,Matlab/Simulink避坑指南(二)——非对称数据溢出Bug》 见《研发日记,Matlab/Simulink避坑指南(三)——向上取整Bug》 见《研发日记,Matlab/Simulink避坑…
最新文章