CurrentHashMap的整体系统介绍及Java内存模型(JVM)介绍

当我们提到ConurrentHashMap时,先想到的就是HashMap不是线程安全的:

在多个线程共同操作HashMap时,会出现一个数据不一致的问题。

ConcurrentHashMap是HashMap的线程安全版本。

它通过在相应的方法上加锁,来保证多线程情况下的数据一致性。

hashmap导致数据不一致的原因?

数据不一致问题的表象有两种情况:

1.写-读冲突:一个线程修改后,另一个线程读到的不是最新的数据。

2.写-写冲突:两个线程同时修改数据,发生数据覆盖的情况。

原因是Java内存模型(JVM)的一些相关规定。

Java内存模型(JVM)

Java内存模型将内存分为两种,主内存工作内存。

并且规定,所有的变量都存储在主内存中(不包括局部变量与方法参数)。

主内存中的变量是所有线程共享的。

每个线程都有自己的工作内存,存储的是当前线程所使用到的变量值。主内存变量中的一个副本数据。

线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

不同线程间无法直接访问对方工作内存中的变量。

线程间变量值的传递需要通过主内存实现。

这样规定的原因:

是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

关于各种硬件间的内存访问差异

CPU,内存,IO设备都在不断迭代,不断朝着更快的方向努力,但三者的速度是有差异的。

CPU最快,内存其次,IO设备(硬盘)最慢。

为了合理利用CPU的高性能,平衡三者间的速度差异,计算机体系结构,操作系统,编译系统都做了贡献,主要体现为:

  • CPU增加了缓存,以平衡与内存的速度差异,

这样CPU运算时所需要的变量,优先会从缓存中读取。

缓存没有时,会从主内存中加载并缓存。如下图所示:

image-20250509163155756

事物都是有两面性的,缓存提高了CPU的运算速度,也带来了相应的问题:

当多个线程在不同的CPU上运行并访问同一个变量时,由于缓存的存在,可能读取不到做最新的值,也就是可见性问题。

可见性指的是一个线程对共享变量的修改,另一个线程能够立刻看到,被称为可见性

  • 操作系统增加了进程线程,以时分复用CPU,进而均衡CPU与IO设备的速度差异

操作系统通过任务的一个切换来减少CPU的等待时间,从而提高效率。

任务切换的时间,可能是发生在任何一条CPU指令执行完之后。

但是我们平时使用的编程语言,如C,Java,Python等都是高级语言,高级语言转换成CPU指令时,一条指令可能对应多条CPU指令。 相当于1=n,这是违背我们直觉的地方。

所以问题来了,著名的count+=1问题就是这个原因。也就是原子性问题。

我们把一个或多个操作在CPU执行的过程中不被中断的特性为原子性。(这里的操作是指我们高级语言中相应的一些操作)

  • 编译程序优化指令执行次序,使得缓存能够得到更加合理的利用。

指令重排序可以提高了缓存的利用率,同样也带来了有序性问题

也就是单例模式问题

重排序提高缓存利用率的例子:

在平时写代码时,经常会在方法内部的开始位置,把这个方法用到的变量全部声明了一遍。缓存的容量是有限的,声明的变量多的时候 前面的变量可能就会在缓存中失效 。

接下来再写业务时,用到了最先声明的变量 然后发现在缓存中已经失效了,需要重新的去主内存进行加载。

所以指令重排序可以看成编译器对我们写的代码进行的一个优化。就类似于让变量都能用上,不至于等到失效在使用。

所以要想实现在各种平台都能达到一直的内存访问效果,就需要解决硬件和操作系统之间产生的问题:

1.CPU增加缓存最后导致的可见性问题

2.操作系统增加了线程,进程之后出现的原子性问题

3.指令重排序导致的有序性问题

Java内存模型如何解决三个问题?

原子性问题解决方案

  • JVM定义了8种操作来完成主内存与工作内存之间的数据交互,虚拟机在实现时需要保证每一种操作都是原子的,不可再分的。

Java中基本数据类型的访问、读写都是具备原子性的(long和Double除外),更大的原子性保证:Java提供了synchronized关键字(synchronized的字节码指令monitorenter和monitorexit来隐式的使用了lock和unlock操作),在synchronized块之间的操作也具备原子性。

八种操作: lock,unlock,read,load,assign,use,store,write

