高频八股——ConcurrentHashMap和Hashtable的区别

在上篇文章中,我们深入解析了HashMap的底层原理,并指出HashMap是非线程安全的。在多线程环境下,我们通常会使用线程安全的 ConcurrentHashMapHashtable。然而,Hashtable 逐渐被淘汰,而 ConcurrentHashMap 成为了主流选择。那么,Hashtable 为什么会被淘汰?它和 ConcurrentHashMap 之间存在哪些关键区别?本文将围绕这些高频面试问题展开。

ConcurrentHashMap和hashtable都是线程安全的,他们的区别主要体现在实现线程安全的机制有所不同。

1. Hashtable 的线程安全机制

Hashtable 通过 synchronized 关键字对所有方法进行加锁,从而保证线程安全。例如,get()put() 方法都被 synchronized 修饰:

java 复制代码
public synchronized V get(Object key) {
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    for (Entry<?,?> e = tab[(hash & 0x7FFFFFFF) % tab.length];
         e != null; e = e.next) {
        if ((e.hash == hash) && e.key.equals(key)) {
            return (V)e.value;
        }
    }
    return null;
}

虽然这种方式保证了线程安全,但由于锁是作用于整个 Hashtable 实例的,这导致所有线程访问该对象时都必须等待锁释放,即使是不同的线程访问不同的键,它们仍然会被串行化处理,无法真正实现高并发。


2. ConcurrentHashMap 的线程安全机制

ConcurrentHashMap 采用了更高效的机制来保证线程安全,JDK1.7及之前和JDK1.8及之后采用了不同的实现方式。

2.1 JDK 1.7 及之前的 ConcurrentHashMap

在 JDK 1.7 及之前,ConcurrentHashMap 采用 分段锁(Segment Lock) 来提高并发能力。

  • 内部结构是 Segment + HashEntry 数组 ,其中 Segment 类似于小型 Hashtable,每个 Segment 维护一部分数据。
  • 通过 ReentrantLock(可重入锁)来控制对不同 Segment 的访问,使得多个线程可以并发访问不同的 Segment

Segment 加锁的代码如下所示,加锁粒度由Hashtable的全局锁缩小成为局部锁,加锁方法由synchronized变为ReentrantLock

java 复制代码
final Segment<K,V> segment = segments[(hash >>> segmentShift) & segmentMask];
// (hash >>> segmentShift) & segmentMask是哈希算法(扰动函数+取模),上一篇文章有详细讲过。
segment.lock();
try {
    // 执行插入操作
} finally {
    segment.unlock();
}

2.2 JDK 1.8 及之后的 ConcurrentHashMap

JDK 1.8 之后,ConcurrentHashMap 主要做出了以下三点优化:

  • 采用 Node 数组 + 链表 + 红黑树 结构,锁加在Node上,也就是数组中每个桶位的链表的头节点上(或是红黑树的的根节点上)。
  • 使用**volatile+ CAS + synchronized** 机制减少锁冲突,提高并发能力。
  • 当链表长度超过 8 时,转换为 红黑树 ,提高查询效率(从 O(n) 降到 O(logn))(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)。

线程向ConcurrentHashMap中添加元素时的过程如下:

  1. 初始化检查 :首先判断 ConcurrentHashMap 是否为空,如果为空,则使用 volatile + CAS 进行初始化,以确保线程安全的延迟加载。

  2. 定位桶索引并尝试插入:计算键的哈希值,根据哈希值确定数据存储的索引位置。

    • 如果该位置为空,则使用 CAS 插入新节点。
    java 复制代码
    // 在 tab[i] 表示哈希表中索引为i的桶位的头节点
    if (tab == null || (f = tab[i = (n - 1) & hash]) == null) {
        // 在 tab[i] 仍然是 null 的情况下原子性地插入新节点
    	if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
         	break; 
    }
  3. 解决哈希冲突 :如果该位置已存在数据(即哈希冲突),则使用 synchronized 进行加锁,并遍历链表。

    java 复制代码
    // 锁住当前桶位的头节点,防止其他线程同时修改该链表
    synchronized (f) {
        // 双重检查:如果头节点仍然是 f,则执行插入或更新操作,保证数据一致性
        if (tab[i] == f) {
            // 进行插入或更新操作
        }
    }
  4. 扩容 :若链表长度超过 8 ,则将链表转换为 红黑树 ,提高查询效率(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)。

  5. 完成插入 :操作完成后,ConcurrentHashMap 通过 volatile 确保数据可见性,并通知其他线程更新状态。

JDK 1.8 之后的ConcurrentHashMap 相比于JDK1.7之前的ConcurrentHashMap 有以下优点:

  1. 锁的粒度更小 :JDK 1.8 之后 ConcurrentHashMap 采用 Node 级别锁 而非 Segment,减少了锁冲突,提高并发度。
  2. volatile + CAS + synchronized 机制:在多线程环境下,利用 CAS 操作进行无锁更新,提高效率。
  3. 红黑树优化查询:当桶中链表过长时,自动转换为红黑树,提高查询速度。

3. 对比总结

总的来说,从 Hashtable,到 JDK1.7 及之前版本的 ConcurrentHashMap,再到 JDK1.8 及之后版本的 ConcurrentHashMap,它们都是通过加锁来保证线程安全的,但是加锁的粒度越来越细,从锁住整个对象,到锁住 Segment,再到现在的锁住 Node,一步步提升了性能。

特性 Hashtable ConcurrentHashMap(JDK 1.7) ConcurrentHashMap(JDK 1.8)
线程安全
并发机制 synchronized 分段锁(Segment + ReentrantLock) volatile + CAS + synchronized
锁的粒度 整个对象 分段锁(Segment 级别) Node 级别锁
读操作是否加锁 否(大部分操作无锁) 否(使用 volatile 保证可见性)
写操作是否加锁 仅对 Segment 加锁 仅对冲突桶加锁
查询效率 O(n) O(n) O(logn)(红黑树优化)
并发性能 一般
相关推荐
血小板要健康9 小时前
Java基础常见面试题复习合集1
java·开发语言·经验分享·笔记·面试·学习方法
野犬寒鸦10 小时前
从零起步学习并发编程 || 第一章:初步认识进程与线程
java·服务器·后端·学习
我爱娃哈哈10 小时前
SpringBoot + Flowable + 自定义节点:可视化工作流引擎,支持请假、报销、审批全场景
java·spring boot·后端
李梨同学丶12 小时前
0201好虫子周刊
后端
思想在飞肢体在追12 小时前
Springboot项目配置Nacos
java·spring boot·后端·nacos
cyforkk13 小时前
09、Java 基础硬核复习:异常处理(容错机制)的核心逻辑与面试考点
java·数据库·面试
Loo国昌15 小时前
【垂类模型数据工程】第四阶段:高性能 Embedding 实战:从双编码器架构到 InfoNCE 损失函数详解
人工智能·后端·深度学习·自然语言处理·架构·transformer·embedding
ONE_PUNCH_Ge16 小时前
Go 语言泛型
开发语言·后端·golang
Warren9816 小时前
Pytest Fixture 作用域详解:Function、Class、Module、Session 怎么选
面试·职场和发展·单元测试·pytest·pip·模块测试·jira
良许Linux16 小时前
DSP的选型和应用
后端·stm32·单片机·程序员·嵌入式