三篇聊下来,HashMap 的核心机制、进阶原理、并发安全都过了一遍了。这篇是系列最后一篇,聊聊一个我在面试里被反复问到的问题:HashMap、Hashtable、ConcurrentHashMap 到底有什么区别?分别应该在什么时候用?
这个问题表面上是问三个类的区别,实际上是在考察你对整个哈希表体系的理解。如果你能把前几篇的内容串起来,这个问题其实不难回答。
但有个前提:你得先搞清楚 Hashtable 是什么。
Hashtable 是什么
说实话,我第一次看到 Hashtable 这个名字的时候,第一反应是"这不就是 HashMap 加了个 s 吗?"
后来才发现这两个东西除了名字像,差别还挺大的。
Hashtable 是 Java 早期就有的一个类,JDK 1.0 就有了。那个时候还没有集合框架(Java 集合框架是 JDK 1.2 才引入的),所以它跟 Vector 一样,算是那个时代的产物。
它的底层结构其实跟 JDK 1.7 之前的 HashMap 差不多:数组加链表。数组是主体,链表用来解决哈希冲突。JDK 1.8 的 HashMap 加了红黑树,但 Hashtable 一直没加:不是不能加,是它基本已经被淘汰了,没必要继续维护。
它的线程安全实现方式也很原始:每个方法都加 synchronized。
publicsynchronizedVput(Kkey,Vvalue){...}publicsynchronizedVget(Objectkey){...}这意味着什么?同一时刻只有一个线程能调用 Hashtable 的方法。不管你是在读还是在写,不管操作的是哪个 key,都得排队。
我以前写过一个比喻:Hashtable 就像只有一个收银台的超市,所有顾客排一条队。ConcurrentHashMap 像有多个收银台的大超市,顾客可以分流到不同的收银台。HashMap 则像无人超市:不用排队,但也没人管秩序,可能会出乱子。
Hashtable 的一些特性
有几个点值得一提。
第一,Hashtable 不支持 null key 和 null value。如果你传一个 null 进去,会直接抛 NullPointerException。原因跟 ConcurrentHashMap 一样:多线程环境下,null 返回值无法区分"key 不存在"和"key 对应的 value 就是 null"。
第二,Hashtable 的扩容策略跟 HashMap 不一样。HashMap 默认容量是 16,扩容翻倍变成 2 的 n 次方。Hashtable 默认容量是 11,扩容变成 2n+1(原来的 2 倍加 1)。而且 Hashtable 的索引计算用的是取模hash % tab.length,不是 HashMap 的位运算hash & (length - 1)。因为它的容量不是 2 的 n 次方,用不了位运算优化。
第三,Hashtable 底层只有数组加链表,没有红黑树。所以如果哈希冲突严重,某个桶的链表变得很长,查找效率就会从 O(1) 掉到 O(n)。而 HashMap 在这一点上做了优化:链表太长就转红黑树。
HashMap 和 Hashtable 的区别
这个问题面试出现的频率挺高的,我当初准备面试的时候专门背过一张对比表。
我把它整理了一下:
| 维度 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | 否 | 是(全方法 synchronized) |
| null key/value | 支持 | 不支持 |
| 默认容量 | 16 | 11 |
| 扩容策略 | 翻倍(2 的幂次方) | 2n+1 |
| 索引计算 | hash & (length - 1) | hash % length |
| 底层结构 | 数组+链表+红黑树(JDK 1.8) | 数组+链表 |
| 继承 | AbstractMap | Dictionary(过时的抽象类) |
| 性能 | 高 | 低 |
| 现状 | 广泛使用 | 基本被淘汰 |
有几个点我觉得值得展开说一下。
线程安全这部分要注意:Hashtable 虽然是线程安全的,但它的安全是以牺牲性能为代价的。每个方法都加 synchronized,意味着任何时刻只能有一个线程操作。在低并发场景下可能还能用,一旦并发上去,性能瓶颈就很明显了。
null key 的处理差异:HashMap 明确支持 null key,会在 put 的时候特殊处理:把 hash 值设为 0,放入 0 号桶。Hashtable 则直接拒绝,因为它的 put 方法里会先检查 value 是否为 null:
publicsynchronizedVput(Kkey,Vvalue){if(value==null){thrownewNullPointerException();}// ...}注意这里只检查了 value,没检查 key。但 key 是通过key.hashCode()来算索引的,key 为 null 的话调 hashCode 会抛 NPE。
扩容差异:Hashtable 用 2n+1 而不是 2 倍,目的其实是为了让容量是奇数(或者说是质数),取模运算时分布更均匀。但 HashMap 选择了另一种思路:用位运算代替取模,通过让容量是 2 的幂次方来实现快速寻址。两种思路各有道理,但从实际效果来看,HashMap 的方案更优,因为位运算比取模快太多了。
现状:Hashtable 基本已经被淘汰了。我学 Java 到现在,没有在任何一个实际项目里见到过 Hashtable 的使用。它更像是一个历史遗迹,告诉后来的开发者"我们曾经是这样做的,现在有更好的方式了"。知道它的存在、能说出它的问题就够了,不用花太多精力去研究它。
ConcurrentHashMap 比 Hashtable 好在哪里
这个问题其实在上一篇已经详细讲过了,这里只做对比总结。
Hashtable 和 ConcurrentHashMap 都是线程安全的 Map,但设计思路完全不同。
Hashtable 的思路是"不管你是谁,进来先排队"。所有方法都加 synchronized,简单粗暴,但并发度是 1。
ConcurrentHashMap 的思路是"如果你们不冲突,就不用排队"。JDK 1.7 用分段锁把数据分成多个 Segment,不同 Segment 之间的操作可以并发。JDK 1.8 更进一步,把锁粒度缩小到每个桶的头节点,配合 CAS 实现更细粒度的并发控制。
我把三个放到一起对比:
| Hashtable | ConcurrentHashMap 1.7 | ConcurrentHashMap 1.8 | |
|---|---|---|---|
| 锁粒度 | 整个对象 | 每个 Segment | 每个桶头节点 |
| 锁类型 | synchronized | ReentrantLock | CAS + synchronized |
| 并发度 | 1 | 16(默认 Segment 数) | 数组长度 |
| 底层结构 | 数组+链表 | Segment+HashEntry | 数组+链表/红黑树 |
| null key/value | 不支持 | 不支持 | 不支持 |
ConcurrentHashMap 和 Hashtable 都不支持 null key/value。原因是一样的:多线程环境下,get 返回 null 的时候,你没法区分是"这个 key 不存在"还是"这个 key 对应的 value 就是 null"。HashMap 单线程用的时候没这个问题,因为你可以通过 containsKey 来判断。但多线程环境下,你调 containsKey 和 get 之间,可能有其他线程修改了数据,所以这种判断就不靠谱了。干脆直接不支持 null,反而更安全。
三种 Map 怎么选
学完这么多理论,最后还是要落到实际选择上。
我自己的经验总结成一句话:单线程用 HashMap,多线程用 ConcurrentHashMap,基本不会用到 Hashtable。
展开说说:
场景一:单线程环境。
唯一的选择就是 HashMap。简单、高效,没有额外的并发开销。绝大多数业务场景都是单线程处理请求,所以 HashMap 的使用频率最高。
场景二:多线程环境,读写操作都比较频繁。
用 ConcurrentHashMap。这是最通用的并发 Map 方案。它的分段锁 / CAS+synchronized 机制保证了高并发下的性能。不管是读多写少还是读写均衡,ConcurrentHashMap 都能应对。
场景三:多线程环境,需要排序。
用 ConcurrentSkipListMap。它基于跳表实现,能保持 key 的有序性。ConcurrentHashMap 不保证顺序,如果业务上需要按 key 的顺序遍历,就得用这个。
场景四:多线程环境,但只是偶尔读一下,几乎没有写。
如果你百分之百确定没有写操作,用 Collections.unmodifiableMap 包装一个 HashMap 就行。但如果不确定,还是用 ConcurrentHashMap 更安全。
Hashtable 在什么时候用?
说实话,我想不出什么场景会主动选择 Hashtable。如果你需要一个线程安全的 Map,ConcurrentHashMap 在任何维度上都优于 Hashtable。Hashtable 的存在更多是历史原因:它在 ConcurrentHashMap 出现之前就被广泛使用了,但因为性能问题,现在基本没人会在新代码里用它。
面试的时候可能会问它的区别,但实际写代码的时候,我的建议是:忘了 Hashtable 吧,记住 ConcurrentHashMap 就够了。
把三条线串起来
三篇 HashMap 相关的文章写下来,从核心机制到进阶原理到并发安全,再到这一篇的三者对比,我觉得这个链条算是完整了。
回想一下我学这些东西的历程。
一开始,我只知道 HashMap 存键值对的,用起来很方便。后来发现多线程下会出问题,知道了 ConcurrentHashMap 这个存在。再后来面试被问到"为什么 HashMap 的容量是 2 的 n 次方"“为什么用红黑树”“分段锁怎么工作的”,才开始去翻源码。
源码看了一遍又一遍,有些地方看了好几遍才看懂。看懂了之后又去写 demo 验证,确认自己的理解是对的。
这个过程挺痛苦的,但也是最有收获的。那些背下来的面试题,在翻了源码之后,才真正变成了自己的理解。
现在回过头来看这四篇文章,其实就是我自己的学习笔记。从一个新手的视角,把 HashMap 从底层到顶层串了一遍。
如果你也是刚学这些东西,希望这个系列能帮你少走一些弯路。不用担心一开始记不住那么多细节,先把整体框架搭起来,用到的时候再回去查。写代码多了,自然就熟练了。
毕竟,我也是这么一点点啃过来的。
Hashtable 与集合对比总结:Map 三雄特性横向对比
定义与边界
这块讲 Hashtable 的底层实现原理以及 HashMap、Hashtable、ConcurrentHashMap 三者的全面对比。包括 Hashtable 的数据结构、线程安全实现方式、三种 Map 在底层数据结构、线程安全机制、null 键值支持、初始化容量、扩容策略、性能特点等方面的差异。不包括 HashMap 和 ConcurrentHashMap 的详细内部原理(分别归入对应分类)。
内容归属
Hashtable 底层实现原理
- 底层数据结构:数组 + 链表,数组是主体,链表是解决哈希冲突存在的。
- Hashtable 的所有公共方法均采用 synchronized 关键字。当一个线程访问同步方法时,其他线程访问就会陷入阻塞或轮询状态。
- 不支持 null 键和值:
put(null, value)会抛 NullPointerException。 - 默认初始容量为 11,每次扩容变为原来的 2n+1。
- 创建时给定了初始容量,会直接用给定的大小,不做 2 的幂次方调整。
Hashtable 线程安全实现
Hashtable 的 put 和 get 都做成同步方法:
publicsynchronizedVput(Kkey,Vvalue){...}publicsynchronizedVget(Objectkey){...}这种实现方式导致任何一个时刻只能有一个线程操作 Hashtable,效率比较低。
HashMap 与 Hashtable 区别
| 维度 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | 否 | 是(synchronized) |
| null key/value | 支持 null key 和 null value | 不支持 null key/value |
| 默认初始容量 | 16 | 11 |
| 扩容策略 | 2 倍(2 的幂次方) | 2n+1 |
| 底层结构 | 数组+链表+红黑树(JDK 1.8) | 数组+链表 |
| 效率 | 高 | 低(全方法同步) |
| 现状 | 广泛使用 | 基本被淘汰 |
Hashtable 与 ConcurrentHashMap 区别
| 维度 | Hashtable | ConcurrentHashMap |
|---|---|---|
| 底层结构 | 数组+链表 | JDK 1.7:分段数组+链表;JDK 1.8:数组+链表/红黑树 |
| 线程安全方式 | synchronized 锁整个对象 | JDK 1.7:分段锁(Segment+ReentrantLock);JDK 1.8:CAS+synchronized 锁桶头节点 |
| 并发度 | 低(一次只能一个线程) | 高(多线程可并发操作不同段/桶) |
| null key/value | 不支持 | 不支持(多线程下无法区分"key 不存在"和"value 就是 null") |
| 性能 | 低 | 高 |
HashMap、Hashtable、ConcurrentHashMap 对比总结
HashMap:
- 线程不安全,效率高,支持 null key/value。
- 默认初始容量 16,扩容 2 倍,保持 2 的幂次方。
- 底层数组+链表+红黑树(JDK 1.8)。
Hashtable:
- 线程安全(全方法 synchronized),效率低,不支持 null key/value。
- 默认初始容量 11,扩容 2n+1。
- 底层数组+链表,基本被淘汰。
ConcurrentHashMap:
- 线程安全,高性能,不支持 null key/value(抛 NPE)。
- JDK 1.7:分段锁(Segment 数组,每个 Segment 拥有自己的数组和独立的 ReentrantLock),读写可并行。
- JDK 1.8:取消 Segment,直接锁桶头节点,数组+链表/红黑树,volatile+CAS+synchronized,锁粒度从"段"缩小到"桶",并发度更高。
与其他分类的关系
Hashtable 不依赖任何其他分类即可独立阅读。HashMap 的详细原理请见"HashMap 核心机制"和"HashMap 进阶设计原理",ConcurrentHashMap 的详细原理请见"并发安全与 ConcurrentHashMap"。