Java Map 并发冲突与 ConcurrentHashMap 分段锁原理解析
大家好,今天我们来深入探讨 Java 中 Map 接口在并发环境下的冲突问题,以及 ConcurrentHashMap 如何利用分段锁机制来解决这些问题。Map 作为一种常用的数据结构,在多线程环境下,如果多个线程同时修改 Map,就会引发数据不一致的问题。为了保证线程安全,我们需要使用线程安全的 Map 实现,而 ConcurrentHashMap 就是一个非常优秀的选择。
1. 并发环境下 HashMap 的问题
首先,我们来看看为什么 HashMap 在并发环境下会出问题。HashMap 内部使用数组 + 链表(或红黑树)来存储键值对。当多个线程同时进行 put 操作,特别是当它们落在同一个桶(bucket)时,就会发生以下问题:
- 数据覆盖: 多个线程可能同时计算出相同的哈希值,并尝试将新的键值对放入同一个桶中。如果没有同步措施,后来的线程可能会覆盖先前线程写入的数据。
 - 链表成环: 在 
resize操作中,如果没有正确的同步机制,可能会导致链表形成环状结构。当get操作访问到环状链表时,会导致死循环,最终导致 CPU 占用率过高。 - 其他数据不一致问题: 例如,
size属性可能不准确,迭代过程中可能出现ConcurrentModificationException。 
为了更直观地理解这些问题,我们来看一个简单的例子。假设有两个线程同时向同一个 HashMap 中 put 数据:
import java.util.HashMap;
import java.util.Map;
public class HashMapConcurrencyExample {
    private static final Map<String, Integer> map = new HashMap<>();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                map.put("key" + i, i);
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 5000; i < 10000; i++) {
                map.put("key" + i, i);
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Map size: " + map.size()); // 预期结果是 10000,但实际结果可能小于 10000
    }
}
在这个例子中,我们创建了两个线程,分别向 HashMap 中 put 5000 个键值对。理想情况下,Map 的大小应该为 10000。但是,由于 HashMap 不是线程安全的,实际运行结果往往小于 10000。这就是并发冲突导致的数据丢失问题。
2. ConcurrentHashMap 的分段锁机制
为了解决 HashMap 在并发环境下的问题,Java 提供了 ConcurrentHashMap。ConcurrentHashMap 通过引入分段锁(Segment Locking)机制,实现了高效的并发访问。
ConcurrentHashMap 的核心思想是将整个 Map 分成多个独立的段(Segment),每个段都有自己的锁。当多个线程访问不同的段时,它们可以并发执行,而不需要相互等待。只有当多个线程访问同一个段时,才会发生锁竞争。
在 JDK 1.7 中,ConcurrentHashMap 的结构如下:
ConcurrentHashMap (JDK 1.7)
|
|-- Segment[] segments
    |
    |-- Segment (继承 ReentrantLock)
        |
        |-- HashEntry[] table
            |
            |-- HashEntry
                |
                |-- key, value, next
- Segment: 
ConcurrentHashMap由多个Segment组成,每个Segment相当于一个小的HashMap。 - ReentrantLock: 每个 
Segment都继承自ReentrantLock,充当锁的角色。 - HashEntry: 
Segment内部使用HashEntry数组来存储键值对,与HashMap类似,采用链地址法解决冲突。 
当线程需要访问 ConcurrentHashMap 中的某个键值对时,首先根据键的哈希值找到对应的 Segment,然后获取该 Segment 的锁。获取锁成功后,才能进行 put、get、remove 等操作。释放锁后,其他线程才能访问该 Segment。
3. ConcurrentHashMap 的实现细节(JDK 1.7)
下面我们来分析 ConcurrentHashMap 的一些关键方法的实现细节(基于 JDK 1.7):
- 
put(K key, V value):
- 计算键的哈希值,找到对应的 
Segment。 - 尝试获取该 
Segment的锁。如果获取失败,则自旋等待,直到获取锁成功。 - 在 
Segment内部,将键值对添加到HashEntry数组中。如果数组需要扩容,则进行扩容操作。 - 释放 
Segment的锁。 
 - 计算键的哈希值,找到对应的 
 - 
get(Object key):
- 计算键的哈希值,找到对应的 
Segment。 - 尝试获取该 
Segment的锁。 - 在 
Segment内部,查找对应的键值对。 - 释放 
Segment的锁。 
 - 计算键的哈希值,找到对应的 
 - 
remove(Object key):
- 计算键的哈希值,找到对应的 
Segment。 - 尝试获取该 
Segment的锁。 - 在 
Segment内部,删除对应的键值对。 - 释放 
Segment的锁。 
 - 计算键的哈希值,找到对应的 
 
