concurrenthashmap为什么是线程安全_Java8中ConcurrentHashMap是如何保证线程安全的

本文深入剖析了ConcurrentHashMap的工作原理,包括初始化、线程安全的put和扩容过程。对比了JDK7与JDK8版本的不同实现方式,并探讨了其如何解决HashMap的并发问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

开源推荐

推荐一款一站式性能监控工具(开源项目)

Pepper-Metrics是跟一位同事一起开发的开源组件,主要功能是通过比较轻量的方式与常用开源组件(jedis/mybatis/motan/dubbo/servlet)集成,收集并计算metrics,并支持输出到日志及转换成多种时序数据库兼容数据格式,配套的grafana dashboard友好的进行展示。项目当中原理文档齐全,且全部基于SPI设计的可扩展式架构,方便的开发新插件。另有一个基于docker-compose的独立demo项目可以快速启动一套demo示例查看效果https://github.com/zrbcool/pepper-metrics-demo。如果大家觉得有用的话,麻烦给个star,也欢迎大家参与开发,谢谢:)


进入正题...

HashMap是工作中使用频度非常高的一个K-V存储容器。在多线程环境下,使用HashMap是不安全的,可能产生各种非期望的结果。

关于HashMap线程安全问题,可参考笔者的另一篇文章: 深入解读HashMap线程安全性问题

针对HashMap在多线程环境下不安全这个问题,HashMap的作者认为这并不是bug,而是应该使用线程安全的HashMap。

目前有如下一些方式可以获得线程安全的HashMap:

  • Collections.synchronizedMap
  • HashTable
  • ConcurrentHashMap

其中,前两种方式由于全局锁的问题,存在很严重的性能问题。所以,著名的并发编程大师Doug Lea在JDK1.5的java.util.concurrent包下面添加了一大堆并发工具。其中就包含ConcurrentHashMap这个线程安全的HashMap。

本文就来简单介绍一下ConcurrentHashMap的实现原理。

PS:基于JDK8

0 ConcurrentHashMap在JDK7中的回顾

ConcurrentHashMap在JDK7和JDK8中的实现方式上有较大的不同。首先我们先来大概回顾一下ConcurrentHashMap在JDK7中的原理是怎样的。

0.1 分段锁技术

针对HashTable会锁整个hash表的问题,ConcurrentHashMap提出了分段锁的解决方案。

分段锁的思想就是:锁的时候不锁整个hash表,而是只锁一部分。

如何实现呢?这就用到了ConcurrentHashMap中最关键的Segment。

ConcurrentHashMap中维护着一个Segment数组,每个Segment可以看做是一个HashMap。

而Segment本身继承了ReentrantLock,它本身就是一个锁。

在Segment中通过HashEntry数组来维护其内部的hash表。

每个HashEntry就代表了map中的一个K-V,用HashEntry可以组成一个链表结构,通过next字段引用到其下一个元素。

上述内容在源码中的表示如下:

public 

所以,JDK7中,ConcurrentHashMap的整体结构可以描述为下图这样子。

3be3e4a055434c98e8f3cfd985cf6a3a.png

由上图可见,只要我们的hash值足够分散,那么每次put的时候就会put到不同的segment中去。 而segment自己本身就是一个锁,put的时候,当前segment会将自己锁住,此时其他线程无法操作这个segment, 但不会影响到其他segment的操作。这个就是锁分段带来的好处。

0.2 线程安全的put

ConcurrentHashMap的put方法源码如下:

public 

最终会调用segment的put方法,将元素put到HashEntry数组中,这里的注释中只给出锁相关的说明

final 

0.3 线程安全的扩容(Rehash)

HashMap的线程安全问题大部分出在扩容(rehash)的过程中。

ConcurrentHashMap的扩容只针对每个segment中的HashEntry数组进行扩容。

由上述put的源码可知,ConcurrentHashMap在rehash的时候是有锁的,所以在rehash的过程中,其他线程无法对segment的hash表做操作,这就保证了线程安全。

1 JDK8中ConcurrentHashMap的初始化

以无参数构造函数为例,来看一下ConcurrentHashMap类初始化的时候会做些什么。

ConcurrentHashMap

首先会执行静态代码块和初始化类变量。 主要会初始化以下这些类变量:

// Unsafe mechanics

这里用到了Unsafe类,其中objectFieldOffset方法用于获取指定Field(例如sizeCtl)在内存中的偏移量。

获取的这个偏移量主要用于干啥呢?不着急,在下文的分析中,遇到的时候再研究就好。

PS:关于Unsafe的介绍和使用,可以查看笔者的另一篇文章 Unsafe类的介绍和使用

2 内部数据结构

先来从源码角度看一下JDK8中是怎么定义的存储结构。

