JAVA面试题分享五:ConcurrentHashMap 底层具体实现知道吗?交现原理是什么?

一、什么是ConcurrentHashMap


java.util.concurrent.ConcurrentHashMap属于 JUC 包下的一个集合类,可以实现线程安全。它是一个支持高并发更新与查询的哈希表(基于HashMap)。在保证安全的前提下,进行检索不需要锁(valetile进行实现)。

在JDK1.7和JDK1.8中ConcurrentHashMap有比较大的改动,因此这里我们分为两部分进行学习。

二、JDK1.7的ConcurrentHashMap的实现

JDK1.7的ConcurrentHashMap与HashMap和Hashtable 最大的不同在于:put和 get 需要两次Hash才能到达指定的HashEntry,第一次hash到达Segment,第二次到达Segment里面的HashEntry数组,然后在遍历里面的Entry链表。

HashEntry和 Segment是ConcurrentHashMap类中的两个静态内部类。

Segment类我们在下面会介绍,这里介绍一下HashEntry类,它是用来封装具体的键值对的,是个典型的四元组。与HashMap中的Entry类似,HashEntry也包括同样的四个域,分别是key、hash、value和next。不同的是,在HashEntry类中,key,hash和next域都被声明为final的,value域被volatile所修饰,因此HashEntry对象几乎是不可变的,这是ConcurrentHashmap读操作并不需要加锁的一个重要原因。next域被声明为final本身就意味着我们不能从hash链的中间或尾部添加或删除节点,因为这需要修改next引用值,因此所有的节点的修改只能从头部开始。对于put操作,可以一律添加到Hash链的头部。但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制(重新new)一遍,最后一个节点指向要删除结点的下一个结点。特别地,由于value域被volatile修饰,所以其可以确保被读线程读到最新的值,这是ConcurrentHashmap读操作并不需要加锁的另一个重要原因

分段锁机制
Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下;因此,在JDK1.5~1.7版本,Java使用了分段锁机制实现ConcurrentHashMap.

简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即可以理解为整个Hash表划分为多个分段(小Hash表);而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可以实现多线程安全的put操作。接下来将详细分析JDK1.7版本中ConcurrentHashMap的实现原理。

JDK1.7的ConcurrentHashMap的数据结构

ConcurrentHashMap类结构如上图所示。由图可知,在ConcurrentHashMap中,定义了一个Segment<K, V>[]数组来将Hash表实现分段存储,从而实现分段加锁; 而一个Segment元素则与HashMap结构类似,其包含了一个HashEntry数组,用来存储Key/Value对。Segment继承了ReetrantLock,表示Segment是一个可重入锁,因此ConcurrentHashMap通过可重入锁对每个分段进行加锁。但是如果每个Segment越来越大时,锁的粒度就变得有些大了。其优势在于保证在操作不同段 map 的时候可以并发执行,操作同段 map 的时候,进行锁的竞争和等待。这相对于直接对整个map同步synchronized是有优势的。

缺点在于分成很多段时会比较浪费内存空间(不连续,碎片化); 操作map时竞争同一个分段锁的概率非常小时,分段锁反而会造成更新等操作的长时间等待; 当某个段很大时,分段锁的性能会下降。

这里jdk 1.7中ConcurrentHashmap采用的底层数据结构为数组+链表的形式。

三、JDK1.8的ConcurrentHashMap的实现 

在JDK1.7之前,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现。

CAS原理
一般地,锁分为悲观锁和乐观锁:悲观锁认为对于同一个数据的并发操作,一定是为发生修改的;而乐观锁则任务对于同一个数据的并发操作是不会发生修改的,在更新数据时会采用尝试更新不断重试的方式更新数据。

CAS(Compare And Swap,比较交换):CAS有三个操作数,内存值V、预期值A、要修改的新值B,当且仅当A和V相等时才会将V修改为B,否则什么都不做。Java中CAS操作通过JNI本地方法实现,在JVM中程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg);反之,如果程序是在单处理器上运行,就省略lock前缀。

CAS同时具有volatile读和volatile写的内存语义。

不过CAS操作也存在一些缺点:1. 存在ABA问题,其解决思路是使用版本号;2. 循环时间长,开销大;3. 只能保证一个共享变量的原子操作。

为了能更好的利用CAS原理解决并发问题,JDK1.5之后在java.util.concurrent.atomic包下采用CAS实现了一系列的原子操作类(aomic包下的).

ConcurrentHashMap的数据结构
JDK1.8的ConcurrentHashMap数据结构比JDK1.7之前的要简单的多,其使用的是HashMap一样的数据结构:数组+链表+红黑树。ConcurrentHashMap中包含一个table数组,其类型是一个Node数组;而Node是一个继承自Map.Entry<K, V>的链表,而当这个链表结构中的数据大于8,则将数据结构升级为TreeBin类型的红黑树结构。

另外,JDK1.8中的ConcurrentHashMap中还包含一个重要属性sizeCtl,其是一个控制标识符,不同的值代表不同的意思:其为0时,表示hash表还未初始化,而为正数时这个数值表示初始化或下一次扩容的大小,相当于一个阈值;即如果hash表的实际大小>=sizeCtl,则进行扩容,默认情况下其是当前ConcurrentHashMap容量的0.75倍;而如果sizeCtl为-1,表示正在进行初始化操作;而为-N时,则表示有N-1个线程正在进行扩容。

关键概念点

  • sizeCtl变量(volatile修饰):通过CAS操作+volatile, 控制数组初始化和扩容操作       
    • -1 代表正在初始化

    • -N 前16位记录数组容量,后16位记录扩容线程大小+1,是个负数

    • 正数0,表示未初始化

    • 正数,0.75*当前数组大小 

  • ForwardingNode:<key,value>键值对,封装为Node对象
  • table变量(volatile):也就是所说的数组,默认为null,默认大小为16的数组,每次扩容时大小总是2的幂次方
  • nextTable(volatile):扩容时新生成的数组,大小为table的两倍
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

之乎者也·

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值