通过这种分段锁机制,ConcurrentHashMap 实现了更高的并发性能。与直接对整个 Map 加锁相比,分段锁减少了锁竞争的范围,允许多个线程同时访问不同的段,从而提高了吞吐量。
4. ConcurrentHashMap 的分段数
ConcurrentHashMap 的分段数(concurrency level)决定了可以同时并发访问的线程数量。默认情况下,ConcurrentHashMap 的分段数为 16。这意味着,在理想情况下,最多可以有 16 个线程同时并发访问 ConcurrentHashMap。
分段数的选择需要根据实际的应用场景进行调整。如果并发访问量较小,可以适当减少分段数,以减少内存占用。如果并发访问量较大,可以适当增加分段数,以提高并发性能。
5. JDK 1.8 的改进
在 JDK 1.8 中,ConcurrentHashMap 的实现发生了很大的变化。它不再使用分段锁,而是采用了 CAS(Compare-and-Swap)算法和 synchronized 关键字来实现线程安全。
JDK 1.8 中的 ConcurrentHashMap 结构如下:
ConcurrentHashMap (JDK 1.8)
|
|-- Node[] table
    |
    |-- Node
        |
        |-- key, value, next
- Node: 
ConcurrentHashMap使用Node数组来存储键值对。 - CAS + synchronized: 当多个线程竞争同一个桶时,使用 CAS 算法尝试修改桶中的数据。如果 CAS 失败,则使用 
synchronized关键字对该桶进行加锁。 
JDK 1.8 中 ConcurrentHashMap 的主要改进包括:
- 更高的并发性能: 使用 CAS 算法和细粒度的 
synchronized锁,减少了锁竞争,提高了并发性能。 - 更好的内存利用率: 不再需要维护多个 
Segment,减少了内存占用。 - 动态扩容: 支持动态扩容,可以根据实际情况调整 
Map的大小。 - 红黑树优化: 当链表长度超过一定阈值时,将链表转换为红黑树,提高查找效率。
 
6. 代码示例 (JDK 1.8)
下面是一个使用 ConcurrentHashMap 的例子(JDK 1.8):
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
public class ConcurrentHashMapExample {
    private static final Map<String, Integer> map = new ConcurrentHashMap<>();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                map.put("key" + i, i);
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 5000; i < 10000; i++) {
                map.put("key" + i, i);
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Map size: " + map.size()); // 预期结果是 10000
    }
}
在这个例子中,我们使用了 ConcurrentHashMap 来代替 HashMap。由于 ConcurrentHashMap 是线程安全的,所以最终 Map 的大小总是 10000。
7. ConcurrentHashMap 的适用场景
ConcurrentHashMap 适用于以下场景:
- 高并发访问: 当多个线程需要同时访问 
Map时,ConcurrentHashMap可以提供更好的并发性能。 - 读多写少: 
ConcurrentHashMap在读操作上的性能非常高,适合读多写少的场景。 - 需要线程安全: 当需要保证 
Map的线程安全时,ConcurrentHashMap是一个不错的选择。 
8. ConcurrentHashMap 的替代方案
除了 ConcurrentHashMap,还有一些其他的线程安全 Map 实现,例如:
- Collections.synchronizedMap(new HashMap<>()): 使用 
Collections.synchronizedMap方法可以创建一个线程安全的Map。但是,这种方式是通过对整个Map加锁来实现线程安全,并发性能较低。 - Hashtable: 
Hashtable是 Java 早期提供的一个线程安全Map实现。与Collections.synchronizedMap类似,Hashtable也是通过对整个Map加锁来实现线程安全,并发性能较低。 
| 特性 | HashMap | ConcurrentHashMap (JDK 1.7) | ConcurrentHashMap (JDK 1.8) | Hashtable | Collections.synchronizedMap | 
|---|---|---|---|---|---|
| 线程安全 | 否 | 是 | 是 | 是 | 是 | 
| 并发性能 | 差 | 较好 | 更好 | 差 | 差 | 
| 锁机制 | 无 | 分段锁 | CAS + synchronized | 全局锁 | 全局锁 | 
| null 键和值 | 允许一个 null 键和多个 null 值 | 不允许 null 键和值 | 不允许 null 键和值 | 不允许 null 键和值 | 取决于底层 HashMap 的实现 | 
| 适用场景 | 单线程环境 | 高并发,读多写少 | 高并发,读多写少 | 遗留代码,不推荐使用 | 低并发,简单线程安全需求 | 
9. 一些需要注意的点
ConcurrentHashMap虽然是线程安全的,但并不能保证所有操作的原子性。例如,putIfAbsent操作虽然可以保证原子性,但get+put操作仍然可能存在竞争条件。- 在使用 
ConcurrentHashMap时,需要注意键的哈希值分布。如果键的哈希值过于集中,会导致某些段的锁竞争加剧,影响并发性能。 - 在选择 
ConcurrentHashMap的分段数时,需要根据实际的应用场景进行调整。过小的分段数会导致锁竞争加剧,过大的分段数会导致内存占用过多。 
总结:ConcurrentHashMap 如何提升并发性能
总而言之,ConcurrentHashMap 通过分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8)机制,实现了高效的并发访问,解决了 HashMap 在并发环境下可能出现的数据不一致问题。它适用于高并发、读多写少的场景,是构建高性能并发应用的重要工具。
选择合适的并发 Map 实现
选择哪种并发 Map 实现取决于你的具体需求。ConcurrentHashMap 通常是高并发场景下的首选,因为它在并发性能和内存占用之间取得了很好的平衡。如果你的并发需求不高,或者只需要简单的线程安全,那么 Collections.synchronizedMap 也是一个可以考虑的选择。
希望今天的讲解能够帮助大家更好地理解 ConcurrentHashMap 的原理和使用方法。谢谢大家!