HashMap、Hashtable 和 ConcurrentHashMap 的区别?

#Java#面试八股

HashMap、Hashtable 和 ConcurrentHashMap 的区别?

结论先行:三者都是基于哈希表的 Map,但线程模型完全不同。HashMap 完全不加锁,单线程最快;Hashtable 用 synchronized 修饰每个公开方法,等于整表一把锁;ConcurrentHashMap(下称 CHM)在 JDK 1.8 用 CAS 写空桶、用 synchronized 锁桶头,读操作无锁。并发场景选 CHM,Hashtable 只应出现在维护老代码时。

1. 一张表看清差异

维度HashMapHashtableConcurrentHashMap
线程安全否是,方法级同步是,桶级锁 + CAS
锁粒度无整张表单个桶头节点
null 支持1 个 null key、多个 null valuekey / value 都不允许key / value 都不允许
初始容量16,必须是 2 的幂11,扩容为 2n+116,必须是 2 的幂
扩容方式2 倍,高低位拆分2n+1,全量 rehash2 倍,多线程协助迁移
定位算法(n - 1) & hash(hash & 0x7FFFFFFF) % length(n - 1) & spread(hash)
迭代器fail-fastfail-fast弱一致性,不抛 CME
冲突结构链表 + 红黑树纯链表链表 + 红黑树
出现版本1.21.0,遗留类1.5 引入,1.8 重写

2. 锁的范围决定了吞吐

Hashtable:一把锁罩住整张表
[============ lock ============]
[ bucket0 ][ bucket1 ][ bucket2 ] ...   写线程全部排队

ConcurrentHashMap:只锁命中的桶头
[ bucket0 ][ bucket1 ][ bucket2 ] ...
                ^
            只锁这里,其他桶照常并发写

Hashtable 的 put、get、size 全是 synchronized 方法,连读都要竞争同一把监视器锁;CHM 的 get 只做 volatile 读,写操作才在桶级别互斥。

3. null 的取舍不是随手定的

Hashtable 遇到 null 直接抛 NullPointerException;CHM 也禁止 null,但理由不同:并发下 get 返回 null 无法区分“key 不存在”和“key 映射到 null”,而 containsKey 再 get 的两步之间状态可能已经变化,所以干脆从入口禁掉,消除歧义。HashMap 单线程没有这个问题,于是允许一个 null key 和任意 null value。

import java.util.Hashtable;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class MapNullDemo {
    public static void main(String[] args) {
        Map<String, String> hashMap = new HashMap<>();
        hashMap.put(null, "v");            // 允许,null key 放在 0 号桶

        Map<String, String> table = new Hashtable<>();
        try {
            table.put(null, "v");          // 抛 NullPointerException
        } catch (NullPointerException e) {
            System.out.println("Hashtable 不接受 null key");
        }

        ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>();
        try {
            chm.put("k", null);            // 抛 NullPointerException
        } catch (NullPointerException e) {
            System.out.println("CHM 不接受 null value");
        }
    }
}

4. 代码:并发计数用 CHM 的原子方法

import java.util.concurrent.ConcurrentHashMap;

public class ChmCounter {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> counter = new ConcurrentHashMap<>();
        // 错误示范:get 后再 put,两步之间可能被其他线程覆盖
        Integer old = counter.get("click");
        counter.put("click", old == null ? 1 : old + 1);

        // 正确:merge 在桶锁内完成“读-改-写”
        counter.merge("click", 1, Integer::sum);

        // 预估容量,减少扩容带来的迁移
        ConcurrentHashMap<String, Integer> big = new ConcurrentHashMap<>(1 << 10);
        big.put("k", 1);
        System.out.println(counter.get("click")); // 输出:2
    }
}

5. Hashtable 为什么被淘汰

  • 整表锁让写吞吐几乎不随核数增长,多核机器上尤其吃亏。
  • 初始容量 11、按 2n+1 扩容,下标必须用取模,无法享受 2 的幂带来的位运算红利。
  • API 停留在 Enumeration 时代,elements()、keys() 的设计与后来的集合框架不一致。
  • 不允许 null 在一些业务里反而更别扭;而它提供的线程安全,CHM 能给得更好。

Collections.synchronizedMap(new HashMap<>()) 本质上与 Hashtable 同类:包装类的每个方法对同一个 mutex 加锁,迭代时仍需手动同步,吞吐同样上不去。

常见追问

  • CHM 的 size 为什么只是近似值? 计数分散在 baseCount 和 counterCells 里,sumCount 遍历求和时其他线程还在写,拿不到同一时刻的快照;需要精确计数用 mappingCount()(返回 long)。
  • Hashtable 的迭代器是 fail-fast 吗? 是,结构性修改会让 modCount 变化,枚举时抛 ConcurrentModificationException。
  • 为什么 HashMap 允许 null 而 CHM 不允许? 单线程无歧义,并发下有歧义;这是接口契约层面的取舍。
  • CHM 的 get 需要加锁吗? 不需要,tabAt 和 Node.val 都是 volatile 语义,读线程能看到已发布的值。
  • 三者能用可变对象当 key 吗? 能,但修改参与 hash 的字段后就再也查不到、删不掉,等价于内存泄漏。
  • 只读场景怎么选? 初始化后不再修改,HashMap 足够;一旦有并发写,必须 CHM。

小结

HashMap 是单线程默认选择,快但不安全;Hashtable 用整表锁换安全,属于历史包袱;CHM 用 CAS + 桶级锁把并发度做上去,同时保留 HashMap 的树化能力。回答时抓住三条主线:锁的粒度、null 的处理、迭代器的语义,再补一句“复合操作要用 merge / computeIfAbsent”,基本就是完整答案。

笔记加载中…