/* 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 没加锁,单线程下跑得快,但多线程环境得自己保证同步。
其他几个关键区别:
容量算法(16 vs 11、位与 vs 取模)、hash 扰动这些也常被追问,扫一眼下面的速查表即可:
| 引入版本 | 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,也就是说整张表共用一把锁。带来三个后果:
但它是“线程安全”的吗?打引号
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,差的不是数字而是算法
| 默认初始容量 | 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,继承 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 慢」这个结论已经过期了。



