ConcurrentHashMap实现原理(JDK1.7和JDK1.8)

过好自己的生活,该来的都在路上


推荐阅读:

谈谈HashMap为什么是线程不安全的?

《面试必问-HashMap》通俗易懂搞定HashMap底层原理

目录

一、CAS和volatile简单介绍

二、JDK1.7

三、JDK1.8

总结


我们都知道HashMap是线程不安全的。

首先可能会想到用Hashtable来解决,这样确实可以,但是HashTable无论是添加数据还是获取数据,get/put方法都会用synchronized关键字加锁,显然效率很低,因此并不推荐。

Hashtable源码如下

又或者用工具类Collections中的synchronizedMap方法,这个方法返回一个SynchronizedMap,其在方法上,全部加上synchronized,类似于HashTable,同样效率低,因此也不推荐。

Collections.synchronizedMap源码如下

ConcurrentHashMap效率相对高很多,在JAVA7中采用分段锁,在JAVA8中采用Synchronized(锁升级)+CAS

一、CAS和volatile简单介绍

  • CAS(compare and swap)

CAS是从乐观的角度出发(乐观锁),尝试用新值更新内存值,更新时会判断内存值是否被别人修改过,如果没有则直接更新,如果修改过,则重新获取最新值再继续尝试更新,直到更新成功为止,所以CAS方式也称为自旋锁。

上述整个过程是不可拆分的,原子性操作。

  • CAS的优势

CAS是一种无锁操作,不需要加锁,避免了线程切换的开销。

  • CAS的缺点

CPU开销过大:如果并发量过大,我们的程序可能会一直自旋,长时间占用CPU资源。所以CAS适合并发量不高多核CPU的情况。

ABA问题:就是原来的值是A,一个线程把值改为了B,又来一个线程把值又改回了A,这个时候判断线程,发现值还是A,无法判断这个值是否被人改过,这就是ABA的问题。

  • 如何防止ABA问题

加标识位(version),这个标识位可以是时间戳。通过标志位可以精确知道每次修改。

CAS需要和volatile配合使用,CAS只能保证变量的原子性,不能保证变量的内存可见性。

  • volatile

仅仅保证该变量对所有线程的可见性,但不保证原子性。

即在多线程环境下,某个变量被一个线程修改成某个新值,这个新值对其他所有的线程来说都是立即可见的。但是修改变量在JVM中分了几步,这几步是不安全的。

ConcurrentHashMap使用volatile修饰变量

二、JDK1.7

在Hashtable中用synchronized修饰方法锁住整张表效率低,那如果把表分段处理,分别加锁,互不影响,不就可以提高效率啦。

JDK1.7中ConcurrentHashMap采用的就是分段锁,就是把整个table分割为n个部分,每个部分就是一个Segment。每个Segment中由HashEntry数组组成,这里的HashEnrty数组结构和HashMap中的相同,由数组+链表组成。

当对某个Segment加锁时,其他的Segment并不会受影响,理想状态下,所有线程操作的都是不同的segment,就可以降低锁的竞争,而且还是线程安全的。

  • put操作

  1. 通过key的hash值定位到segment数组的下标

  2. 通过tryLock尝试加锁,如果加锁成功,返回null,否则执行scanAndLockForPut方法

tips:

Segment继承于ReentrantLock

tryLock和lock是ReentrantLock中的方法

tryLock不会阻塞,抢锁成功就返回true,失败就返回false

lock方法抢锁成功则返回,失败则会进入同步队列,阻塞等待获取锁

3. 将当前Segment中的table,根据key的hash值与table的长度取模,定位其在HashEntry数组中的下标index

4. 找到HashEnrty数组对应下标位置的第一个HashEntry节点first,遍历first

5. 如果这个first为空则需要创建一个新的HashEntry加入到segment中,同时会先判断是否需要扩容,如果这个first不为空,则判断出传入的key与当前遍历的key是否相等,相等则覆盖value

6. 最后手动释放锁unlock

  • scanAndLockForPut()

put操作第一步枪锁失败,就会执行此方法自旋获取锁。

如果重试次数到达了最大限制,则停止循环,用阻塞的方式去获取锁。

  • get操作

通过key的hash定位到具体的segment,再通过一次hash定位到具体元素,由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。

三、JDK1.8

JDK1.8中的ConcurrentHashMap实现,完全重构了JDK1.7,不再使用分段锁,而是给数组中的每个头节点都加锁,并且用的是synchronized。整体采用CAS+synchronized来保证并发的安全性。

我们都知道synchronized之前一直都是重量级锁,但是后来官方在JDK1.6之后做了优化,现在是采用锁升级的方式去做。

  • synchronized锁升级

有四种状态:无锁,偏向锁,轻量级锁和重量级锁。

当一个对象被创建后,还没有线程进入,这个时候对象处于无锁状态。

这时有个线程A访问同步块获取锁时,锁对象会变成偏向锁,这个是通过CAS修改对象头中的锁标识位,偏向锁顾名思义就是偏向第一次获取到他的线程,第二次执行到代码块时,会先判断持有线程是否改变,没有就不用加锁了,避免了额外开销。

如果发生了锁竞争,这个时候偏向锁就会升级为轻量级锁,也就是自旋锁,通过不断CAS判断锁对是否被成功获。

长时间的自旋比较消耗性能,所以会控制自旋次数,默认是10次,如果超过次数就会升级为重量级锁,升级后,发生锁竞争,没有获取到锁的就会自动挂起,等待被唤醒。

这个升级过程是不可逆的。

  • put操作

  1. 判断表是否为空,如果为空就初始化表initTable(),只有一个线程可以初始化成功。

  2. 如果已经初始化,则找到当前key所在桶是判断是否为空,若为空则通过CAS把新节点插入此位置casTabAt(),只有一个线程可以CAS成功

  3. 如果key所在桶不为空,则判断节点的hash值是否为-1,若为-1则说明当前数组正在扩容。

  4. 如果如果key所在桶不为空,且没在扩容,则给桶中的第一个节点对象加锁synchronized,然后判断是否是链表或者树,然后插入数据。

  5. 判断链表长度是否大于8,如果是链表转为红黑树。

  • initTable()

  1. 判断是否有线程正在初始化(即判断sc是否为-1),如果有线程正在初始化,那么当前线程初始化失败,只是自旋。

  2. 如果没有线程初始化,通过CAS把sc的值设置为-1,说明当前线程正在初始化。

  3. 重新检查表是否为空,然后初始化默认容量16

  • get操作

get方法不用加锁,是非阻塞的,重写Node类,通过volatile修饰next来实现每次获取都是最新值。

总结

HashMap基本面试必问那种,作为线程安全的ConcurrentHashMap也会跟着问。需要注意的小细节是HashMap中key和vaule都是可以为null的,而ConcurrentHashMap中key和value是不可以为null的。

如果本篇文章有任何错误,请大家多多包涵批评指教,不胜感激!

我是酱子(关注微信公众号:爪哇酱子),感谢大家对本期文章的阅读,创作不易,各位的支持和认可是我最大的动力,如果觉得文章写的不错的话,就请各位点赞在看关注,我们下期见~

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值