CAS(乐观锁),比较并替换,(Compare And Swap),CAS是一条CPU的原子指令(即cmpxchg指令),Java中的Unsafe类提供了相应的CAS方法,如(compareAndSwapXXX)底层实现即为CPU指令cmpxchg,从而保证操作的原子性。

可见性问题与有序性问题解决方案

  • JVM定义了Happens-Before原则来解决内存的不可见性与重排序的问题。

Happens-Before规则约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后要遵守Happens-Before规则。

Happens-Before规则:

对于两个操作A和B,这两个操作可以在不同的线程中执行,如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作时可见的。

8种Happens-Before规则

程序次序规则、锁定规则、volatile变量规则、线程启动规则、线程终止规则、线程中断规则、对象终结原则、传递性原则。

volatile变量规则(重点):对一个volatile变量的写操作先行发生于后面的这个变量的读操作。

hashmap导致数据不一致的解决方案

常规思路是加锁,但是锁的存在会大大影响性能,所以提升性能的关键就是减少锁的粒度,以及找出哪些操作可以无锁化

对于写操作:涉及到对数据的改动,需要加锁,这只能尽量减少锁的粒度。

对于读操作:确保数据改动不会出错之后,读操作就相对好办;主要考虑的能不能读到另外一个线程对数据的一个改动(一致性)(等待写操作的完成)

这时就有三种情况:

  1. 强一致性 : 读写都加锁,类似于串行化,这样可以保证读到最新的数据,但性能过低

  2. 顺序一致性 : 变量使用volatile关键字修饰

  3. 弱一致性 : 读不加锁

对应方案:

  1. 强一致性 使用synchronized 修饰方法或者代码块,来保证代码块或方法的一致性,可见性(串行,即有序性),性能较低

  2. 顺序一致性 : 使用volatile关键字修饰变量,volatile 可以保证一个共享变量的可见性以及禁止指令的重排序

  3. 弱一致性: 使用CAS,CAS操作可以保证一个共享变量的原子操作。

我们可以去读一下ConcurrentHashMap的源码,

可以发现代码中一会使用CAS,一会使用synchronized,让人摸不清,为什么呢?

这是因为在高级语言中一条语句往往需要多条CPU指令完成

而Java中基本数据类型的访问、读写都具备原子性(long和Double除外),其他大部分不是原子性操作,

就比如在new一个对象时,就不是一个原子性操作,它需要三步才能完成,分配内存,初始化对象,将对象赋值给变量。

所以在创建数组的时候,除了使用synchronized外,CAS是不能保证原子性的,CAS只是CPU的一条指令,他不能保证多个指令的原子性,但是我们可以参考AQS,使用CAS锁一个基本类型的变量,其他线程进行自旋。

其次,synchronized锁需要一个对象,当数组的元素为null时,是无法使用synchronized锁的,所以此时使用的就是CAS操作来保证赋值的原子性。

以及底层的数组table已经被volatile修饰,但是数组元素的修改却不能保证可见性

明明volatile保证共享变量的可见性,为什么数组元素的修改却不能保证可见性呢?

原因:

volatile保证共享变量的可见性,但是如果该变量是一个对象的引用,那么volatile此时指的就是对象引用的可见性。

而在Java中,数组也是一个对象,当使用volatile来修饰数组arr时,代表的是arr的引用具有可见性,即arr的引用地址修改了之后,其他线程是可见的,但是无法保证数组内的元素具有可见性。

HashTable与ConcurrentHashMap

Hashtable

前置知识:在JDK1.0时,加锁只有synchronized一种方法,synchronized是重量级锁(需要去CPU申请锁)

底层结构:数组+链表 链表使用头插法 定位数组下标使用取余操作

线程安全: 使用synchronized来保证线程安全,在所有的方法上都加了synchronized关键字,即使用一把全局锁来同步不同线程间的并发访问(锁住整个table结构),性能较低。

相关操作: put,get,remove,size方法体上都添加synchronized关键字,扩容逻辑在put方法内发生,也是线程安全的

优点:实现简单

缺点:一个线程在插入数据时,其他线程不能读写,并发效率低下

ConcurrentHashMap(JDK1.5)

在JDK1.5时引入,此时Java内存模型已经成熟完善,在此基础上开发了java.util.concurrent包,ConcurrentHashMap随着JUC包一起引入JDK,同时引入了AQS,实现了ReentrantLock

