高频八股——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)(红黑树优化)
并发性能 一般
相关推荐
赖皮猫4 分钟前
PIKIE-RAG 本地部署实践
后端·python·flask
Asthenia041219 分钟前
面试回顾:Java RMI 问题解析(续)
后端
无名之逆22 分钟前
[特殊字符] Hyperlane 框架:高性能、灵活、易用的 Rust 微服务解决方案
运维·服务器·开发语言·数据库·后端·微服务·rust
Asthenia041236 分钟前
面试回顾:Java RMI 问题解析
后端
uhakadotcom42 分钟前
Python 中的 @staticmethod 和 @classmethod 详解
后端·面试·github
uhakadotcom1 小时前
单点登录的两大核心技术:SAML和OIDC
后端·面试·github
Asthenia04121 小时前
正则表达式详解与 Java 实践-预定义字符类/重复类/反义类/分组/零宽断言
后端
慕离桑1 小时前
SQL语言的物联网
开发语言·后端·golang
我是哪吒1 小时前
分布式微服务系统架构第94集:Kafka 消费监听处理类,redisson延时队列
后端·面试·github
hhope1 小时前
🧀 【实战演练】从零搭建!让复制粘贴上传文件“跑起来” (Node.js 后端版)
前端·javascript·面试