欢迎光临
我们一直在努力

一道“过时”的面试题:HashMap 和 Hashtable 到底差在哪?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

一道“过时”的面试题:HashMap 和 Hashtable 到底差在哪?

Hashtable 是 1996 年 JDK 1.0 的产物。1998 年 JDK 1.2 引入集合框架后,它就被官方标注为过时的遗留类,但面试官几乎每次都问。

因为它能一口气串起四条主线:线程安全的实现方式、null 语义的取舍、容量与索引算法、迭代器的 fail-fast 机制。答得出来是及格,答得出“为什么这么设计”才是加分。

0. 先给面试答案(30 秒版)

HashMap 和 Hashtable 最核心的区别就是线程安全性。 Hashtable 的方法基本都加了 synchronized,是线程安全的,但代价是性能差;HashMap 没加锁,单线程下跑得快,但多线程环境得自己保证同步。

其他几个关键区别:

  • 对 null 的处理不一样。HashMap 允许一个 null 键和多个 null 值,Hashtable 不让存 null,往里塞就抛 NullPointerException;
  • 继承体系不同。HashMap 继承 AbstractMap,是正经的 Map 实现;Hashtable 继承的是古早的 Dictionary 类,虽然后来也实现了 Map 接口,但 Dictionary 早就被官方标注为过时(obsolete);
  • 迭代器机制有差别。HashMap 用的是 fail-fast 的 Iterator,迭代时改结构直接抛 ConcurrentModificationException;Hashtable 的 keys()/elements() 返回的是老式 Enumerator,不做校验,改了结构也不一定报错——不过要注意,它的 keySet().iterator() 是 fail-fast 的(第 5 节有实测)。
  • 容量算法(16 vs 11、位与 vs 取模)、hash 扰动这些也常被追问,扫一眼下面的速查表即可:

    维度HashMapHashtable
    引入版本 JDK 1.2 JDK 1.0
    父类 AbstractMap Dictionary(官方标注已废弃)
    线程安全 ❌ 不安全 ✅ 安全(方法级 synchronized,锁 this)
    null 支持 1 个 null key + 任意 null value key 和 value 都不允许
    默认容量 16 11
    扩容策略 2 倍(保持 2 的幂) 2n + 1(保持奇数)
    索引计算 (n – 1) & hash (hash & 0x7FFFFFFF) % length
    hash 扰动 有(h ^ (h >>> 16)) 无,直接用 key.hashCode()
    迭代器 fail-fast keySet() 视图 fail-fast,keys()/elements() 不是

    一句话记住:Hashtable 用一把锁把整个表锁住,安全但慢;HashMap 不锁所以快,但并发要自己管。

    下面把每一条拆开,最后给几个实测数据。


    1. 出身不同:一个 1998 年的遗留类

    Hashtable 是 JDK 1.0 就有的类,比 Collection 框架还早两年。等到 JDK 1.2 引入集合框架时,官方才补了 HashMap,并顺手把 Hashtable 归入“不推荐使用”的历史遗留。

    证据在 Dictionary 的 javadoc 里:

    NOTE: This class is obsolete. New implementations should implement the Map interface, rather than extending this class.

    Hashtable 恰恰继承了 Dictionary,所以它的位置很尴尬:

    • 它没有被 @Deprecated 标注(实测 Hashtable.class.isAnnotationPresent(Deprecated.class) 返回 false),所以编译不报警;
    • 但文档明确说“新实现应该实现 Map 接口,而不是继承这个类”。

    命名也不规范。 按 Java 命名约定应该是 HashTable,而 Hashtable 把 t 写成了小写——这是 JDK 1.0 时代命名规范尚未定型留下的历史包袱。

    方法名更让人困惑:

    Hashtable<String, String> ht = new Hashtable<>();
    ht.put("a", "v");
    System.out.println(ht.contains("v")); // true —— 它其实是 containsValue!

    contains(Object) 判断的是值是否存在,和直觉完全相反。HashMap 干脆删掉了这个方法,只保留 containsKey 和 containsValue,从 API 设计上消灭了歧义。


    2. 线程安全:一把锁锁住整张表

    实证数据: 反射统计 Hashtable 的 36 个 public 方法,其中 26 个带 synchronized。

    public synchronized V put(K key, V value) { ... }
    public synchronized V get(Object key) { ... }
    public synchronized V remove(Object key) { ... }

    锁对象是 this,也就是说整张表共用一把锁。带来三个后果:

  • 读也被阻塞:线程 A 在 put,线程 B 的 get 必须等——哪怕它们操作的是完全不同的桶;
  • 单线程也要付锁开销:synchronized 在无竞争时虽然已优化到偏向锁/轻量级锁,但仍有对象头操作和内存屏障的成本;
  • 并发吞吐极差:线程数一上来,全部退化成串行。
  • 但它是“线程安全”的吗?打引号

    if (!ht.containsKey(key)) { // ① 检查
    ht.put(key, value); // ② 写入
    }

    ①和②是两个独立的原子操作,中间完全可能被其他线程插入。「先检查再操作」的复合逻辑,Hashtable 保证不了。

    想要真正的原子性,因为锁就是 this,可以自己再套一层:

    synchronized (ht) {
    if (!ht.containsKey(key)) {
    ht.put(key, value);
    }
    }

    这算 Hashtable 的一个便利:它的锁对象就是容器本身(this),所以外部可以直接 synchronized (ht) 把多个操作打包成一个原子块。Collections.synchronizedMap 同理——锁就是包装后返回的那个对象,遍历时同样要 synchronized (syncMap)。

    但这也是把双刃剑:任何持有该引用的代码都能锁住整张表,一旦有人写了 synchronized (ht) { 耗时操作 },全表都会被拖住。这是“锁对象公开”的代价。


    3. null 语义:为什么 Hashtable 一个都不让放

    Hashtable<String, String> ht = new Hashtable<>();
    ht.put("k", null); // NullPointerException
    ht.put(null, "v"); // NullPointerException

    实测两者的 NPE 来源还不一样:

    调用异常位置原因
    put("k", null) Hashtable.put:476 显式检查:if (value == null) throw new NullPointerException();
    put(null, "v") Hashtable.put:481 隐式:执行 key.hashCode() 时空指针

    而 HashMap 允许 1 个 null key + 任意多个 null value:

    HashMap<String, String> hm = new HashMap<>();
    hm.put(null, null);
    hm.put("a", null);
    System.out.println(hm.size()); // 2

    为什么 HashMap 允许 null key? 因为它的 hash 计算对 null 做了特殊处理:

    static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    null key 的 hash 恒为 0,固定落在 0 号桶。putVal 里的判断也随之兼容:

    if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))

    当 key == null 时,p.key == key 直接命中(null == null 为 true),equals 根本不会被调用,不会 NPE。

    那 ConcurrentHashMap 为什么也不允许 null?

    这是这道题最能拉开差距的追问:

    并发场景下,get(key) 返回 null 会产生二义性——无法区分是“这个 key 不存在”,还是“这个 key 存在但值是 null”。

    单线程下可以用 containsKey 再确认一次,但在并发环境里,containsKey 和 get 之间状态可能已经变了,两次调用不具备原子性,这个确认本身不可靠。

    ConcurrentHashMap 的设计从源头上拒绝 null,就是为了避免这种“看起来能判断、实际判断不准”的陷阱——它的 javadoc 也明确写着:与 Hashtable 一样,不允许 null key 和 null value。

    引申的实践建议: 业务代码里也尽量别用 null 当值。如果必须表达“空”,用 Optional 或空对象模式,比 map.get(k) == null 安全得多。


    4. 容量与扩容:11 和 16,差的不是数字而是算法

    HashMapHashtable
    默认初始容量 16 11
    负载因子 0.75 0.75
    扩容公式 newCap = oldCap * 2 newCap = oldCap * 2 + 1
    扩容后容量 16 → 32 → 64 11 → 23 → 47

    实测扩容序列(反射读取 table 数组长度):

    HT default table : 11
    HT table after put : 11
    HT table grown : 47 (size=31)

    为什么 HashMap 坚持 2 的幂

    因为它的索引是 (n – 1) & hash。只有 n 是 2 的幂时,n – 1 的二进制才是全 1,位与运算才等价于取模,且能完整保留低位信息。传非 2 的幂进去,构造器也会用 tableSizeFor 向上取整。

    为什么 Hashtable 用 11 和 2n+1

    因为它用的是取模,不需要 2 的幂:

    int index = (hash & 0x7FFFFFFF) % tab.length;

    (& 0x7FFFFFFF 是为了把负的 hash 变成正数,否则 % 会得到负索引。)

    • 初始容量选 11:11 是质数,取模后分布更均匀,减少冲突;
    • 扩容选 2n+1:保持容量始终是奇数,避免 hash % n 在 n 为偶数时产生的规律性聚集。

    性能对比: 位与(&)确实比取模(%)快,但这点差异在现代 CPU 上微乎其微。Hashtable 真正的性能问题不在容量算法,而在锁——即使索引算得再快,26 个 synchronized 方法也会把并发性能卡死。


    5. 一个实测出来的意外:两个迭代器行为不同

    这是写这篇文章时最有意思的发现。

    Hashtable<String,String> ht = new Hashtable<>();
    for (int i = 0; i < 5; i++) ht.put("k" + i, "v" + i);

    // 方式一:keySet().iterator()
    for (String k : ht.keySet()) { ht.put("x" + k, "y"); }

    // 方式二:keys() 返回的 Enumeration
    Enumeration<String> en = ht.keys();
    while (en.hasMoreElements()) {
    String k = en.nextElement();
    ht.put("x" + k, "y");
    }

    JDK 21 实测输出:

    keySet() + 遍历中修改 : java.util.ConcurrentModificationException
    keys() + 遍历中修改 : 无异常,size = 12

    同一个类,两种迭代方式,行为完全相反。

    原因在 Hashtable.Enumerator 的构造参数:

    // 简化示意
    Enumerator(Hashtable<K,V> table, boolean iterator) {
    ...
    this.expectedModCount = iterator ? table.modCount : 0; // 只有 iterator 模式才记录
    }

    • keySet() / entrySet() 走的是 Iterator 模式(iterator = true),每次 next() 校验 modCount,改了结构就抛 CME —— 这就是 fail-fast;
    • keys() / elements() 返回的是老式 Enumeration(iterator = false),不做校验,遍历期间修改不报错,但可能读到重复或已删除的元素,结果不确定。

    这个差异的实际影响:

    老项目里用 keys() 遍历 Hashtable 一直相安无事,某天重构把它改成 keySet(),突然开始偶发 ConcurrentModificationException——这不是 bug,是行为变了。

    正确姿势(两种都一样):遍历 Hashtable 必须自己加锁。

    synchronized (ht) {
    for (String k : ht.keySet()) { ... }
    }

    因为迭代器本身不持有锁,它只是在读 table 数组,其他线程随时可能 rehash 把数组换掉。

    最后一句话记住:fail-fast 是“尽力检测”,不是“并发安全保障”。 它只能帮你尽早发现误用,不能替代同步。


    6. 三个容易踩的 API 陷阱

    6.1 contains() 判的是值

    ht.contains("v") // 等价于 containsValue("v")

    想知道 key 在不在,用 containsKey。这个命名坑在维护老代码时很常见。

    6.2 putAll 可能半途失败

    Hashtable<String,String> ht = new Hashtable<>();
    Map<String,String> src = new HashMap<>();
    src.put("a", "1");
    src.put("b", null); // 这一条会让 putAll 中途抛 NPE
    ht.putAll(src); // 结果:ht 里有 "a",没有 "b"

    部分写入成功——putAll 没有事务性。从 HashMap 迁移数据到 Hashtable 时,如果源数据里有 null 值,就会遇到这种“半个表”的状态。

    6.3 Dictionary 不是 Map

    Hashtable 实现的是 Map 接口(JDK 1.2 后补的),但它同时继承了 Dictionary。所以它能被当成 Map 用,但 Dictionary 类型本身没有 entrySet()、keySet() 这些现代 API。老代码里如果变量声明成 Dictionary,能调的方法非常有限。


    7. 现在到底该怎么选

    场景选择理由
    单线程 HashMap 无锁开销,性能最好
    多线程读写 ConcurrentHashMap 桶级锁 + CAS,读几乎无锁
    需要存 null 值 HashMap Hashtable 和 CHM 都不允许
    已有 Map 要加同步 Collections.synchronizedMap 能包装任意 Map,但同样是全表锁
    遍历时要强一致 自己加锁或用快照 任何并发 Map 的迭代器都是弱一致
    遗留代码 保持,逐步替换 直接换会踩 null 和 contains 的坑

    为什么 Collections.synchronizedMap 也不推荐? 它和 Hashtable 本质相同——都是对整张表加同一把锁,并发性能和 Hashtable 一个水平,只是锁对象可由你指定。

    ConcurrentHashMap 的迭代器是弱一致的:遍历时不阻塞、不抛 CME,但不保证看到最新的写入。这是并发容器“用一致性换吞吐”的典型设计。


    8. 高频追问 10 连

    Q1:Hashtable 是线程安全的吗?
    单个方法是(synchronized),但复合操作不是。“先判断再操作”必须自己 synchronized (ht) 包起来。

    Q2:为什么初始容量是 11?
    历史遗留 + 取模算法需要质数。11 是质数,hash % 11 分布均匀。换成 16 也不会错,只是冲突率略变。

    Q3:为什么扩容是 2n + 1?
    保持容量始终为奇数,避免偶数容量在取模时产生的规律性聚集。

    Q4:ConcurrentHashMap 为什么比 Hashtable 快?
    Hashtable 是整表一把锁;CHM 在 JDK 7 是分段锁,JDK 8 起是 CAS + synchronized 锁单个桶的头节点,锁粒度细化到桶级别,读操作基本无锁。

    Q5:HashMap 的 null key 存在哪个桶?
    0 号桶,因为 hash(null) 返回 0。

    Q6:为什么 ConcurrentHashMap 不允许 null?
    并发下 get 返回 null 无法区分“键不存在”和“值为 null”,而 containsKey 与 get 之间没有原子性,这个判断不可靠,会产生隐蔽的并发 bug。

    Q7:把 Hashtable 换成 HashMap 要注意什么?
    三件事:① null 语义变了(Hashtable 拒绝,HashMap 接受,可能把原本的 NPE 变成“悄悄存入”);② contains(v) 要改成 containsValue(v);③ 同步要靠调用方自己保证。

    Q8:Dictionary 和 Map 什么区别?
    Dictionary 是抽象类,Map 是接口。Dictionary 已被官方标注 obsolete,新的实现都应该实现 Map。

    Q9:为什么现在还会被问到 Hashtable?
    因为它是一道“活化石题”——由它引出线程安全、null 语义、扩容算法、fail-fast 四个知识点,考察成本低、区分度高。

    Q10:HashMap 线程不安全的具体表现是什么?
    并发 put 丢数据、JDK 7 扩容头插法成环导致 get 死循环、size 计数不准。这三点展开就是下一篇的内容。


    9. 3 分钟答题模板

  • 结论:Hashtable 是 JDK 1.0 的遗留类,方法级 synchronized,整表一把锁;HashMap 是 JDK 1.2 的替代品,不保证线程安全但性能好得多;
  • 差异:分四条讲——线程安全(全表锁 vs 无锁)、null(都不允许 vs 允许 1 个 null key)、容量(11 取模 vs 16 位与)、迭代器(Enumeration vs fail-fast Iterator);
  • 为什么淘汰:全表锁导致并发退化成串行,现代场景用 ConcurrentHashMap;
  • 加分:提一句“Hashtable 的 keys() 不是 fail-fast,keySet() 才是”,以及“CHM 拒绝 null 是为了避免并发二义性”。

  • 小结

    Hashtable → JDK 1.0,继承 Dictionary,全表 synchronized
    HashMap → JDK 1.2,AbstractMap,无锁但高性能
    null → HT 全拒绝;HM 允许 1 个 null key(0 号桶)
    容量 → HT: 11 起步,2n+1,取模;HM: 16 起步,2 倍,位与
    迭代器 → HT: keys() 不 fail-fast,keySet() fail-fast
    现代替代 → ConcurrentHashMap

    实测环境:JDK 21.0.8(Windows),反射部分通过 –add-opens java.base/java.util=ALL-UNNAMED 读取内部 table 字段。

    下一篇拆 「synchronized 和 ReentrantLock 的区别」——一个关键字一个类,为什么 ReentrantLock 能中断、能超时、能公平,以及为什么 JDK 6 之后「synchronized 慢」这个结论已经过期了。


    赞(0)
    未经允许不得转载:171主机测评 » 一道“过时”的面试题:HashMap 和 Hashtable 到底差在哪?
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址