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

集合评论738阅读模式
摘要

HashMap基本面试必问那种,作为线程安全的ConcurrentHashMap也会跟着问。ConcurrentHashMap效率相对高很多,在JAVA7中采用分段锁,在JAVA8中采用Synchronized(锁升级)+CAS。

         我们都知道HashMap是线程不安全的。
         首先可能会想到用Hashtable来解决,这样确实可以,但是HashTable无论是添加数据还是获取数据,get/put方法都会用synchronized关键字加锁,显然效率很低,因此并不推荐。
         ConcurrentHashMap效率相对高很多,在JAVA7中采用分段锁,在JAVA8中采用Synchronized(锁升级)+CAS。

CAS和volatile简单介绍

  • CAS(compare and swap)
        CAS是从乐观的角度出发(乐观锁),尝试用新值更新内存值,更新时会判断内存值是否被别人修改过,如果没有则直接更新,如果修改过,则重新获取最新值再继续尝试更新,直到更新成功为止,所以CAS方式也称为自旋锁。
ConcurrentHashMap实现原理(JDK1.7和JDK1.8)
        上述整个过程是不可拆分的,原子性操作。
  • 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中的相同,由数组+链表组成。
ConcurrentHashMap实现原理(JDK1.7和JDK1.8)
         当对某个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的。

评论  0  访客  0
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定