底层结构:数组+链表 链表使用头插法 定位下标使用&运算

线程安全:使用分段锁的思想,其内部是一个Segment数组,Segment继承了ReentrantLock(可重复锁),即Segment自身就是一个锁。

Segment内部有一个HashEntry数组(Segment有点类似HashTable),每个HashEntry是一个链表结构的元素,一把锁只锁住容器中的一部分数据,多线程访问容器中里不同数据段的数据,就不会存在锁竞争,提高并发访问率

相关操作:调用put方法时,当前的segment会将自己锁住,此时其他线程无法操作这个segment,但不会影响到其他segment的操作。

调用get方法时,使用unsafe.getObjectVolatile方法获取节点;底层使用C++的volatile来实现Java中的volatile效果(保证共享变量的可见性(一个线程对共享变量的修改,另一个线程能够立刻看到))

调用remove方法时,当前的segment会将自己锁住。

put,get,remove操作都是在单个Segment上进行的,size操作是在多个segment进行的

size方法采用了一种比较巧妙的方式,来尽量避免对所有的Segment都加锁。

每个Segment都有一个modCount 变量,代表的是对Segment中元素的数量造成影响的操作次数。这个值只增不减。

size 操作就是遍历了两次Segment,每次记录Segment 的modCount值,然后将两次的modCount进行比较,如果相同,则表示期间没有发生过写入操作,就将原先遍历的结果返回。如果不相同,则把这个过程再重复做一次,如果再不同,则就需要将所有的Segment都锁住,然后一个一个遍历。

扩容操作,发生在put方法内部,跟put方法使用的是同一个锁.

扩容不会增加Segment的数量,只会增加Segment中链表数组的容量大小

这样的好处是扩容过程不需要对整个ConcurrentHashMaprehash,只需要对Segment里面的元素做一个rehash即可。这样就不会去影响其他的segment里面的元素。

优点:每次只锁住一部分数据,访问不同数据段的数据,不会存在锁竞争。提高了并发访问率;

扩容只针segment内部的HashEntry数组进行扩容,不影响其他segment内部的HashEntry数组。

缺点:定位一个元素,需要经过两次hash操作。 当某个segment很大时,类似Hashtable,性能会下降。

比较浪费内存空间(因为每个segment内部的HashEntry数组是不连续的)

拓展:

在JDK6中,针对synchronized做了大量的优化,引入了轻量级锁偏向锁。性能与ReentrantLock已相差无几,甚至synchronized的自动释放锁会更好用。

Java官方表示,在多线程环境下不建议使用HashMap。

随着互联网的快速发展,业务场景随之更加复杂,很多人在使用多线程的情况下使用HashMap的时候,结果导致cpu100%的情况。

主要原因:HashMap的链表使用的是头插法,在多线程的情况下触发扩容,链表可能会形成一个死循环。

在JDK8中也做了相应的优化,将头插法改为尾插法,引入了红黑树,来优化链表过长导致的查询速度变慢。

连带着ConcurrentHashMap也做了相应的修复,使得ConcurrentHashMap与HashMap的结构更加统一。

ConcurrentHashMap(JDK8之后)

image-20250509190726003

由类图可知,ConcurrentHashMap中有四种类型的节点,四种类型的节点的用途不同。

  • Node节点是ConcurrentHashMap中存储数据的最基本结构,也是其他类型节点的父类,他可以用来构建链表。hash值>=0

  • TreeNode节点主要用来构造红黑树以及存储数据hash值>=0

  • TreeBin节点是红黑树的代理节点,不存储数据,他的Hash值是一个固定值-2

  • ForWardingNode节点,表示的是底层数组table正在扩容,当前节点的数据已经迁移完毕,不存储数据,hash值也是固定值-1

注意事项:TreeBin为什么是红黑树的代理节点?

因为在向红黑树添加数据或删除数据时可能会触发红黑树的自平衡,根节点可能会被子节点替代,如果此时有线程来红黑树读取数据,可能会出现读取不到数据的情况。

而红黑树的查找是从根节点开始遍历的,当根节点变成子节点时,作为根节点的左子树或者右子树可能是不被遍历的。

ConcurrentHashMap的get方法是没有使用锁的,不可能通过加锁来保证强一致性,而红黑树的并发操作需要加上一层锁来保证在红黑树自平衡时的读操作没有问题。这就是TreeBin的工作。

