从最简单开始:Hashtable 和 ConcurrentHashMap 是啥?
在 Java 里,Hashtable 和 ConcurrentHashMap 都是键值对存储的工具,功能上差不多,但用起来差别可不小。Hashtable 是老古董,诞生于 JDK 1.0,而 ConcurrentHashMap 是后起之秀,从 JDK 1.5 开始登场,主打并发场景。咱先从最朴素的视角看看它们有啥不一样。
Hashtable:全家桶锁住
先瞅一眼 Hashtable 的代码,比如 put 方法(简化版):
public synchronized V put(K key, V value) {
// 一些检查,比如 key 或 value 不能是 null
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
// 插入逻辑,处理冲突啥的
return value;
}
看到没?put 方法直接加了个 synchronized,这意味着啥?整个方法被锁住了!再看看 get:
public synchronized V get(Object key) {
// 查找逻辑
}
又是 synchronized!也就是说,Hashtable 的所有操作——不管读还是写——都得排队,一个线程干活,其他线程等着。这种锁叫“方法级同步”,本质上是把整个对象锁住,用的就是 Java 的内置对象锁(monitor lock)。简单粗暴,像把整个房子锁上,谁想进屋都得拿钥匙。
ConcurrentHashMap:分片锁试试
再看看 ConcurrentHashMap,它可没这么“独”。以 JDK 1.7 为例(后面聊 1.8),它的核心是个 Segment 数组,每个 Segment 管一块数据。put 方法大概长这样:
public V put(K key, V value) {
Segment<K,V> s;
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
s = segments[j]; // 定位到某个 Segment
return s.put(key, hash, value, false);
}
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
lock(); // Segment 级别的锁
try {
// 插入逻辑
} finally {
unlock();
}
}
这儿的关键是 Segment,它继承了 ReentrantLock。不像 Hashtable 一锁锁全家,ConcurrentHashMap 把数据分成 16 个 Segment(默认值),每个 Segment 有自己的锁。写的时候只锁住一个 Segment,其他 15 个还能照常干活。这叫“分段锁”,并发能力一下就上去了。
朴素策略的问题:锁太粗or锁太麻烦?
从这儿就能看出,Hashtable 和 ConcurrentHashMap 的朴素思路有啥毛病。
Hashtable:锁得太狠
Hashtable 用的是对象级别的 synchronized,简单是简单,但问题大了。想象一下,10 个线程同时来读写,第一个线程锁住整个表,其他 9 个只能干瞪眼,哪怕它们操作的是完全不同的键值对。这效率低得可怕,尤其在多线程环境下,吞吐量直接崩盘。为啥用这种锁呢?因为它最省事,JVM 帮你管一切。但本质上,这种锁是“悲观锁”,假设冲突无处不在,干脆全锁了再说。
能不能换别的锁?比如细粒度的锁?理论上行,但 Hashtable 是 JDK 1.0 的产物,那时候多核 CPU 还没普及,设计者压根没考虑并发优化,粗暴锁全家反而最稳。
ConcurrentHashMap(JDK 1.7):分段锁的妥协
ConcurrentHashMap 聪明多了,用分段锁把锁的范围缩小到 Segment。假设有 16 个 Segment,理论上能支持 16 个线程同时写,效率比 Hashtable 高多了。但问题呢?16 这个数是固定的,线程再多也只能锁 16 块,剩下的还得排队。而且 Segment 数量在初始化时定死(默认 16),不能动态调整。如果数据分布不均,某些 Segment 里挤了一堆键,其他的空荡荡,锁冲突还是跑不了。
为啥用 ReentrantLock 呢?因为它比 synchronized 灵活,支持公平锁、非公平锁,还能手动控制锁的释放(tryLock 啥的)。但本质上,它还是“锁”,还是得阻塞线程,只是粒度细了点。
再挖深点:JDK 1.8 的进化
到了 JDK 1.8,ConcurrentHashMap 又升级了,锁机制变得更骚气。咱看看代码:
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
int hash = spread(key.hashCode());
Node<K,V>[] tab = table;
int n = tab.length;
int i = (n - 1) & hash;
Node<K,V> f = tab[i];
if (f == null) {
// CAS 插入
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
return value;
} else {
synchronized (f) { // 锁住链表头节点
// 插入逻辑
}
}
}
这儿有俩大变化:
- 无锁优先,用 CAS:如果桶里没节点,直接用 CAS(
compareAndSwap)插进去,完全不锁。CAS 是啥?简单说就是“比较并替换”,靠硬件指令实现,原子性超强,不用锁也能保证线程安全。 - 细化到节点,用
synchronized:如果桶里有链表了,就只锁链表头节点,而不是整个Segment。锁粒度更小,并发性更高。
为啥这么改?因为 Segment 锁还是有点粗,锁一个 Segment 可能影响好几个键值对。1.8 直接把锁下沉到每个桶的头节点,理论上桶有多少(默认 16,扩容后更多),就能支持多少线程并发写。而且 CAS 无锁操作把性能拉满,减少了锁的开销。
但为啥不用 ReentrantLock 而是回头用 synchronized 了呢?因为 JDK 1.8 后,synchronized 被 JVM 优化得飞起(锁升级机制:偏向锁 → 轻量级锁 → 重量级锁),性能不输 ReentrantLock,还省去了手动管理的麻烦。
朴素策略的短板和优化方向
回头看,Hashtable 和 ConcurrentHashMap 的朴素设计都有坑。Hashtable 锁太死,吞吐量惨不忍睹;JDK 1.7 的 ConcurrentHashMap 分段锁进步了,但粒度不够细,动态性也差。1.8 的版本已经很接近现代方案了,但还能咋优化呢?咱从短板出发,推几个方向:
-
无锁化,能不用锁就不用
CAS 是 1.8 的亮点,但只用在空桶插入。能不能再扩展,比如链表操作也用无锁算法(像 Treiber Stack 那样的无锁链表)?现代并发方案,比如某些数据库的 MVCC(多版本并发控制),就尽量避免锁,靠版本号解决问题。 -
动态调整,分桶更聪明
分段锁的 16 个Segment是死的,1.8 的桶数能扩容,但分布不均还是问题。能不能搞个自适应的桶管理机制,根据负载动态调整?像 Google 的 Guava Cache,就有类似的负载均衡思路。 -
锁粒度再细化,甚至去锁
1.8 锁住头节点已经很细了,但能不能再细分到每个键值对?或者用读写分离的策略,读完全无锁,写用轻量锁,像StampedLock那样。这也是现代并发容器的发展趋势。 -
性能拉满,减少开销
CAS 和synchronized都不错,但 CAS 在高竞争下会自旋浪费 CPU,synchronized在锁升级时也有开销。能不能用硬件级的优化(比如 Transactional Memory)彻底干掉这些损耗?前沿研究里这块已经有点苗头了。
最后唠一句
从 Hashtable 的全家锁,到 1.7 的分段锁,再到 1.8 的 CAS+节点锁,锁的进化就是个“从粗到细,从笨到聪明”的过程。锁的本质是协调线程,防止混乱,但用不好就成瓶颈。现代方案的目标是尽量少锁甚至不锁,同时保证效率和正确性。