news 2026/10/6 9:35:39

HashMap vs Hashtable:从源码到面试,彻底搞懂九大差异

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HashMap vs Hashtable:从源码到面试,彻底搞懂九大差异

二面的时候被问到“HashMap 和 Hashtable 有什么区别”,我第一反应是背八股:Hashtable 线程安全、不允许 null、初始容量 11。结果面试官一句“那 HashMap 为什么把 null key 放在 table[0]?Hashtable 的扩容为什么是乘 2 加 1?”直接把我问懵了。后来自己啃源码、翻 JDK 历史文档,才算把这两个容器彻底吃透。

说实话,这道题在 Java 基础里算“老演员”了,但每次面试依然高频出现。它不是单纯考记忆,而是考你对哈希表底层结构、线程安全模型、以及 JDK 演进方向的综合理解。这篇文章我不打算只列一个对比表格完事,而是把每个差异背后“为什么这么设计”也讲清楚,再附上从源码层面验证的细节,以及我实际面试时踩过的坑和总结的回答话术。

如果你是正在准备 Java 面试的开发者,或者工作中需要做集合选型,这篇文章可以帮你把这条知识线彻底打通。

1. 先记住九大差异:这是基础分

1.1 面试中最常被问到的七个点

先给一张对照表,把最常见的差异点一次性说清。这些不是全部,但已经是面试考频最高的部分了。

对比维度HashMapHashtable
线程安全非线程安全线程安全(方法级 synchronized)
null 键/值key/value 均允许 nullkey/value 均不允许 null
继承体系AbstractMapDictionary(Dictionary 是老古董,现在基本废弃)
初始容量1611
扩容方式容量翻倍(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; }

流程分四步:

  1. 表为空就先扩容。
  2. 用(n - 1) & hash找到桶位,如果桶是空直接放新节点。
  3. 桶不为空就判断头节点、判断是否树节点、链表遍历三种情况。
  4. 插入完成后++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 相关的问题,你都会觉得轻松很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 9:34:45

Agent-Reach:面向非技术用户的轻量级智能体调度CLI

1. 项目概述&#xff1a;Agent-Reach 是什么&#xff0c;它解决的不是“调用API”这个表层问题Agent-Reach 这个名字乍看像某个开源库或工具链代号&#xff0c;但结合它在热搜词中与 CLI、API、YouTube、Reddit 的高频共现&#xff0c;再叠加近期技术社区里反复刷屏的 “llm-de…

作者头像 李华
网站建设 2026/10/6 9:34:40

自建OpenShell终端工作流:tmux+fzf+本地模型提升运维效率

先把话说在前面&#xff1a;如果你每天的工作就是对着黑底白字的终端敲命令&#xff0c;那你大概率已经受够了这几件事——服务器一多&#xff0c;IP和密钥记不住&#xff1b;命令历史长得像流水账&#xff0c;想翻一条昨天用过的命令得按十几下方向键&#xff1b;写过的运维脚…

作者头像 李华
网站建设 2026/10/6 9:34:38

Circuitjs占空比调节全攻略:从555定时器到PWM信号源实操

先交代一个背景。我平时会带硬件爱好者做实操入门&#xff0c;这类问题被问得最多&#xff1a;“老师&#xff0c;我在Circuitjs里搭好了波形发生器&#xff0c;占空比怎么调都调不动&#xff0c;到底该加什么、改哪里&#xff1f;”其实这个需求本身并不复杂&#xff0c;难的是…

作者头像 李华
网站建设 2026/10/6 9:34:15

Java字符串处理实战:从不可变性到性能优化的完整指南

写字符串相关的文章&#xff0c;其实挺容易写成"API字典"的&#xff0c;罗列一堆方法名和参数&#xff0c;看完就忘。但这东西恰恰是日常开发里最绕不开的&#xff1a;拼SQL、拆报文、处理文件名、解析配置、格式化输出&#xff0c;哪一个都离不开字符串操作。偏偏这…

作者头像 李华
网站建设 2026/10/6 9:33:39

从new Thread到线程池:核心参数与运行机制全拆解

很多人学并发编程&#xff0c;都是从 new Thread 起步的。我也一样&#xff0c;早期写多线程代码基本就是一把梭&#xff1a;要并发&#xff1f; new Thread 就行。直到有一天线上服务出了问题&#xff0c;线程数飙到几百&#xff0c;每个线程都在那空转&#xff0c;CPU 被…

作者头像 李华
网站建设 2026/10/6 9:33:34

Agent-Reach:轻量级智能体互联网关的设计与实践

每个做智能体&#xff08;Agent&#xff09;的人&#xff0c;大概率都遇到过同一个尴尬&#xff1a;单机跑得好好的 Agent&#xff0c;一旦想让它调用另外一个系统里的 Agent&#xff0c;或者让两个不同团队开发的 Agent 互相协作&#xff0c;立刻变成一场灾难。地址写死、接口…

作者头像 李华