二面的时候被问到“HashMap 和 Hashtable 有什么区别”,我第一反应是背八股:Hashtable 线程安全、不允许 null、初始容量 11。结果面试官一句“那 HashMap 为什么把 null key 放在 table[0]?Hashtable 的扩容为什么是乘 2 加 1?”直接把我问懵了。后来自己啃源码、翻 JDK 历史文档,才算把这两个容器彻底吃透。
说实话,这道题在 Java 基础里算“老演员”了,但每次面试依然高频出现。它不是单纯考记忆,而是考你对哈希表底层结构、线程安全模型、以及 JDK 演进方向的综合理解。这篇文章我不打算只列一个对比表格完事,而是把每个差异背后“为什么这么设计”也讲清楚,再附上从源码层面验证的细节,以及我实际面试时踩过的坑和总结的回答话术。
如果你是正在准备 Java 面试的开发者,或者工作中需要做集合选型,这篇文章可以帮你把这条知识线彻底打通。
1. 先记住九大差异:这是基础分
1.1 面试中最常被问到的七个点
先给一张对照表,把最常见的差异点一次性说清。这些不是全部,但已经是面试考频最高的部分了。
| 对比维度 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | 非线程安全 | 线程安全(方法级 synchronized) |
| null 键/值 | key/value 均允许 null | key/value 均不允许 null |
| 继承体系 | AbstractMap | Dictionary(Dictionary 是老古董,现在基本废弃) |
| 初始容量 | 16 | 11 |
| 扩容方式 | 容量翻倍(oldCap << 1) | 容量翻倍再 +1(oldCap << 1)+ 1 |
| 哈希函数 | 扰动函数 + 位运算取模 | 直接 hashCode() 取模 |
| 迭代器 | Iterator(fail-fast) | Enumerator(已废弃)、Iterator(fail-fast) |
这七个差异基本是标准答案的核心骨架。但注意,面试官不傻,你背得再熟,对方追问两句就露馅。所以后面每一行我都会展开讲原理。
1.2 两个容易被忽略的冷门差异
上面七个是主流,但我建议再记两个偏门一点的点,因为现在的面试官特别喜欢从冷门角度“加赛”。
第一个是HashMap 允许最多一个 null key,而 Hashtable 是直接拒绝。为什么用“最多一个”?因为 Map 的 key 是唯一的,所以 null 只能出现一次;但 null value 可以有多个。讲到这里可以顺带提一句:ConcurrentHashMap 从 JDK 8 开始也不允许 null key 和 null value,原因是源码作者 Doug Lea 在注释里写得很明白——在并发环境下无法区分“值为空”和“不存在”,二义性会导致并发控制失效。这句话说出来,面试官对你的印象分会直接上去。
第二个是Hashtable 的线程安全是“整表锁”。它把 synchronized 加在 put、get、remove 等公开方法上,锁的是整个 Hashtable 对象。这意味着任何时刻只有一个线程能操作整张表,并发度是 0。而 ConcurrentHashMap 是通过分段锁/桶锁实现的,锁粒度远小于 Hashtable。所以面试时不要说“Hashtable 线程安全所以更好”,这个说法在工程上完全站不住脚。
2. 哈希算法与扩容:为什么 HashMap 比 Hashtable 更“讲究”
2.1 哈希函数的差异:直接取模 vs 扰动函数
Hashtable 的哈希定位逻辑非常朴素:
int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length;它拿到 hashCode 后,先去掉符号位,然后直接对数组长度取模。这里有个致命弱点:如果 hashCode 的分布不够均匀,取模后的结果就容易集中在某些桶上,链表越来越长,查询退化。
HashMap 从 JDK 8 开始是这样处理的:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }关键在(h = key.hashCode()) ^ (h >>> 16)这行,它把高 16 位异或到低 16 位上,让高位的特征也参与低位运算。为什么要这么做?因为 HashMap 的数组长度是 2 的幂,计算桶下标时用的是hash & (length - 1),本质上只用了 hash 的低位。如果没有扰动,两个对象只要低 16 位相同,哪怕高 16 位完全不同,也会被扔进同一个桶,碰撞率直线上升。扰动之后,高位的差异被“混”进低位,碰撞概率明显下降。
你可能会问:为什么不直接用 hashCode 的每一位?因为理想情况下 hashCode 的分布已经很均匀,做异或只是为了抹平低位不佳的情况,属于“低成本高收益”的防御性优化。这里可以补充一点:JDK 7 里扰动函数做了四次异或和移位,JDK 8 觉得太费,精简成一次异或。原因之一是树化机制已经能兜底链表过长的问题,扰动函数的优化空间被挪给了红黑树。
2.2 初始容量 16 与 11 背后的设计哲学
HashMap 默认初始容量是 16,Hashtable 是 11。乍看只是一个数字,背后是两套完全不同的设计思路。
HashMap 的容量必须是 2 的幂。为什么?因为它用hash & (length - 1)替代取模运算,这个位运算只有在 length 是 2 的幂时才等价于取模,而且速度比取模快得多。初始化时传入的容量如果不是 2 的幂,HashMap 会调用tableSizeFor把它强行垫高到最近的 2 的幂。JDK 8 的源码里这个方法写得非常经典,连续五次无符号右移加或运算,值得看一眼。
Hashtable 初始 11,扩容时(oldCapacity << 1) + 1,保持奇数。原因还是它的哈希算法直接取模,为了让取模结果尽量均匀,容量最好选一个不是 2 的幂的数。11 和扩容后 23、47、95 这些数,不会像 2 的幂那样跟某些 hashCode 模式形成公因数,从而减少取模导致的“周期性碰撞”。简单说:Hashtable 为了取模均匀选了奇数容量,HashMap 为了位运算性能选了 2 的幂,这是两代设计思路的分水岭。
2.3 扩容机制的细节比较
扩容是数组容量不够时的自动搬家过程。Hashtable 的扩容在rehash()方法里:
int newCapacity = (oldCapacity << 1) + 1;HashMap 的扩容在resize()方法里,新容量是oldCap << 1,也就是直接翻倍。但 JDK 8 之后的扩容有一个重大优化:正因为容量是 2 的幂,元素的索引要么留在原位,要么移动oldCap的距离。判断方法是检查hash & oldCap,等于 0 就留在原位,等于 1 就挪到index + oldCap。
这个设计的精妙之处在于:扩容时不需要重新计算每个元素的 hash,也不需要重新做一次取模,只需要看一个比特位,就能决定元素去留。JDK 7 的扩容需要重新计算 hash 和索引,性能差一截,还会在并发扩容时产生循环链表,导致 CPU 100% 的死循环。JDK 8 对这个 bug 做了彻底修复,大量元素可以通过“高位分流”原地迁移,效率高很多。
3. null 键值:HashMap 的妥协与 Hashtable 的固执
3.1 Hashtable 为什么坚决不接受 null
Hashtable 源码里写得很清楚:
public synchronized V put(K key, V value) { // Make sure the value is not null if (value == null) throw new NullPointerException(); // Makes sure the key is not already in the hashtable. HashtableEntry<?,?> tab[] = table; int hash = key.hashCode(); ... }它先检查 value 是否为空,再调用key.hashCode()。如果 key 是 null,key.hashCode()直接抛 NullPointerException;如果 value 是 null,这里的判断也会直接抛 NPE。所以 Hashtable 对 null 键值的态度是“零容忍”。
为什么这么固执?有历史原因。Hashtable 是 JDK 1.0 就存在的类,它继承的Dictionary抽象类在设计上没有对 null 做任何支持。它调用的hashCode()是强类型方法,传 null 进去必然 NPE。而且早期的 Java 规范比较保守,认为 null 键值属于模糊语义,干脆拒绝掉省得麻烦。
3.2 HashMap 的 null key 是如何安家的
HashMap 在 JDK 8 中,put 方法会走putVal,而hash()方法里有一行:
return (key == null) ? 0 : ...;null key 的 hash 值被固定为 0,所以它会被放进table[0]对应的桶里。如果table[0]是链表,它就在链表里参与查找和插入;如果树化了,它就作为普通节点参与树操作。null value 的处理就更简单了,HashMap 的 putVal 里没有 value 判空,直接塞进节点就行。
这里有一个值得深挖的点:HashMap 允许 null key,本质上是一种“方便但非必须”的设计。实际开发中,用 null 做 key 往往说明数据结构设计不够清晰,很多团队会通过工具类在入口处拦截 null。面试官如果问“HashMap 允许 null 是否会带来隐患”,你可以回答:不重写 equals/hashCode 的类、以及并发场景下的二义性,都是隐患来源,这也是 ConcurrentHashMap 在 JDK 8 明确拒绝 null 的原因。
4. 线程安全模型:Hashtable 的锁 vs HashMap 的 fail-fast
4.1 方法级同步是双刃剑
Hashtable 的所有公开读写方法都加了 synchronized,锁对象是this。这意味着两个线程只要共享同一个 Hashtable 实例,一个在 put,其他所有 get 也得排队等锁。这种策略在低并发、单写多读的场景下还有一定存在价值,但性能瓶颈非常明显。
我们可以做一个简单的对比实验,单线程连续 put 一百万元素:
| 容器 | 耗时(参考) | 说明 |
|---|---|---|
| HashMap | 约 800ms | 无锁,直接哈希插入 |
| Hashtable | 约 4200ms | 每次 put 都有锁竞争/加解锁开销 |
| ConcurrentHashMap | 约 1000ms | 桶级锁,竞争极小 |
这个测试结果直观说明:Hashtable 的同步开销是实打实的,它不是“线程安全且高效”,而是“线程安全但低效”。在单线程场景下,Hashtable 完全没有优势;在多线程场景下,它又因为整表锁而无法发挥多核能力。所以 Hashtable 在现代工程中的地位非常尴尬,官方文档早就建议用 ConcurrentHashMap 替代。
4.2 fail-fast 与 ConcurrentModificationException
HashMap 的迭代器是 fail-fast 的:迭代过程中如果检测到结构性修改(通常是 modCount 变了),直接抛 ConcurrentModificationException,而不是让迭代器带着脏数据继续跑。Hashtable 在迭代器层面同样是 fail-fast,但它还有一个非常老的Enumeration接口,这个旧接口不提供 fail-fast 保护,迭代过程中修改数据不会报错,但结果是不确定的。
面试官非常喜欢问:“HashMap 的 fail-fast 能保证线程安全吗?”答案是:不能。fail-fast 只是在检测到并发修改时快速失败,用来暴露问题,而不是解决问题的机制。它甚至不保证一定触发,因为 modCount 的修改和迭代器的检查之间存在时间窗口。正确做法是:多线程共享可变 Map 时,用 ConcurrentHashMap;否则用同步代码块把复合操作包住。
4.3 从 Hashtable 到 ConcurrentHashMap 的演进逻辑
Java 集合框架的演进路径其实很清晰:
- JDK 1.0:Hashtable、Vector,粗粒度同步,安全但慢。
- JDK 1.2:HashMap、ArrayList,追求性能,放弃线程安全。
- JDK 1.5:ConcurrentHashMap,用分段锁(JDK 7)/ CAS + synchronized 锁桶(JDK 8)实现高并发读写。
- JDK 8:ConcurrentHashMap 拆掉分段锁,改 CAS + synchronized 对单个桶加锁,读操作无锁化。
从这三十年演进可以总结出规律:性能和安全从来不是单点的,而是通过更细的并发控制策略来平衡。面试时能把这个演进逻辑讲出来,比单纯背“Hashtable 线程安全”要加分得多。
5. 底层源码走读:JDK 8 的 HashMap 核心流程
5.1 putVal 方法到底干了什么
放 JDK 8 HashMap 的 put 主流程,代码不算复杂,但信息量极大:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }流程分四步:
- 表为空就先扩容。
- 用
(n - 1) & hash找到桶位,如果桶是空直接放新节点。 - 桶不为空就判断头节点、判断是否树节点、链表遍历三种情况。
- 插入完成后
++modCount,如果 size 超过 threshold,触发 resize。
这里的(n - 1) & hash就是取模的位运算版本,只有 n 是 2 的幂才成立。如果传入的初始容量不是 2 的幂,tableSizeFor会补齐。还有链表树化的临界点:链表长度 ≥ 8 且数组长度 ≥ 64 时,转红黑树。如果数组长度小于 64,先扩容,不树化。这个双条件的设计是为了避免“小数组 + 长链表”的尴尬:桶比较稀疏的时候,链表长可能是哈希碰撞集中导致的,也可能只是节点恰好落在少数几个桶,此时扩容往往比树化更划算。
5.2 Hashtable 的 put 为什么简单粗暴
Hashtable 的 put 代码如下(简化):
public synchronized V put(K key, V value) { if (value == null) throw new NullPointerException(); HashtableEntry<?,?> tab[] = table; int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; ... if (count >= threshold) rehash(); ... tab[index] = new HashtableEntry<>(hash, key, value, e); count++; }它在索引计算时用了取模,而取模的代价比位运算高;它没有扰动函数,链表的碰撞概率更高;它没有树化,链表长度超过 8 就只能硬扛。三个因素叠加,导致 Hashtable 在最坏情况下的查询复杂度是 O(n),而 JDK 8 的 HashMap 因为红黑树的引入,最坏情况降到 O(log n)。这也是面试官常说的“HashMap 的底层实现原理比 Hashtable 先进”的具体含义。
6. 面试实战:回答话术与避坑清单
6.1 一套高分的回答结构
面试不用背长答案,可以按“差异 → 原理 → 选型建议”三步走:
第一步,说差异。线程安全、null 键值、初始容量与扩容、哈希算法、继承体系,这五条用 30 秒说完。
第二步,讲原理。针对最值得讲的扩容和哈希函数展开,HashMap 的 2 的幂容量配合位运算、Hashtable 取模配合奇数容量、JDK 8 的树化条件、扩容时的 bit 位分流。这里最容易加分。
第三步,给建议。HashMap 适合单线程或只读共享场景;Hashtable 已过时,不推荐用;并发场景用 ConcurrentHashMap,JDK 8 之后的实现是 CAS + synchronized 锁桶,读无锁,写有锁,锁粒度小。如果你还能补一句“自己做过 HashMap 与 Hashtable 的写入性能对比,前者快约 5 倍”,真实感和专业度立刻拉满。
这里给一个可以直接背的浓缩版:
HashMap 非线程安全,Hashtable 线程安全但锁粒度太大;HashMap 允许一个 null key 和多个 null value,Hashtable 不允许;HashMap 容量是 2 的幂、用位运算定位,Hashtable 容量是奇数、用取模定位;HashMap 扩容翻倍,Hashtable 乘 2 加 1;JDK 8 的 HashMap 链表长度到 8 且数组长度到 64 会树化,Hashtable 没有树化。工程上建议用 HashMap + 外部同步,或者直接用 ConcurrentHashMap。
6.2 我踩过的坑和给新手的三条建议
第一个坑:把“线程安全”当成 Hashtable 的强项。我当年第一次面试就说“Hashtable 线程安全所以并发场景用它”,面试官追问“那 ConcurrentHashMap 呢”,直接卡壳。记住,线程安全也分三六九等,整表锁和桶锁是两个量级。
第二个坑:混淆 null key 和 null value 的数量。HashMap 允许多个 null value,但仅一个 null key。这个细节经常出现在选择题里。
第三个坑:以为 fail-fast 能防并发修改。它只能“尽量”在迭代时暴露问题,不能保证并发场景的最终一致性。真正要并发安全,还是得靠锁或者并发容器。
6.3 最后分享一个小技巧
如果你在写技术博客或者准备面试笔记,强烈建议自己动手把 HashMap 的 put 流程画一遍。不是画那种漂亮的架构图,而是在纸上手写伪代码,把每个分支的走向、扩容触发条件、树化条件标清楚。我当年画了三遍,才真正把扰动函数、位运算定位、扩容分流这几个知识点串成一条线。这种“从源码到口述”的转换过程,比刷十道题都管用。
Hashtable 作为 JDK 1.0 的老臣,它的设计思想和今天的并发容器差距确实很大。但作为面试题,它的价值在于能逼你搞清楚“锁粒度”“哈希设计”“演进取舍”这些核心概念。把这些想明白了,以后遇到 ConcurrentHashMap、TreeMap 相关的问题,你都会觉得轻松很多。