Java ConcurrentHashMap 简介

一、ConcurrentHashMap 底层结构

1.1 JDK1.7

在 JDK1.7 中 ConcurrentHashMap 的底层是 Segment 数组 + HashEntry 数组 + 链表。如下图所示:

ConcurrentHashMap 为了提高本身的并发能力,在内部采用了一个叫做 Segment 的结构,一个 Segment 其实就是一个类 Hashtable 的结构,Segment 内部维护了一个链表数组每个 Segment 继承 ReentrantLock,作为独立的锁,默认 16 个 Segment 最大并发 16。

ConcurrentHashMap 定位一个元素的过程需要进行两次Hash操作,第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部,因此,这一种结构带来的副作用是 Hash 的过程要比普通的 HashMap 要长,但是带来的好处是写操作的时候可以只对元素所在的 Segment 进行操作即可,不会影响到其他的 Segment,这样在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作(刚好这些写操作都非常平均地分布在所有的 Segment上),所以通过这一种结构,ConcurrentHashMap 的并发能力可以大大的提高。

ConcurrentHashMap 采用了二次 hash 的方式,第一次 hash 将 key 映射到对应的 segment,而第二次 hash 则是映射到 segment 的不同桶(bucket)中。为什么要用二次 hash,主要原因是为了构造分离锁,使得对于 map 的修改不会锁住整个容器,提高并发能力。当然,没有一种东西是绝对完美的,二次 hash 带来的问题是整个 hash 的过程比 HashMap 单次 hash 要长,所以,如果不是并发情形,不要使用 ConcurrentHashMap。

1.2 JDK1.8

在 JDK1.7 中解决了并发问题,并且能支持 N 个 Segment 多次数的并发,但是查询遍历链表效率太低。JDK1.8 的 ConcurrentHashMap 不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。如下图所示:

TreeNode 是存储红黑树节点,被 TreeBin 包装。TreeBin 通过 root 属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap 中 TreeBin 通过waiter 属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。

java 复制代码
static final class TreeBin<K,V> extends Node<K,V> {
        TreeNode<K,V> root;
        volatile TreeNode<K,V> first;
        volatile Thread waiter;
        volatile int lockState;
        // values for lockState
        static final int WRITER = 1; // set while holding write lock
        static final int WAITER = 2; // set when waiting for write lock
        static final int READER = 4; // increment value for setting read lock
...
}

ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。

1.3 内存一致性

如何让多线程之间对象的状态对于各线程的"可视性"是顺序一致的:ConcurrentHashMap 使用了 happens-before 规则来实现。

happens-before 规则

  • 程序次序法则:线程中的每个动作A都 happens-before 于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后;
  • 监视器锁法则:对一个监视器锁的解锁 happens-before 于每一个后续对同一监视器锁的加锁;
  • volatile 变量法则:对 volatile 域的写入操作 happens-before 于每一个后续对同一个域的读写操作;
  • 线程启动法则:在一个线程里,对 Thread.start 的调用会 happens-before 于每个启动线程的动作;
  • 线程终结法则:线程中的任何动作都 happens-before 于其他线程检测到这个线程已经终结、或者从 Thread.join 调用中成功返回,或 Thread.isAlive 返回 false;
  • 中断法则:一个线程调用另一个线程的 interrupt happens-before 于被中断的线程发现中断;
  • 终结法则:一个对象的构造函数的结束 happens-before 于这个对象 finalizer 的开始;
  • 传递性:如果 A happens-before 于 B,且 B happens-before 于 C,则 A happens-before于C:假设代码有两条语句,代码顺序是语句1先于语句2执行;那么只要语句之间不存在依赖关系,那么打乱它们的顺序对最终的结果没有影响的话,那么真正交给CPU去执行时,他们的执行顺序可以是先执行语句2然后语句1;

二、ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同:

  • 底层数据结构:JDK1.7 的 ConcurrentHashMap 底层采用分段的数组+链表实现,在 JDK1.8 中采用的数据结构跟 HashMap 的结构一样,数组+链表/红黑树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

  • 实现线程安全的方式

    1、在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。

    2、到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;

    3、Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

三、ConcurrentHashMap 的 key 和 value 不能为 null

ConcurrentHashMap 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。

例如用 get 方法取值,返回的结果为 null 存在两种情况:

  • 值没有在集合中 ;
  • 值本身就是 null;

这也就是二义性的由来。

多线程环境下,存在一个线程操作该 ConcurrentHashMap 时,其他的线程将该 ConcurrentHashMap 修改的情况,所以无法通过 containsKey(key) 来判断否存在这个键值对,也就没办法解决二义性问题了。与此形成对比的是,HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。如果传入 null 作为参数,就会返回 hash 值为 0 的位置的值。单线程环境下,不存在一个线程操作该 HashMap 时,其他的线程将该 HashMap 修改的情况,所以可以通过 contains(key)来做判断是否存在这个键值对,从而做相应的处理,也就不存在二义性问题。也就是说,多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。如果你确实需要在 ConcurrentHashMap 中使用 null 的话,可以使用一个特殊的静态空对象来代替 null。

java 复制代码
public static final Object NULL = new Object();

四、ConcurrentHashMap 不能保证复合操作的原子性

ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的。

复合操作是指由多个基本操作(如 put、get、remove、containsKey 等)组成的操作,例如先判断某个键是否存在 containsKey(key),然后根据结果进行插入或更新 put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。

例如,有两个线程 A 和 B 同时对 ConcurrentHashMap 进行复合操作,如下:

java 复制代码
// 线程 A
if (!map.containsKey(key)) {
map.put(key, value);
}
// 线程 B
if (!map.containsKey(key)) {
map.put(key, anotherValue);
}

如果线程 A 和 B 的执行顺序是这样:

  1. 线程 A 判断 map 中不存在 key
  2. 线程 B 判断 map 中不存在 key
  3. 线程 B 将 (key, anotherValue) 插入 map
  4. 线程 A 将 (key, value) 插入 map

那么最终的结果是 (key, value),而不是预期的 (key, anotherValue)。这就是复合操作的非原子性导致的问题。

想要保证复合操作的原子性,可以使用 ConcurrentHashMap 提供的原子性复合操作方法,如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge 等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。

上面的代码可以改写为:

java 复制代码
// 线程 A
map.putIfAbsent(key, value);
// 线程 B
map.putIfAbsent(key, anotherValue);

或者:

java 复制代码
// 线程 A
map.computeIfAbsent(key, k -> value);
// 线程 B
map.computeIfAbsent(key, k -> anotherValue);
相关推荐
梦未9 小时前
Spring控制反转与依赖注入
java·后端·spring
喜欢流萤吖~9 小时前
Lambda 表达式
java
ZouZou老师9 小时前
C++设计模式之适配器模式:以家具生产为例
java·设计模式·适配器模式
曼巴UE59 小时前
UE5 C++ 动态多播
java·开发语言
VX:Fegn08959 小时前
计算机毕业设计|基于springboot + vue音乐管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·课程设计
程序员鱼皮9 小时前
刚刚,IDEA 免费版发布!终于不用破解了
java·程序员·jetbrains
steins_甲乙10 小时前
C++并发编程(3)——资源竞争下的安全栈
开发语言·c++·安全
Hui Baby10 小时前
Nacos容灾俩种方案对比
java
曲莫终10 小时前
Java单元测试框架Junit5用法一览
java