TreeBin重要属性:

  • root:指向的是红黑树的根节点

  • first:指向的是双向链表,也就是所有的TreeNode节点构成的一个双向链表

  • lockState:用于实现基于CAS的读写锁。

总结:对红黑树添加或删除数据的整体操作:

首先在最外层加上synchronized同步锁,然后再红黑树自平衡时加上lockState的写锁。

当由线程来读红黑树的时候,会先判断此时是否有线程持有写锁或者是否有线程在等待获取写锁,如果有的话,读线程直接读取双向链表,否则会加上lockState的读锁。然后读取红黑树的数据,从而来保证读操作不被阻塞以及它的正确性。

双向链表的作用:

  • 读操作会来读取链表上的数据。

  • 在扩容时,会遍历双向链表,根据hash值判断是放在新数组的高位还是低位。

底层结构:数组+链表+红黑树 链表使用尾插法 定位下标使用 & 运算

线程安全:消了分段锁的设计,1取而代之的是通过 cas 操作和 synchronized 关键字来保证并发更新的安全。

Synchronized只是用于锁住链表或者红黑树的第一个节点,只要没有Hash冲突,就不存在并发问题,效率也就大大的提升。

相关操作:

put方法,使用cas + synchronized 来保证线程安全.

get方法,没有使用加锁,使用的是Unsafe.getObjectVolatile方法获取数据。保证数据的可见性。

remove方法、使用synchronized 来保证线程安全。

size方法(难点):主要是LongAdder的思想进行的累加计算。

扩容操作(难点):扩容操作发生在数据添加成功之后,并且支持多个线程。

优点:锁粒度更精细,性能更强

缺点:实现更加复杂。

希望对大家有所帮助!

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Android开发-设计规范

在Android应用开发中,遵循良好的设计规范不仅能够提升用户体验,还能确保代码的可维护性和扩展性。本文将从用户界面(UI)、用户体验(UX)、性能优化以及代码结构等多个维度探讨Android开发中的设计规范&#…

泛型加持的策略模式:打造高扩展的通用策略工具类

一、传统策略模式的痛点与突破 1.1 传统策略实现回顾 // 传统支付策略接口 public interface PaymentStrategy {void pay(BigDecimal amount); }// 具体策略实现 public class AlipayStrategy implements PaymentStrategy {public void pay(BigDecimal amount) { /* 支付宝支…

物联网从HomeAssistant开始

文章目录 一、什么是home-assistant?1.核心架构2.集成架构 二、在树梅派5上安装home-assistant三、接入米家1.对比下趋势2.手动安装插件3.配置方式 四、接入公牛1.手动安装插件2.配置方式 五、接入海尔1.手动安装插件2.配置方式 六、接入国家电网 一、什么是home-assistant? …

系统架构-嵌入式系统架构

原理与特征 嵌入式系统的典型架构可概括为两种模式,即层次化模式架构和递归模式架构 层次化模式架构,位于高层的抽象概念与低层的更加具体的概念之间存在着依赖关系,封闭型层次架构指的是,高层的对象只能调用同一层或下一层对象…

计算机图形学编程(使用OpenGL和C++)(第2版)学习笔记 09.天空和背景

