ConcurrentHashMap的介绍

ConcurrentHashMap的介绍

   概要

   在前面的文章《散列表》中,我们对HashMap进行了介绍。但是,在并发环境下,HashMap 存在线程安全问题,可通过 HashTable 或者 Collections.synchronizedMap 解决,但它们会对整个集合加锁,影响性能。为了兼顾线程安全和效率,ConcurrentHashMap 应运而生。   

    一、JDK1.7及之前 ConcurrentHashMap

    1. 底层实现

    在 JDK 1.7 及之前版本,ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。

    ConcurrentHashMap 将数据分为一段一段(这个“段”就是 Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。因此,就不会存在锁竞争,提高并发访问率。简单来说:ConcurrentHashMap 优势就是采用了锁分段技术,每一个Segment就好比自治区,读写操作高度自治,Segment之间互不影响。

    Segment类如下:

static class Segment<K,V> extends ReentrantLock implements Serializable {
}   

   在 JDK 1.7 中它使用的是数组+链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。大数组 Segment 可以理解为 MySQL 中的数据库,而每个数据库(Segment)中又有很多张表 HashEntry,每个 HashEntry 中又有多条数据,这些数据是用链表连接的,如下图所示:

   2.  JDK1.7 线程安全实现

   了解了 ConcurrentHashMap 的底层实现,再看它的线程安全实现就比较简单了。添加元素方法的部分源码如下:

 1 final V put(K key, int hash, V value, boolean onlyIfAbsent) {
 2     // 在往该 Segment 写入前,先确保获取到锁
 3     HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); 
 4     V oldValue;
 5     try {
 6         // Segment 内部数组
 7         HashEntry<K,V>[] tab = table;
 8         int index = (tab.length - 1) & hash;
 9         HashEntry<K,V> first = entryAt(tab, index);
10         for (HashEntry<K,V> e = first;;) {
11             if (e != null) {
12                 K k;
13                 // 更新已有值...
14             }
15             else {
16                 // 放置 HashEntry 到特定位置,如果超过阈值则进行 rehash
17                 // 忽略其他代码...
18             }
19         }
20     } finally {
21         // 释放锁
22         unlock();
23     }
24     return oldValue;
25 }

   Segment 本身是基于 ReentrantLock 实现的加锁和释放锁的操作,这样就能保证多个线程同时访问 ConcurrentHashMap 时,同一时间只有一个线程能操作相应的节点,这样就保证了 ConcurrentHashMap 的线程安全了。也就是说 ConcurrentHashMap 的线程安全是建立在 Segment 加锁的基础上的,所以我们把它称之为分段锁或片段锁。

   如下图:

   3.  读写的详细过程

   1)get() 方法

  •   为输入的Key做Hash运算,得到hash值。
  •   通过hash值,定位到对应的Segment对象
  •   再次通过hash值,定位到Segment当中数组的具体位置。

   2)put() 方法

  •   为输入的Key做Hash运算,得到hash值。
  •   通过hash值,定位到对应的Segment对象
  •   获取可重入锁
  •   再次通过hash值,定位到Segment当中数组的具体位置。
  •   插入或覆盖HashEntry对象。

   说明:从步骤可以看出,ConcurrentHashMap在读写时都需要二次定位。首先定位到Segment,之后定位到Segment内的具体数组下标。

   二、JDK1.8 ConcurrentHashMap

   1. 底层实现

   在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现,具体实现结构如下图:

 

   ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。

   链表升级为红黑树的规则:当链表长度大于 8,并且数组的长度大于 64 时,链表就会升级为红黑树的结构。

   2. JDK1.8 线程安全实现

   部分源码如下:

 1 final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
 2     int hash = spread(key.hashCode());
 3     int binCount = 0;
 4     for (Node<K,V>[] tab = table;;) {
 5         Node<K,V> f; int n, i, fh; K fk; V fv;
 6         if (tab == null || (n = tab.length) == 0)
 7             tab = initTable();
 8         else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 节点为空
 9             // 利用 CAS 去进行无锁线程安全操作,如果 bin 是空的
10             if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
11                 break; 
12         }
13         else if ((fh = f.hash) == MOVED)
14             tab = helpTransfer(tab, f);
15         else if (onlyIfAbsent
16                  && fh == hash
17                  && ((fk = f.key) == key || (fk != null && key.equals(fk)))
18                  && (fv = f.val) != null)
19             return fv;
20         else {
21             V oldVal = null;
22             synchronized (f) {
23                    // 细粒度的同步修改操作... 
24                 }
25             }
26             // 如果超过阈值,升级为红黑树
27             if (binCount != 0) {
28                 if (binCount >= TREEIFY_THRESHOLD)
29                     treeifyBin(tab, i);
30                 if (oldVal != null)
31                     return oldVal;
32                 break;
33             }
34         }
35     }
36     addCount(1L, binCount);
37     return null;
38 }

    从上面的源码可以看出,在 JDK 1.8 中,添加元素时首先会判断容器是否为空,如果为空则使用 volatile 加 CAS 来初始化。如果容器不为空则根据存储的元素计算该位置是否为空,如果为空则利用 CAS 设置该节点;如果不为空则使用 synchronize 加锁,遍历桶中的数据,替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。

    上述流程简化一下,我们可以简单地认为:在 JDK 1.8 中,ConcurrentHashMap 是在当前链表或红黑二叉树的首节点来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度。

    如下图:

 

    三、JDK1.7和JDK1.8的ConcurrentHashMap实现的区别

  • Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值(默认为8)时,将链表转换为红黑树)。
  • 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。

    总结一下:

    1) 在 JDK 1.7 时,ConcurrentHashMap使用的是数据+链表的形式实现的,其中数组分为两类:大数组 Segment 和小数组 HashEntry,而加锁是通过给 Segment 添加 ReentrantLock 锁来实现线程安全的。

    2)在JDK 1.8 中 中,ConcurrentHashMap 使用的是数组+链表/红黑树的方式实现的,它是通过 CAS 或 synchronized 来实现线程安全的,并且它的锁粒度更小,查询性能也更高。

 

   参考资料:

   https://javaguide.cn/

   https://www.51cto.com/article/795865.html

posted @ 2024-03-07 17:23  欢乐豆123  阅读(20)  评论(0编辑  收藏  举报