HashMap、Hashtable 和 ConcurrentHashMap 的区别?
结论先行:三者都是基于哈希表的 Map,但线程模型完全不同。HashMap 完全不加锁,单线程最快;Hashtable 用 synchronized 修饰每个公开方法,等于整表一把锁;ConcurrentHashMap(下称 CHM)在 JDK 1.8 用 CAS 写空桶、用 synchronized 锁桶头,读操作无锁。并发场景选 CHM,Hashtable 只应出现在维护老代码时。
1. 一张表看清差异
| 维度 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | 否 | 是,方法级同步 | 是,桶级锁 + CAS |
| 锁粒度 | 无 | 整张表 | 单个桶头节点 |
| null 支持 | 1 个 null key、多个 null value | key / value 都不允许 | key / value 都不允许 |
| 初始容量 | 16,必须是 2 的幂 | 11,扩容为 2n+1 | 16,必须是 2 的幂 |
| 扩容方式 | 2 倍,高低位拆分 | 2n+1,全量 rehash | 2 倍,多线程协助迁移 |
| 定位算法 | (n - 1) & hash | (hash & 0x7FFFFFFF) % length | (n - 1) & spread(hash) |
| 迭代器 | fail-fast | fail-fast | 弱一致性,不抛 CME |
| 冲突结构 | 链表 + 红黑树 | 纯链表 | 链表 + 红黑树 |
| 出现版本 | 1.2 | 1.0,遗留类 | 1.5 引入,1.8 重写 |
2. 锁的范围决定了吞吐
Hashtable:一把锁罩住整张表
[============ lock ============]
[ bucket0 ][ bucket1 ][ bucket2 ] ... 写线程全部排队
ConcurrentHashMap:只锁命中的桶头
[ bucket0 ][ bucket1 ][ bucket2 ] ...
^
只锁这里,其他桶照常并发写
Hashtable 的 put、get、size 全是 synchronized 方法,连读都要竞争同一把监视器锁;CHM 的 get 只做 volatile 读,写操作才在桶级别互斥。
3. null 的取舍不是随手定的
Hashtable 遇到 null 直接抛 NullPointerException;CHM 也禁止 null,但理由不同:并发下 get 返回 null 无法区分“key 不存在”和“key 映射到 null”,而 containsKey 再 get 的两步之间状态可能已经变化,所以干脆从入口禁掉,消除歧义。HashMap 单线程没有这个问题,于是允许一个 null key 和任意 null value。
import java.util.Hashtable;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MapNullDemo {
public static void main(String[] args) {
Map<String, String> hashMap = new HashMap<>();
hashMap.put(null, "v"); // 允许,null key 放在 0 号桶
Map<String, String> table = new Hashtable<>();
try {
table.put(null, "v"); // 抛 NullPointerException
} catch (NullPointerException e) {
System.out.println("Hashtable 不接受 null key");
}
ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>();
try {
chm.put("k", null); // 抛 NullPointerException
} catch (NullPointerException e) {
System.out.println("CHM 不接受 null value");
}
}
}
4. 代码:并发计数用 CHM 的原子方法
import java.util.concurrent.ConcurrentHashMap;
public class ChmCounter {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> counter = new ConcurrentHashMap<>();
// 错误示范:get 后再 put,两步之间可能被其他线程覆盖
Integer old = counter.get("click");
counter.put("click", old == null ? 1 : old + 1);
// 正确:merge 在桶锁内完成“读-改-写”
counter.merge("click", 1, Integer::sum);
// 预估容量,减少扩容带来的迁移
ConcurrentHashMap<String, Integer> big = new ConcurrentHashMap<>(1 << 10);
big.put("k", 1);
System.out.println(counter.get("click")); // 输出:2
}
}
5. Hashtable 为什么被淘汰
- 整表锁让写吞吐几乎不随核数增长,多核机器上尤其吃亏。
- 初始容量 11、按 2n+1 扩容,下标必须用取模,无法享受 2 的幂带来的位运算红利。
- API 停留在
Enumeration时代,elements()、keys()的设计与后来的集合框架不一致。 - 不允许 null 在一些业务里反而更别扭;而它提供的线程安全,CHM 能给得更好。
Collections.synchronizedMap(new HashMap<>()) 本质上与 Hashtable 同类:包装类的每个方法对同一个 mutex 加锁,迭代时仍需手动同步,吞吐同样上不去。
常见追问
- CHM 的 size 为什么只是近似值? 计数分散在
baseCount和counterCells里,sumCount遍历求和时其他线程还在写,拿不到同一时刻的快照;需要精确计数用mappingCount()(返回 long)。 - Hashtable 的迭代器是 fail-fast 吗? 是,结构性修改会让
modCount变化,枚举时抛ConcurrentModificationException。 - 为什么 HashMap 允许 null 而 CHM 不允许? 单线程无歧义,并发下有歧义;这是接口契约层面的取舍。
- CHM 的 get 需要加锁吗? 不需要,
tabAt和Node.val都是 volatile 语义,读线程能看到已发布的值。 - 三者能用可变对象当 key 吗? 能,但修改参与 hash 的字段后就再也查不到、删不掉,等价于内存泄漏。
- 只读场景怎么选? 初始化后不再修改,HashMap 足够;一旦有并发写,必须 CHM。
小结
HashMap 是单线程默认选择,快但不安全;Hashtable 用整表锁换安全,属于历史包袱;CHM 用 CAS + 桶级锁把并发度做上去,同时保留 HashMap 的树化能力。回答时抓住三条主线:锁的粒度、null 的处理、迭代器的语义,再补一句“复合操作要用 merge / computeIfAbsent”,基本就是完整答案。