天空和背景 对于 3D 场景,通常可以通过在远处的地平线附近创造一些逼真的效果,来增强其真实感。我们可以采用天空盒、天空柱(Skydome)或天空穹(Skydome)等技术来模拟天空。 天空盒 天空盒(Sk…

【Leetcode 每日一题】1550. 存在连续三个奇数的数组

问题背景 给你一个整数数组 a r r arr arr,请你判断数组中是否存在连续三个元素都是奇数的情况:如果存在,请返回 t r u e true true;否则,返回 f a l s e false false。 数据约束 1 ≤ a r r . l e n g t h ≤ 10…

面试题解析 | C++空类的默认成员函数(附生成条件与底层原理)

在C面试中,“空类默认生成哪些成员函数”是考察对象模型和编译器行为的高频题目。许多资料仅提及前4个函数,但完整的答案应包含6个核心函数,并结合C标准深入解析其生成规则与使用场景。 一、空类默认生成的6大成员函数 1. ​缺省构造函数​ …

视频编解码学习7之视频编码简介

视频编码技术发展历程与主流编码标准详解 视频编码技术是现代数字媒体领域的核心技术之一,它通过高效的压缩算法大幅减少了视频数据的体积,使得视频的存储、传输和播放变得更加高效和经济。从早期的H.261标准到最新的AV1和H.266/VVC,视频编码…

使用Stable Diffusion(SD)中,步数(Steps)指的是什么?该如何使用?

Ⅰ定义: 在Stable Diffusion(SD)中,步数(Steps) 指的是采样过程中的迭代次数,也就是模型从纯噪声一步步“清晰化”图像的次数。你可以理解为模型在画这张图时“润色”的轮数。 Ⅱ步数的具体作…

消息队列如何保证消息可靠性(kafka以及RabbitMQ)

目录 RabbitMQ保证消息可靠性 生产者丢失消息 MQ丢失消息 消费端丢失了数据 Kakfa的消息可靠性 生产者的消息可靠性 Kakfa的消息可靠性 消费者的消息可靠性 RabbitMQ保证消息可靠性 生产者丢失消息 1.事务消息保证 生产者在发送消息之前,开启事务消息随后生…

如何查看项目是否支持最新 Android 16K Page Size 一文汇总

前几天刚聊过 《Google 开始正式强制 Android 适配 16 K Page Size》 之后,被问到最多的问题是「怎么查看项目是否支持 16K Page Size」 ?其实有很多直接的方式,但是最难的是当你的项目有很多依赖时,怎么知道这个「不支持的动态库…

HttpServletResponse的理解

HttpServletResponse 是 Java Servlet API 提供的一个接口 常用方法 方法用途setContentType(String type)设置响应内容类型(如 "application/json"、"text/html")setStatus(int sc)设置响应状态码(如 200、404&#x…

可灵 AI:开启 AI 视频创作新时代

在当今数字化浪潮中,人工智能(AI)技术正以前所未有的速度渗透到各个领域,尤其是在内容创作领域,AI 的应用正引发一场革命性的变革。可灵 AI 作为快手团队精心打造的一款前沿 AI 视频生成工具,宛如一颗璀璨的…

用 AltSnap 解锁 Windows 窗口管理的“魔法”

你有没有遇到过这样的场景:电脑屏幕上堆满了窗口,想快速调整它们的大小和位置,却只能拖来拖去,费时又费力?或者你是个多任务狂魔,喜欢一边写代码、一边看文档、一边刷视频,却发现 Windows 自带的…

深度策略梯度算法PPO

一、策略梯度核心思想和原理 从时序差分算法Q学习到深度Q网络,这些算法都侧重于学习和优化价值函数,属于基于价值的强化学习算法(Value-based)。 1. 基于策略方法的主要思想(Policy-based) 基于价值类方…

【LaTeX】Word插入LaTeX行间公式如何编号和对齐

在 Word 文档中插入公式,需要用到 LaTeX \LaTeX LATE​X 。但遗憾的是,Word 只支持部分 LaTeX \LaTeX LATE​X 语法,这就导致很多在 Markdown 能正常渲染的公式在 Word 中无法正常显示。 “内嵌”和“显示” 首先介绍一下 Word 的“内嵌”…

互联网大厂Java面试实战:Spring Boot到微服务的技术问答解析

💪🏻 1. Python基础专栏,基础知识一网打尽,9.9元买不了吃亏,买不了上当。 Python从入门到精通 😁 2. 毕业设计专栏,毕业季咱们不慌忙,几百款毕业设计等你选。 ❤️ 3. Python爬虫专栏…

spring boot3.0自定义校验注解:文章状态校验示例

文章目录 Spring Boot 自定义校验注解:状态校验示例一、创建 State 注解步骤:1. 创建自定义注解:2. 实现校验逻辑: 二、 实现自定义校验步骤:1. 在实体类中使用自定义校验注解 State:2. 添加 State 注解: 总…

无侵入式弹窗体验_探索 Chrome 的 Close Watcher API

1. 引言 在网页开发中,弹窗(Popup)是一种常见的交互方式,用于提示用户进行操作、确认信息或展示关键内容。然而,传统的 JavaScript 弹窗方法如 alert()、confirm() 和 prompt() 存在诸多问题,包括阻塞主线程、样式不可定制等。 为了解决这些问题,Chrome 浏览器引入了 …

调出事件查看器界面的4种方法

方法1. 方法2. 方法3. 方法4.