“浅浅深究”一下ConcurrentHashMap
前言
面试官:我如果想要使用线程安全的HashMap怎么办?
我:①Collections.synchronizedMap:是 Java 提供一种简单的方式来包装普通 HashMap,使其线程安全。它通过在每个方法上添加 synchronized 锁来实现线程安全,因为每次都会锁住整个Map所以不推荐使用。
②HashTable:是 Java 早期提供的线程安全的哈希表实现。它与 HashMap 类似,但所有方法都是同步的,因为也是在所有的方法上加synchronized修饰,但现如今已经被废弃不推荐使用。
③ConcurrentHashMap:是JUC包下的一个线程安全的Map类,它通过锁分段(JDK 7)或锁细化(JDK 8 及以上)以及无锁的 CAS 操作,实现了高效的线程安全操作,推荐使用!!
ConcurrentHashMap底层数据结构
在jdk1.7
分段锁Segment(继承自ReentrantLock)+数组+链表:
①分段锁Segment:Segment类是ConcurrentHashMap实现并发控制的核心。它继承自ReentrantLock,拥有自己的锁,这也是实现线程安全的关键元素。
②定位一个元素:ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。第一次Hash,定位到Segment。第二次Hash,定位到元素所在的链表的头部。
③并发控制:当线程需要访问ConcurrentHashMap中的某个键时,它会首先计算键的哈希值,并根据哈希值的高位定位到对应的Segment。然后,线程会尝试获取该Segment的锁。如果锁已经被其他线程持有,则当前线程会等待直到获取锁为止。一旦线程获得Segment的锁,它就可以在该Segment内部进行哈希表的查找、插入或删除操作。这些操作与普通的HashMap类似,但需要在锁的保护下进行以确保线程安全。完成操作后,线程会释放锁,使得其他线程有机会访问该Segment。
④总结:Java 8之前的ConcurrentHashMap
通过分段锁的设计实现了高并发性能。它将哈希表划分为多个段,并使用细粒度的锁来控制对每个段的访问。这种设计大大减少了锁的竞争,提高了并发性能。然而,随着Java版本的迭代和硬件性能的提升,分段锁的设计逐渐暴露出一些问题,如内存占用较大、扩容操作复杂等
在jdk1.8
数组+链表/红黑树:
①数据结构其实就和HashMap一致,只是在并发控制上有独特的设计
②并发控制:ConcurrentHashMap主要是通过CAS和Synchronized相互配合来保证的线程安全的。当线程需要访问ConcurrentHashMap中的某个键时,首先通过 spread() 方法计算哈希再通过计算获取桶的位置。然后,线程也会尝试获取头结点的锁,此时加锁的逻辑如下:
- 桶为空:CAS 写入头节点。
- 桶非空:synchronized 锁住头节点,遍历链表/树插入或更新。
如果锁已经被其他线程持有,则当前线程会等待直到获取锁为止。一旦线程获取到锁,它就可以在该节点进行查找、插入或删除操作,最后仔释放锁即可。
③总结:Java 8中的ConcurrentHashMap通过采用CAS操作结合synchronized同步块的并发控制策略以及优化后的数据结构和哈希算法等技术手段实现了高并发性能下的线程安全访问。与之前的版本相比,它在简化数据结构、提高空间利用率和降低锁竞争等方面取得了显著的进步。这些改进使得ConcurrentHashMap成为Java并发编程中不可或缺的重要组件之一。
ConcurrentHashMap的协助扩容机制
扩容时机:
容量阈值:当元素总数 ≥ 数组容量 × 负载因子(默认 0.75) 时触发扩容。
链表转树条件不满足:当链表长度 ≥8 但数组容量 <64 时,优先扩容而非树化。
扩容流程:
线程A开始扩容,创建2倍大小的数组,并且开始扩容,每次扩容完成就会将该节点标记为FowardingNode,这个主要是提供给其他线程,如果是读请求,则告诉他们去新的数组中找数据,如果是写请求的话,则会让线程暂时放下请求,加入协助扩容,通过一个transferIndex去找到自己负责的一个区间段进行扩容。
与HashMap的对比
①第一点:是否线程安全
②第二点:底层的数据结构
③第三点:协助扩容机制
④第四点:对空值的支持
⑤第五点:适用场景