/**

可以发现,JDK8与JDK7的实现由较大的不同,JDK8中不在使用Segment的概念,他更像HashMap的实现方式。

PS:关于HashMap的原理,可以参考笔者的另一篇文章 HashMap原理及内部存储结构

这个结构可以通过下图描述出来

1db5d98de214ff1a17b1b4a82256579f.png

3 线程安全的hash表初始化

由上文可知ConcurrentHashMap是用table这个成员变量来持有hash表的。

table的初始化采用了延迟初始化策略,他会在第一次执行put的时候初始化table。

put方法源码如下(省略了暂时不相关的代码):

/**

initTable源码如下

/**

成员变量sizeCtl在ConcurrentHashMap中的其中一个作用相当于HashMap中的threshold,当hash表中元素个数超过sizeCtl时,触发扩容; 他的另一个作用类似于一个标识,例如,当他等于-1的时候,说明已经有某一线程在执行hash表的初始化了,一个小于-1的值表示某一线程正在对hash表执行resize。

这个方法首先判断sizeCtl是否小于0,如果小于0,直接将当前线程变为就绪状态的线程。

当sizeCtl大于等于0时,当前线程会尝试通过CAS的方式将sizeCtl的值修改为-1。修改失败的线程会进入下一轮循环,判断sizeCtl<0了,被yield住;修改成功的线程会继续执行下面的初始化代码。

在new Node[]之前,要再检查一遍table是否为空,这里做双重检查的原因在于,如果另一个线程执行完#1代码后挂起,此时另一个初始化的线程执行完了#6的代码,此时sizeCtl是一个大于0的值,那么再切回这个线程执行的时候,是有可能重复初始化的。关于这个问题会在下图的并发场景中说明。

然后初始化hash表,并重新计算sizeCtl的值,最终返回初始化好的hash表。

下图详细说明了几种可能导致重复初始化hash表的并发场景,我们假设Thread2最终成功初始化hash表。 Thread1模拟的是CAS更新sizeCtl变量的并发场景 Thread2模拟的是table的双重检查的必要性

bc8c92ab48f54fa85057e1961528cdeb.png

由上图可以看出,在Thread1中如果不对sizeCtl的值更新做并发控制,Thread1是有可能走到new Node[]这一步的。 在Thread3中,如果不做双重判断,Thread3也会走到new Node[]这一步。

4 线程安全的put

put操作可分为以下两类 当前hash表对应当前key的index上没有元素时 当前hash表对应当前key的index上已经存在元素时(hash碰撞)

4.1 hash表上没有元素时

对应源码如下

else 

tabAt方法通过Unsafe.getObjectVolatile()的方式获取数组对应index上的元素,getObjectVolatile作用于对应的内存偏移量上,是具备volatile内存语义的。

如果获取的是空,尝试用cas的方式在数组的指定index上创建一个新的Node。

4.2 hash碰撞时

对应源码如下

else 

不同于JDK7中segment的概念,JDK8中直接用链表的头节点做为锁。 JDK7中,HashMap在多线程并发put的情况下可能会形成环形链表,ConcurrentHashMap通过这个锁的方式,使同一时间只有有一个线程对某一链表执行put,解决了并发问题。

5 线程安全的扩容

put方法的最后一步是统计hash表中元素的个数,如果超过sizeCtl的值,触发扩容。

扩容的代码略长,可大致看一下里面的中文注释,再参考下面的分析。 其实我们主要的目的是弄明白ConcurrentHashMap是如何解决HashMap的并发问题的。 带着这个问题来看源码就好。关于HashMap存在的问题,参考本文一开始说的笔者的另一篇文章即可。

其实HashMap的并发问题多半是由于put和扩容并发导致的。

这里我们就来看一下ConcurrentHashMap是如何解决的。

扩容涉及的代码如下:

/**

根据上述代码,对ConcurrentHashMap是如何解决HashMap并发问题这一疑问进行简要说明。

  • 首先new一个新的hash表(nextTable)出来,大小是原来的2倍。后面的rehash都是针对这个新的hash表操作,不涉及原hash表(table)。
  • 然后会对原hash表(table)中的每个链表进行rehash,此时会尝试获取头节点的锁。这一步就保证了在rehash的过程中不能对这个链表执行put操作。
  • 通过sizeCtl控制,使扩容过程中不会new出多个新hash表来。
  • 最后,将所有键值对重新rehash到新表(nextTable)中后,用nextTable将table替换。这就避免了HashMap中get和扩容并发时,可能get到null的问题。
  • 在整个过程中,共享变量的存储和读取全部通过volatile或CAS的方式,保证了线程安全。

6 总结

多线程环境下,对共享变量的操作一定要小心。要充分从Java内存模型的角度考虑问题。

ConcurrentHashMap中大量的用到了Unsafe类的方法,我们自己虽然也能拿到Unsafe的实例,但在生产中不建议这么做。 多数情况下,我们可以通过并发包中提供的工具来实现,例如Atomic包下面的可以用来实现CAS操作,lock包下可以用来实现锁相关的操作。

善用线程安全的容器工具,例如ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue等,因为我们在工作中无法像ConcurrentHashMap这样通过Unsafe的getObjectVolatile和setObjectVolatile原子性的更新数组中的元素,所以这些并发工具是很重要的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值