Skip to content

并发安全容器及工具类

约 13864 字大约 46 分钟

2025-03-12

1. 请介绍一下ConcurrentHashMap

在 Java 中,ConcurrentHashMap 是一个线程安全且高效的哈希表实现,属于 java.util.concurrent 包。它可以在多线程环境中高效地支持并发读写操作,而不会像传统的 HashTable 那样进行全表加锁,因而在高并发场景下表现更好。以下是 ConcurrentHashMap 的主要特性、实现原理和使用方法。

1. ConcurrentHashMap 的特性

  • 线程安全ConcurrentHashMap 支持多个线程的并发读写操作,提供了线程安全性而不需要使用外部同步。
  • 高效的并发性:通过分段锁和 CAS(Compare-And-Swap)操作,避免全表加锁,允许更高的并发操作。
  • 无锁读操作:大多数读操作不会加锁,可以并发读取,提升读取性能。
  • 不允许 null 键和值:在 ConcurrentHashMap 中,键和值都不能为 null。如果尝试插入 null 值会抛出 NullPointerException

2. ConcurrentHashMap 的实现原理

在 Java 8 中,ConcurrentHashMap 的实现结构和原理如下:

  • 分段锁:在 Java 7 及之前版本中,ConcurrentHashMap 使用分段锁(Segment)技术,将整个表分成多个段(Segment),每个段维护一部分数据。写操作只锁定所需的段,而不是整个表,提高了并发性能。
  • CAS 和 Synchronized 结合:在 Java 8 中,ConcurrentHashMap 不再使用分段锁,而是采用 CAS 操作(Compare-And-Swap)和synchronized 关键字的结合,使用更细粒度的锁。在高并发环境下,CAS 可以避免加锁,提升性能。
  • 链表和红黑树结构:Java 8 中,ConcurrentHashMap 使用了链表和红黑树相结合的结构。当哈希桶中的节点数量较少时,采用链表存储;当桶中节点数量达到一阈值(默认 8)时,链表会转换为红黑树结构,以提高查找效率。
  • 分桶(Bucket)结构ConcurrentHashMap 采用了分桶(Bucket)结构,每个桶可以独立存储一组数据。通过哈希算法将键映射到不同的桶内,从而实现分布式存储和并行操作。

3. ConcurrentHashMap 的操作实现

3.1 读操作
  • 读操作是无锁的ConcurrentHashMap 中的读操作不需要加锁,采用了 volatile 关键字和内存屏障来保证可见性。大多数情况下,读操作可以直接访问当前值。
  • 红黑树和链表的查找:当哈希桶中数据较少时,采用链表查找;当达到一定阈值时,转换为红黑树查找,查找效率提高到 O(logN)
3.2 写操作
  • 写操作的细粒度锁:写操作会使用 CAS 操作来保证原子性。如果 CAS 失败,则通过 synchronized 锁定桶节点,避免线程冲突。
  • putIfAbsent() 方法:ConcurrentHashMap 提供了 putIfAbsent() 方法,用于原子地插入数据。如果键已存在,putIfAbsent() 不会覆盖旧值。
  • 分段锁的减少:Java 8 以后 ConcurrentHashMap分段锁被优化为分桶锁,大幅提高了并发性能。

4. ConcurrentHashMap 的常用方法

  • put(K key, V value):插入键值对。线程安全,如果键已存在则覆盖旧值。
  • putIfAbsent(K key, V value):仅在键不存在时插入键值对。线程安全。
  • get(Object key):根据键获取值,线程安全。
  • remove(Object key):移除键值对,线程安全。
  • replace(K key, V oldValue, V newValue):仅当键的当前映射值为指定的 oldValue 时,才替换为 newValue
  • compute(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction):根据键的计算函数更新值。
  • forEach(long parallelismThreshold, BiConsumer<? super K, ? super V> action):并发遍历 ConcurrentHashMap,并对每个键值对执行给定的操作。

5. 示例代码

以下是 ConcurrentHashMap 的一些常用操作示例:

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        // put 操作
        map.put("A", 1);
        map.put("B", 2);

        // putIfAbsent 操作
        map.putIfAbsent("A", 3); // 键 "A" 已存在,不会覆盖
        map.putIfAbsent("C", 3); // 键 "C" 不存在,会插入

        // get 操作
        System.out.println("Value for key A: " + map.get("A")); // 输出 1
        System.out.println("Value for key C: " + map.get("C")); // 输出 3

        // replace 操作
        map.replace("A", 1, 5); // 如果 "A" 的值是 1,则替换为 5
        System.out.println("Value for key A after replace: " + map.get("A")); // 输出 5

        // remove 操作
        map.remove("C");
        System.out.println("Map after removing key C: " + map);

        // forEach 操作
        map.forEach((key, value) -> System.out.println(key + ": " + value));
    }
}

6. ConcurrentHashMap 与其他 Map 的对比

特性HashTableSynchronizedMapConcurrentHashMap
线程安全性线程安全线程安全线程安全
实现方式全表加锁全表加锁分桶、CAS 和细粒度锁
锁粒度粗粒度锁粗粒度锁细粒度锁
并发性能较低较低较高
读操作加锁
允许 null 键值
适用场景低并发、线程安全场景低并发、线程安全场景高并发、多线程访问场景

7. 使用注意事项

  • 不要使用 null 键和值:ConcurrentHashMap 不允许 null 键或值,插入 null 会抛出 NullPointerException
  • 不保证遍历的强一致性:ConcurrentHashMap 的迭代器是弱一致性,不保证遍历期间的数据完全一致。这种弱一致性提供了较好的并发性能。
  • 适合读多写少的场景ConcurrentHashMap 对读操作无锁化,读性能很高,但写操作涉及 CASsynchronized,所以适合读多写少的场景。

8. ConcurrentHashMap 的应用场景

  • 缓存系统:适合用于需要频繁访问的缓存,特别是缓存的读多写少场景。ConcurrentHashMap 的线程安全和高并发性能使其成为缓存的理想选择。
  • 统计计数:可以用于并发环境中的计数统计,如记录在线人数、访问次数等。通过 compute() 方法可以实现计数累加操作。
  • 共享配置信息:在多线程系统中,可以使用 ConcurrentHashMap 来存储共享配置信息,保证数据一致性和线程安全。

9. 总结

ConcurrentHashMap 在 Java 中是一个非常高效的线程安全集合,解决了 HashTableSynchronizedMap 的全表加锁问题。通过细粒度锁和无锁读操作,ConcurrentHashMap 提供了更高的并发性能,非常适合在多线程环境中使用。合理使用 ConcurrentHashMap 可以提高应用的性能和并发效率。

2. ConcurrentHashMap 和 Hashtable 的区别?

ConcurrentHashMapHashtable 都是 Java 中的线程安全的哈希表实现,但它们的设计和实现方式不同,导致在并发环境中的性能和使用场景也不同。以下是 ConcurrentHashMapHashtable 的主要区别:

1. 锁的粒度不同

  • Hashtable:采用粗粒度锁,对整个哈希表进行加锁。每次线程执行读或写操作时,都会锁定整个 Hashtable,所有其他线程都需要等待锁释放才能继续执行。这种方式的锁粒度很粗,导致在高并发环境下性能较差,因为每次操作都会阻塞其他线程的访问。
  • ConcurrentHashMap:采用细粒度锁。在 Java 8 之前,ConcurrentHashMap 使用了分段锁(Segment),每个段独立维护一部分数据,写操作只会锁定特定的段,不会影响其他段的并发操作。Java 8 之后,ConcurrentHashMap 使用了更加细粒度的锁,采用了 CAS(Compare-And-Swap)操作和 synchronized 的结合,仅在特定的桶或节点上加锁,大大提高了并发性能。

2. 读操作的实现方式

  • Hashtable:在 Hashtable 中,读操作也需要加锁。即使是简单的 get() 操作,也会锁定整个哈希表。这会导致多线程读取时性能低下,特别是在高并发读操作的情况下。
  • ConcurrentHashMap:在 ConcurrentHashMap 中,读操作大多数情况下是无锁的。使用 volatile 和内存屏障来确保线程间的可见性,直接读取当前值,不加锁。这种无锁读操作大大提高了并发读的性能,适合读多写少的场景。

3. 并发性能

  • Hashtable:由于每个操作都锁定整个哈希表,Hashtable 在高并发环境下性能较差。多个线程同时访问 Hashtable 时,会因为锁的竞争导致大量的阻塞,降低了并发性能。
  • ConcurrentHashMapConcurrentHashMap 的设计初衷是为了解决 Hashtable 的性能瓶颈。通过细粒度锁和无锁读操作,ConcurrentHashMap 支持更高的并发访问,可以在多线程环境下更高效地处理大量的读写请求。

4. 是否允许 null 键和值

  • Hashtable:不允许 null 键或 null 值。如果尝试插入 null 键或 null 值,会抛出 NullPointerException
  • ConcurrentHashMap:同样不允许 null 键或 null 值。如果尝试插入 null 键或 null 值,也会抛出 NullPointerException。这是为了避免在多线程操作中出现 null 值带来的歧义和潜在的错误。

5. 数据结构和存储方式

  • Hashtable:使用简单的哈希表数据结构,内部使用数组和链表来存储键值对。没有采用红黑树等复杂的数据结构来优化性能。
  • ConcurrentHashMap:在 Java 8 之前,ConcurrentHashMap 使用分段锁(Segment)和链表的数据结构。Java 8 之后,ConcurrentHashMap 使用链表和红黑树相结合的方式。当哈希桶中链表的长度超过一定阈值(默认为 8)时,链表会转换为红黑树,以提高查找效率,从而更高效地处理高并发场景下的哈希冲突。

6. 并发级别和锁的实现方式

  • Hashtable:采用 synchronized 关键字锁定整个表,实现线程安全。这种全表锁导致并发级别较低。
  • ConcurrentHashMap:使用 CAS 和 synchronized 的结合来实现锁机制。写操作采用 CAS 实现原子性,如果 CAS 失败则使用 synchronized 锁定特定桶。Java 8 之前还使用分段锁技术,分段锁允许在不同的段上并行访问,从而提高并发性能。

7. 方法的支持和扩展

  • Hashtable:由于 Hashtable 是一个比较古老的类,只提供了一些基本的哈希表操作,没有更多的扩展方法。
  • ConcurrentHashMapConcurrentHashMap 提供了许多新的方法,如 putIfAbsent()compute()merge() 等,支持更多原子性操作。此外,ConcurrentHashMap 提供了并发遍历的方法 forEach()reduce()search() 等,可以在并发环境下高效地遍历和处理数据。

8. 使用场景

  • Hashtable:由于其性能较差,Hashtable 现在已不常用于多线程环境。它主要出现在一些遗留代码中,不推荐在新项目中使用。
  • ConcurrentHashMap:适合高并发场景,尤其是读多写少的场景,比如缓存系统、统计系统等。ConcurrentHashMap 提供了高效的并发访问机制,是多线程环境下更推荐的选择。

9. 简单示例对比

Hashtable 示例
import java.util.Hashtable;

public class HashtableExample {
    public static void main(String[] args) {
        Hashtable<String, Integer> table = new Hashtable<>();
        
        table.put("A", 1);
        table.put("B", 2);

        System.out.println("Value for key A: " + table.get("A"));
    }
}
ConcurrentHashMap 示例
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        
        map.put("A", 1);
        map.put("B", 2);

        System.out.println("Value for key A: " + map.get("A"));
        
        // 使用新方法 putIfAbsent
        map.putIfAbsent("A", 3);
        System.out.println("Value for key A after putIfAbsent: " + map.get("A"));  // 仍然是 1
    }
}

10. 总结对比表

特性HashtableConcurrentHashMap
线程安全性
锁的粒度整个表细粒度锁(分段锁和 CAS)
读操作是否加锁否(大部分读操作是无锁的)
并发性能较低较高
是否允许 null 键和值
数据结构哈希表 + 链表哈希表 + 链表 + 红黑树
扩展方法基本操作提供 putIfAbsentcomputemerge 等方法
适用场景低并发场景,已不推荐使用高并发场景,尤其是读多写少的场景

11. 选择建议

  • 如果需要线程安全的哈希表:在多线程环境中,优先选择 ConcurrentHashMap,因为它的并发性能比 Hashtable 高得多。
  • 如果不需要线程安全的哈希表:在单线程或低并发环境中,可以选择 HashMap,它没有线程安全性开销,性能更高。
  • 不要在新代码中使用 **Hashtable**Hashtable 是一个较旧的类,设计方式不适合现代高并发应用。ConcurrentHashMap 是它的现代替代品。

总结来说,ConcurrentHashMap 在多线程环境中更适合使用,它的设计更现代,提供了更高的并发性能和更多的扩展方法。Hashtable 由于全表加锁导致性能差,现在已很少使用。

3. ConcurrentHashMap JDK1.7实现的原理是什么?

首先将数据分为一段一段(这个“段”就是 Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。

Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变。 Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。

在 JDK 1.7 中,ConcurrentHashMap 是通过 分段锁(Segment Locking)实现线程安全的。这种分段锁机制允许多个线程在不同的段上进行并发操作,从而提高并发性能。以下是 JDK 1.7 中 ConcurrentHashMap 的底层原理的详细介绍:

1. 数据结构

在 JDK 1.7 中,ConcurrentHashMap 主要由以下几个部分组成:

  • SegmentSegmentConcurrentHashMap 的核心组成部分,可以将它理解为一个小的哈希表(类似于 HashMap),每个 Segment 内部维护一个哈希表结构和一个锁。当一个线程访问某个 Segment 时,只会锁定该 Segment,不影响其他 Segment 的访问。
  • HashEntryHashEntry 用于存储键值对的节点,相当于 HashMap 中的 Entry每个 Segment 中包含一个 HashEntry 数组,数组中的每个元素是一个链表的头结点,用于解决哈希冲突。
  • ConcurrentHashMap:整个 ConcurrentHashMap 是由多个 Segment 组成的数组,Segment 数组的长度默认是 16(可以通过构造函数自定义)。每个 Segment 维护自己的一部分数据和锁,从而实现并发控制。

2. 分段锁(Segment Lock)机制

ConcurrentHashMap 的并发控制基于 分段锁Segment 本质上是一个可重入锁(继承自 ReentrantLock),每个 Segment 独立维护自己的锁,因此多个线程可以同时访问不同的 Segment,而不会发生锁竞争。具体原理如下:

  • 当一个线程对 ConcurrentHashMap 进行写操作时,只会锁定与操作相关的 Segment,而不会锁定整个 ConcurrentHashMap,这样可以在不影响其他 Segment 的情况下进行操作,从而提高并发度。
  • 在默认情况下,ConcurrentHashMap 有 16 个 Segment,这意味着最多可以有 16 个线程同时进行写操作,而不会发生锁竞争。

3. 核心操作的实现原理

3.1 插入操作(put()

put() 方法用于插入或更新键值对。以下是 put() 的具体实现步骤:

  1. 计算哈希值:对键进行哈希运算,得到哈希值,用于定位要插入的 Segment 和在该 Segment 中的具体位置。
  2. 定位 Segment:根据哈希值确定应该插入到哪个 SegmentSegment 的选择通过哈希值的高位来决定,这样可以尽可能均匀地将数据分布到不同的 Segment 中。
  3. 加锁 Segment:在确定要插入的 Segment 后,对该 Segment 加锁,以确保线程安全。由于只锁定特定的 Segment,其他 Segment 的操作不受影响。
  4. 定位 HashEntry:在加锁后的 Segment 中,根据哈希值找到对应的 HashEntry 数组位置,遍历链表查找是否存在相同键。
    1. 如果找到相同的键,则更新对应的值。
    2. 如果没有找到相同的键,则将新键值对插入链表中(放在链表头部)。
  5. 解锁 Segment:操作完成后,解锁 Segment,允许其他线程访问该 Segment

通过这种分段锁的机制,put() 操作只会锁住一个 Segment,从而提高了并发性能。

3.2 获取操作(get()

get() 方法用于根据键获取值。因为 get() 操作不涉及数据修改,因此不需要加锁,可以直接读取,提高了读取性能。具体实现如下:

  1. 计算哈希值:对键进行哈希运算,得到哈希值,用于定位 Segment
  2. 定位 Segment:根据哈希值找到对应的 Segment
  3. 定位 HashEntry:在 Segment 中,查找对应的 HashEntry 数组位置。
    1. 如果找到了对应的链表,则遍历链表,查找键是否匹配。
    2. 如果找到相应的键,则返回对应的值;如果找不到,则返回 null

由于 get() 不需要加锁,读取性能很高,非常适合读多写少的高并发场景。

3.3 删除操作(remove()

remove() 方法用于删除指定键的键值对。删除操作需要修改数据,因此需要加锁。具体步骤如下:

  1. 计算哈希值:根据键计算哈希值,用于定位 Segment
  2. 定位 Segment 并加锁:根据哈希值找到对应的 Segment,并对该 Segment 加锁,确保线程安全。
  3. 定位 HashEntry 并删除:在 Segment 中找到对应的 HashEntry 数组位置,遍历链表找到要删除的节点,并更新链表的引用,将其移除。
  4. 解锁 Segment:删除完成后,解锁 Segment

删除操作需要加锁,但由于只锁定相关的 Segment,并不会影响其他 Segment 的并发操作。

3.4 扩容操作(Rehashing)

ConcurrentHashMap 的扩容机制与 HashMap 类似。当 ConcurrentHashMap 的负载因子超过某个阈值时,会触发扩容,将当前 Segment 中的数据迁移到新的、更大的数组中。扩容机制的具体步骤如下:

  1. 扩容条件:当 ConcurrentHashMap 的容量达到某个阈值(即负载因子 × Segment 数组大小)时,触发扩容操作。JDK 1.7 中的默认负载因子是 0.75。
  2. Segment 扩容:每个 Segment 会独立扩容,即每个 Segment 在需要时会创建一个新的 HashEntry 数组,并将旧数据迁移到新数组中。
  3. 数据迁移:每个 Segment 中的链表节点会被重新哈希,并迁移到新数组中的对应位置。

由于每个 Segment 独立扩容,多个线程可以同时对不同的 Segment 进行扩容,从而提高了扩容的效率。

4. Segment、HashEntry 和锁机制

在 JDK 1.7 中,ConcurrentHashMap 采用了分段锁的设计,这一设计的核心是 SegmentHashEntry

  • Segment:是 ConcurrentHashMap 中的锁的最小单位。每个 Segment 拥有一个独立的锁,因此多个线程可以在不同的 Segment 上并发执行写操作。Segment 继承自 ReentrantLock,本质上是一个可重入锁。
  • HashEntry:存储具体的键值对数据。每个 Segment 中包含一个 HashEntry 数组,每个数组元素是一个链表的头节点,解决哈希冲突。

5. 并发控制和锁的粒度

JDK 1.7 中的 ConcurrentHashMap 将整个哈希表分成多个 Segment,每个 Segment 相当于一个独立的小哈希表,内部通过 ReentrantLock 进行加锁控制。并发控制体现在以下几个方面:

  • 分段锁提升并发度:多个线程可以同时访问不同的 Segment,提高了并发性能。默认情况下,ConcurrentHashMap 有 16 个 Segment,可以支持最多 16 个线程同时执行写操作。
  • 锁粒度较粗:虽然分段锁机制提升了并发性,但由于每次操作会锁定整个 Segment,导致锁粒度相对较粗。当数据量很大或并发写操作很多时,性能会有所下降。

6. JDK 1.7 ConcurrentHashMap 的缺点

  • 锁粒度较粗:每个 Segment 是一个加锁单位,导致锁粒度较粗。对于高并发写操作,多个线程可能会争抢同一个 Segment,从而影响性能。
  • 链表查找性能低:JDK 1.7 中使用链表来解决哈希冲突,当哈希冲突较多时,链表会变长,查找效率降低为 O(n)
  • 扩容机制效率较低:每个 Segment 扩容时会重新分配数组并迁移数据,这种扩容机制的效率较低,并且当大量数据分布在某个 Segment 中时,该 Segment 的扩容会成为性能瓶颈。

总结

JDK 1.7 中的 ConcurrentHashMap 通过分段锁实现了高并发控制,但仍然存在一些局限性。主要特点如下:

  1. 分段锁机制:将整个哈希表分成多个 Segment,每个 Segment 使用独立锁来实现并发控制,提高了写操作的并发性。
  2. 链表结构:使用链表来解决哈希冲突,当链表长度变长时查找性能降低。
  3. 无锁读操作get() 操作不需要加锁,可以直接读取,大大提高了读取性能,适合读多写少的场景。
  4. 独立扩容:每个 Segment 可以独立扩容,多个 Segment 可以同时扩容,但扩容效率较低。

虽然分段锁提高了并发性,但由于锁粒度较粗,JDK 1.8 中移除了分段锁,改用 CAS 和 synchronized 结合的方式,并引入了链表到红黑树的转换机制,进一步提升了性能。因此,JDK 1.8 中的 ConcurrentHashMap 是对 JDK 1.7 版本的优化。

4. ConcurrentHashMap JDK1.8实现的原理是什么?

JDK1.8 ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值8(同时满足Node数组长度>=64,小于64时是直接扩容)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。

Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。

为什么不用Lock而是用Synchronized?

Lock 算是上层并发包的设计,有时候会更方便。 但如果是简单加锁场景,性能来说肯定是不如直接使用synchronized的,他属于是JVM底层的实现。

在 JDK 1.8 中,ConcurrentHashMap 的实现相较于之前的版本有了较大改进。JDK 1.8 采用了更加高效的并发策略,移除了分段锁(Segment),转而使用 CAS(Compare-And-Swap)操作和 synchronized 关键字的结合,以实现更细粒度的锁,从而提升并发性能。在高并发的写入、读取、扩容操作中,ConcurrentHashMap 的效率得到了显著提升。以下是 JDK 1.8 中 ConcurrentHashMap 的底层原理详细介绍:

1. 数据结构

在 JDK 1.8 中,ConcurrentHashMap 的底层数据结构主要由数组链表红黑树组成。具体结构如下:

  • 数组(Node<K, V>[] table)ConcurrentHashMap 的核心数据结构是一个 Node 数组(类似于 HashMapEntry 数组),它是存储键值对的主要容器。
  • 链表:当哈希冲突发生时(即多个键映射到同一个索引位置),这些键值对会以链表的形式连接起来,插入到数组的同一个槽位(Bucket)。
  • 红黑树:为了提高高并发情况下的查找效率,当某个桶中的链表长度超过一定阈值(默认为 8)时,链表会转换为红黑树,查找性能从 O(n) 提升到 O(log n),有效减少链表查找带来的性能损耗。

2. 核心组件和字段

  • NodeConcurrentHashMap 中的基本单元,每个 Node 存储一个键值对。Node 使用 volatile 修饰,确保可见性(value也是)。Node 的结构如下:
static class Node<K, V> {
    final int hash;
    final K key;
    volatile V value;
    volatile Node<K, V> next;

    Node(int hash, K key, V value, Node<K, V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}
  • ForwardingNode:用于扩容时的特殊节点。当一个桶被扩容迁移时,旧桶中的节点会被替换为 ForwardingNode,用于指向新数组,以提示其他线程该桶已经在扩容,避免重复迁移。
  • TreeNode:当链表转换为红黑树时,链表节点 Node 会变成 TreeNode,以提升查找效率。

3. 核心操作的实现原理

3.1 插入操作(put()

put() 方法用于插入键值对,它使用了 CAS 和 synchronized 的组合来保证线程安全。具体步骤如下:

  1. 计算哈希值:根据键计算哈希值,用来确定插入位置。
  2. 初始化数组:如果数组 table 为空,使用 CAS 操作初始化数组。ConcurrentHashMap 在初始化时是延迟加载的,只有第一次插入元素时才会分配数组空间。
  3. 定位桶(Bucket):根据哈希值计算桶的位置。如果对应的桶为空,使用 CAS 操作将新节点插入该位置。
  4. 处理哈希冲突
    1. 如果该位置已有节点(发生哈希冲突),首先判断该节点的类型:
      • 链表节点:如果桶中是链表,则将新节点插入链表末尾。如果链表长度超过阈值(8),链表会转换成红黑树。
      • 红黑树节点:如果桶中是红黑树,则在树中插入新节点。
  5. synchronized** 加锁**:在插入过程中,若 CAS 失败,则使用 synchronized 对该桶加锁,确保只有一个线程能在同一个桶中进行写操作,避免数据不一致。
3.2 获取操作(get()

get() 方法用于根据键获取值。由于 get() 操作不需要修改数据,因此可以通过无锁的方式实现。

  1. 计算哈希值:根据键计算哈希值,用来定位桶的位置。
  2. 定位桶并查找:直接读取数组中对应桶的位置,如果桶中有节点,则依次查找链表或红黑树中的节点,找到对应的键值对并返回。
  3. 遍历链表或树:如果找到的是链表节点,遍历链表;如果找到的是红黑树节点,则在树中查找。

这种无锁读操作保证了 get() 方法的高效性,即使在高并发环境下,读取操作也不会阻塞其他线程。

3.3 删除操作(remove()

remove() 方法用于删除指定键的键值对。删除操作与插入类似,先通过定位找到对应的桶,再使用 synchronized 锁住该桶,以确保线程安全。

  1. 定位桶:根据键的哈希值找到对应的桶。
  2. 遍历链表或树:在桶中遍历链表或红黑树,找到要删除的节点。
  3. synchronized** 加锁**:如果需要删除的节点在链表或树中,使用 synchronized 锁住桶,防止并发修改导致的不一致。删除是直接Syncronized,插入才是先用CAS。
  4. 更新节点引用:移除节点后更新引用,确保其他线程在读取该桶时不会读到已删除的节点。
3.4 扩容操作(Rehashing)

扩容是 ConcurrentHashMap 中一个关键的操作。当数组的负载因子超过一定阈值时(默认 0.75),会触发扩容,将数据迁移到更大的数组中。

  1. 创建新数组:扩容时,创建一个容量为当前数组 2 倍的新数组。
  2. 分批迁移:为了减少扩容对性能的影响,ConcurrentHashMap 的扩容是分批进行的,多个线程可以同时参与迁移任务。每个线程负责迁移部分桶的数据,这种方式可以显著提高扩容效率。
  3. ForwardingNode 协调:在扩容过程中,旧数组的桶位置会被标记为 ForwardingNode,表示该位置的节点已经被迁移。访问到 ForwardingNode 的线程会被引导到新数组,避免重复迁移。
  4. 迁移完成:当所有桶都迁移完成后,旧数组被丢弃,引用切换到新数组。

4. CAS 操作和 synchronized 的结合

ConcurrentHashMap 使用 CAS 操作(Compare-And-Swap)和 synchronized 的结合来实现并发控制。具体来说:

  • CAS 操作:CAS 是一种无锁的原子操作,通过比较和替换来更新变量的值。在 ConcurrentHashMap 中,CAS 操作主要用于初始化数组和插入新节点等操作中。注意没有删除操作。
  • synchronized当 CAS 操作失败时,ConcurrentHashMap 会退而使用 synchronized 来加锁。synchronized 锁住的是单个桶(桶的头结点),保证了线程安全性。

这种无锁优先、锁定备用的设计使得 ConcurrentHashMap 在读多写少的场景下具有较高的性能。

5. 主要优化点总结

  • 细粒度锁:JDK 1.8 中移除了分段锁,直接在每个桶上使用细粒度锁,减少锁竞争。
  • 链表到红黑树的转换:当哈希冲突较多时,链表会转换为红黑树,提高查找效率。
  • 无锁读操作:读取操作不需要加锁,通过 volatile 保证可见性,实现无锁读,大幅提升了读取性能。
  • 分批扩容:多个线程可以共同完成扩容任务,避免单线程扩容的性能瓶颈。

总结

JDK 1.8 中 ConcurrentHashMap 的底层原理主要通过以下方式提升了并发性能:

  • 移除了分段锁,采用 CAS 和 synchronized 的结合来控制并发。
  • 引入红黑树结构,在哈希冲突严重时提升查找效率。
  • 扩容过程中使用分批迁移,多个线程协同扩容,提升效率。
  • 通过无锁读操作提高了读性能,使得 get() 操作几乎不会阻塞。

总体来说,JDK 1.8 的 ConcurrentHashMap 提供了更高的并发性能,尤其适合读多写少的高并发场景,是一个性能和安全性都优越的并发集合。

5. ConcurrentHashMap JDK1.7的实现和1.8的实现有什么区别?

线程安全实现方式:JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。

Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。

并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。

在 Java 中,ConcurrentHashMap 是一个用于高并发环境的线程安全的哈希表。JDK 1.7 和 JDK 1.8 中 ConcurrentHashMap 的实现方式有较大的不同。这些改动提高了并发性能,特别是在写操作方面。以下是 JDK 1.7 和 JDK 1.8 中 ConcurrentHashMap 的主要区别:

1. 锁的实现方式

  • JDK 1.7:使用**分段锁(Segment)**的机制。ConcurrentHashMap 被分成多个段(Segment),每个段是一个独立的小哈希表,拥有独立的锁。写操作只需要锁定一个段,不会影响其他段的操作,这种设计大大减少了锁的粒度,从而提高了并发性能。JDK 1.7 默认将 ConcurrentHashMap 分成 16 个段,这样可以实现最多 16 个线程同时进行写操作。
  • JDK 1.8:在 JDK 1.8 中,Segment 结构被移除,取而代之的是更加细粒度的锁机制,采用了 CAS(Compare-And-Swap)synchronized 结合的方式。JDK 1.8 中 ConcurrentHashMap 直接在每个桶(Bucket)上加锁,减少了锁的范围,使得并发度更高。使用 CAS 可以避免频繁的加锁解锁操作,只有在 CAS 失败时才会使用 synchronized 来锁定单个桶中的链表或红黑树节点,从而进一步提升并发性能。

2. 数据结构的改进

  • JDK 1.7:每个段中的数据结构是数组 + 链表。每个桶存储的键值对形成一个链表,当发生哈希冲突时,链表长度会增加。这种链表结构在大量哈希冲突的情况下会影响查找性能(链表查找的时间复杂度是 O(n))。
  • JDK 1.8:JDK 1.8 引入了 红黑树 来优化链表查找性能。当桶中链表长度超过阈值(默认是 8)时,链表会转换成红黑树结构,以提高查找效率。红黑树的查找时间复杂度是 O(logN),相比链表的 O(n) 提升明显。通过这种改进,JDK 1.8 的 ConcurrentHashMap 在哈希冲突较多的情况下可以更高效地进行查找和插入操作。

3. 扩容机制

  • JDK 1.7扩容是以段(Segment)为单位进行的,每个段是一个独立的小哈希表,段之间没有直接的联系,因此每个段可以独立扩容。这种分段扩容的方式有一定的并发性,但在某些场景下可能导致扩容效率低下,因为扩容需要对每个段分别进行操作。
  • JDK 1.8:JDK 1.8 中的扩容机制变得更加灵活和高效,使用了 分批扩容 的方式。JDK 1.8 的 ConcurrentHashMap 直接在整体哈希表上进行扩容,而不是以段为单位。在扩容时,ConcurrentHashMap 会将数据逐步转移到新的哈希表中,避免一次性进行全表扩容带来的性能问题。在扩容过程中,多个线程可以协同完成数据迁移,进一步提高了扩容的效率。说到底还是并发粒度更细。类似Redis HashTable的渐进式扩容。

4. 并发性能和锁粒度

  • JDK 1.7:使用分段锁,最大并发度由段的数量决定,默认是 16,意味着最多支持 16 个线程同时写操作。当某个段被锁定时,只有该段的其他线程会阻塞,其他段可以继续操作,因此在一定程度上可以支持并发写操作。
  • JDK 1.8:锁粒度降低为每个桶,使得并发度显著提高。ConcurrentHashMap 中的每个桶可以独立进行并发操作,无需全表锁定。JDK 1.8 使用 CAS 操作和 synchronized 锁结合来保证线程安全,读写性能相比 JDK 1.7 有明显提升。在高并发写场景下,JDK 1.8 的 ConcurrentHashMap 能够更好地利用多线程,提供更高的吞吐量。

5. 遍历性能

  • JDK 1.7:由于使用分段锁机制,遍历性能较差,无法在全表级别上进行无锁遍历操作。在遍历时需要锁住每个段,导致性能瓶颈。
  • JDK 1.8:在 JDK 1.8 中,ConcurrentHashMap 提供了 forEachreducesearch 等并发遍历方法,能够在多线程环境下高效地遍历。并且由于锁粒度降低到每个桶,JDK 1.8 在遍历时不需要锁住整个表,因此遍历性能更高。这些方法利用了 ForkJoinPool 的分治思想,实现了并发遍历的能力。

6. 主要方法的实现差异

  • put() 方法
    • JDK 1.7put 操作中,通过锁定整个段来保证线程安全。当多个线程并发插入时,不同的段可以并发进行 put 操作,但同一段中的插入会被锁定。
    • JDK 1.8put 操作使用 CAS 结合 synchronized 实现,首先尝试使用 CAS 插入值,如果 CAS 成功则不加锁。如果 CAS 失败,则使用 synchronized 加锁单个桶,进一步降低锁冲突。
  • get() 方法
    • JDK 1.7get 操作需要遍历段中的链表查找元素,但不需要加锁,因为 get 操作不修改数据。
    • JDK 1.8get 操作是无锁的,直接读取当前值,且查找时通过链表和红黑树结构来提高效率。

7. JDK 1.7 和 JDK 1.8 ConcurrentHashMap 的对比总结

特性JDK 1.7JDK 1.8
锁的实现分段锁(Segment)CAS + synchronized 结合,锁粒度降低至桶或节点
数据结构数组 + 链表数组 + 链表 + 红黑树
扩容机制以段为单位独立扩容整体哈希表分批扩容,多个线程协同迁移
并发性能最大并发度受限于段数,默认 16每个桶独立加锁,并发度更高
遍历性能需锁定每个段,遍历性能较差提供 forEachreducesearch 等并发遍历方法
put 方法实现锁定整个段使用 CAS 更新桶或锁定单个桶
get 方法实现无锁,遍历段中的链表无锁,遍历链表或红黑树
适用场景适合中低并发场景适合高并发场景,尤其是读多写少的场景

8. 总结

  • 分段锁机制(JDK 1.7):ConcurrentHashMap 使用分段锁,每个段是一个独立的小哈希表,每个段可以单独加锁和扩容,但锁的粒度仍然相对较大,限制了最大并发度。
  • CAS + synchronized** 机制**(JDK 1.8):JDK 1.8 中移除了 Segment,使用了更细粒度的锁机制,即 CAS 和 synchronized 结合的方式,锁粒度降低到桶或节点。读写性能更高,并且在大量哈希冲突的情况下,红黑树结构也进一步提升了效率。扩容也不再是一次性操作,而是通过分批迁移实现,更高效。

在高并发环境下,JDK 1.8 的 ConcurrentHashMap 提供了更高的并发性能,特别是在读多写少的场景中表现优异。

6. JDK1.8中,ConCurrentHashmap什么情况下链表才会转换成红黑树进行存储?

链表长度大于等于8,且数组长度大于等于64。

并非一开始就创建红黑树结构,如果当前Node数组长度小于阈值MIN_TREEIFY_CAPACITY,默认为64,先通过扩大数组容量为原来的两倍以缓解单个链表元素过大的性能问题。

7. JDK1.8中,ConcurrentHashmap的put过程是怎样的?

操作前判空,操作后扩容。

8. ConcurrentHashMap的get方法是否要加锁,为什么?

不需要。get方法不涉及对变量的修改,所以会导致并发下可能出问题的原因就是读共享变量的可见性问题。而ConcurrentHashMap中,对get方法中用到的共享变量都使用volatile关键字修饰,所以整个get方法不加锁也不会有问题。

9. ConcurrentHashMap默认初始容量是多少?

16

10. ConcurrentHashmap 的key,value是否可以为null?

不行。如果key或者value为null会抛出空指针异常。(原因是因为没办法解决get返回值为null时的二义性问题,即没办法确定是存储的值本身为null,还是说值不存在);

注意:HashMap 允许使用 null 作为值和键。(因为HashMap只能单线程下使用,所以hashmap可以用containsKey来二次判断,排除二义性问题)

为什么不给ConcurrentHashMap也搞一个containsKey()方法来判断呢,判断的时候使用cas操作不就不会有并发问题了吗?

并发状态下,containsKey 和 get 是两步操作,会有原子问题,而hashmap就是单线程使用,所以没有原子问题。

11. 什么是BlockingQueue?

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法。

支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。

支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。

12. 你了解的阻塞队列有哪些?

1741767053876-50.png

在 Java 中,阻塞队列(BlockingQueue)java.util.concurrent 包中提供的一种支持线程安全的队列类型,广泛用于生产者-消费者模式中。在阻塞队列中,当队列为空时,获取元素的操作会阻塞线程,直到有元素被插入;当队列满时,插入操作会阻塞线程,直到有空间可以插入新元素。阻塞队列的实现通过内部的阻塞机制,可以有效地在并发环境中协调线程之间的数据传递。

ArrayBlockingQueue类必须有界,LinkedBlockingQueue允许有界,其余都是无界。

1. 阻塞队列的接口 BlockingQueue

BlockingQueue 是 Java 提供的一个接口,继承自 Queue 接口,定义了阻塞式插入和移除元素的方法,主要包括以下几种操作:

  • 插入操作
    • add(E e):添加元素,如果队列已满则抛出异常。
    • offer(E e):尝试添加元素,返回 true 表示成功,false 表示队列已满。
    • offer(E e, long timeout, TimeUnit unit):尝试在指定时间内添加元素,如果在此期间队列有空间则插入,否则返回 false
    • put(E e):如果队列满,则阻塞直到有空间再插入。
  • 移除操作
    • remove(Object o):移除指定元素。
    • poll():尝试移除并返回头部元素,如果队列为空则返回 null
    • poll(long timeout, TimeUnit unit):尝试在指定时间内移除并返回头部元素,如果在此期间队列为空则返回 null
    • take():获取并移除头部元素,如果队列为空则阻塞直到有元素。
  • 检查操作
    • peek():返回头部元素,但不移除;如果队列为空则返回 null
    • size():返回队列中元素的数量。

2. 阻塞队列的实现类

Java 提供了多个 BlockingQueue 的实现类,适合不同的并发场景:

2.1 ArrayBlockingQueue
  • 特点:基于数组实现的有界阻塞队列,队列的大小是固定的,在创建时必须指定容量。
  • 应用场景:适用于大小固定的缓冲区或缓存池,例如限制生产者-消费者的任务数量。
  • 线程安全:使用单一的锁和两个条件(notEmptynotFull)来控制线程的阻塞和唤醒。
BlockingQueue<Integer> arrayBlockingQueue = new ArrayBlockingQueue<>(5);
2.2 LinkedBlockingQueue
  • 特点:基于链表实现的阻塞队列,可以是有界队列(指定容量)或无界队列(默认容量为 Integer.MAX_VALUE)。
  • 应用场景:适合生产者和消费者数量不固定的场景,常用于任务队列,如线程池中的任务管理。
  • 线程安全:使用了分离锁机制(一个锁控制插入,另一个锁控制移除),可以减少锁争用,提高并发性能。
BlockingQueue<Integer> linkedBlockingQueue = new LinkedBlockingQueue<>(10);
2.3 PriorityBlockingQueue
  • 特点:一个支持优先级排序的无界阻塞队列,基于堆实现,不保证元素的 FIFO 顺序。可以在创建时指定比较器。
  • 应用场景:适合需要优先级的任务处理场景,比如事件处理、任务调度。
  • 线程安全:PriorityBlockingQueue 不阻塞 put() 操作,因为它是无界的,但 take() 操作会在队列为空时阻塞。
BlockingQueue<Integer> priorityBlockingQueue = new PriorityBlockingQueue<>();
2.4 DelayQueue
  • 特点:一个支持延时获取元素的无界阻塞队列,只有在延时到期的元素才能从队列中取出。基于优先级队列实现,内部元素需要实现 Delayed 接口。
  • 应用场景:适合定时任务、延迟消息处理的场景,例如任务调度器、到期通知系统。
  • 线程安全:延时控制实现了线程安全,take() 操作会在所有元素都未到期时阻塞。
BlockingQueue<Delayed> delayQueue = new DelayQueue<>();
2.5 SynchronousQueue
  • 特点:一个不存储元素的阻塞队列,每个 put 操作必须等待 take 操作完成,反之亦然。每个插入操作都需要等待相应的删除操作。
  • 应用场景:适合“交换”或“直通”场景,比如将生产者任务直接交给消费者线程,不需要缓冲。
  • 线程安全:每次操作都必须成对出现,插入和移除是同步的,可以有效避免过多的锁竞争。
BlockingQueue<Integer> synchronousQueue = new SynchronousQueue<>();
2.6 LinkedTransferQueue
  • 特点:基于链表实现的无界阻塞队列,提供了 transfer 方法,如果消费者线程未准备好,则会阻塞直到消费者消费。
  • 应用场景:适合生产者频繁生产、消费者频繁消费的场景,**消费者可以无等待地获取任务。**队列中可以有元素。
  • 线程安全:LinkedTransferQueue 支持生产者等待消费者接受数据。
BlockingQueue<Integer> linkedTransferQueue = new LinkedTransferQueue<>();

3. 阻塞队列的应用场景

阻塞队列在生产者-消费者模式中非常常见,尤其适合并发的任务处理和数据缓冲场景。例如:

  • 任务调度:线程池中的任务队列通常使用阻塞队列来存储等待处理的任务。
  • 消息缓冲:消息队列可以使用阻塞队列来暂存待处理的消息,保证消息不会丢失。
  • 资源池管理:可以用阻塞队列来管理资源池,比如数据库连接池,当连接池满时,新的请求会被阻塞。

4. 使用示例

以下是使用 BlockingQueue 实现生产者-消费者模式的示例:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ProducerConsumerExample {
    private static final int CAPACITY = 5;
    private static final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(CAPACITY);

    public static void main(String[] args) {
        Thread producer = new Thread(new Producer());
        Thread consumer = new Thread(new Consumer());

        producer.start();
        consumer.start();
    }

    static class Producer implements Runnable {
        @Override
        public void run() {
            int value = 0;
            try {
                while (true) {
                    System.out.println("Producing " + value);
                    queue.put(value++);
                    Thread.sleep(1000);  // 模拟生产过程
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    static class Consumer implements Runnable {
        @Override
        public void run() {
            try {
                while (true) {
                    int value = queue.take();
                    System.out.println("Consuming " + value);
                    Thread.sleep(1500);  // 模拟消费过程
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}
说明:
  • 生产者线程:不断向队列中添加整数。如果队列已满(达到 CAPACITY),生产者线程会阻塞,直到消费者消费掉一个元素后再继续生产。
  • 消费者线程:不断从队列中取出整数并消费。如果队列为空,消费者线程会阻塞,直到生产者生产出一个新的元素。

5. 阻塞队列的优缺点

  • 优点
    • 线程安全:阻塞队列的实现是线程安全的,适合多线程环境。
    • 自动阻塞和唤醒:阻塞队列自动管理线程的阻塞和唤醒,简化了并发编程。
    • 支持限界:可以设置队列的容量,有效控制资源的使用。
  • 缺点
    • 性能开销:阻塞队列在多线程环境下存在一定的性能开销,因为其内部会进行加锁和线程阻塞/唤醒操作。
    • 队列类型选择复杂:不同阻塞队列的实现方式和应用场景不同,需要根据具体需求选择合适的实现。

6. 总结

Java 中的阻塞队列是一种重要的并发数据结构,在多线程编程中应用广泛。常见的阻塞队列类型包括 ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueueDelayQueueSynchronousQueueLinkedTransferQueue。每种队列类型适用于不同的场景,选择合适的阻塞队列可以有效提高并发程序的性能和资源管理效率。

13. ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?

ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:

底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。

是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。

锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。

内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。

14. 如果队列是空的,消费者会一直等待,当生产者添加元素时,消费者是如何知道当前队列有元素的呢?

使用通知模式实现。所谓通知模式,当消费者从空的队列获取元素时会阻塞住消费者,此时如果生产者放了一个元素进入队列,则需要通知阻塞住消费者当前有元素可取。同理当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。通过查看JDK源码发现部分阻塞队列使用了Condition来实现。

15. CountDownLatch,CyclicBarrier,Semaphore,Exchanger了解吗?

在 Java 中,CountDownLatchCyclicBarrierSemaphoreExchangerjava.util.concurrent 包中的四种常用的并发工具类,用于线程间的协作和同步。它们各自的作用和使用场景不同,以下是对这四种工具类的详细介绍。

1. CountDownLatch

CountDownLatch 是一个计数器类,用于让一个或多个线程等待其他线程完成操作。CountDownLatch 初始化时设置一个计数器的值,每当一个线程完成任务后调用 countDown() 方法,计数器值会减 1。当计数器的值变为 0 时,等待的线程才会继续执行。

  • 主要方法
    • countDown():将计数器减 1。
    • await():阻塞当前线程,直到计数器为 0。
  • 使用场景
    • 等待多个线程完成操作后,主线程继续执行,例如并行计算任务后汇总结果。
    • 控制多个线程同时开始执行,例如模拟并发测试。
示例代码:
import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    public static void main(String[] args) throws InterruptedException {
        int threadCount = 3;
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " is working.");
                    Thread.sleep(1000); // 模拟任务执行
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    latch.countDown(); // 任务完成,计数器减 1
                }
            }).start();
        }

        latch.await(); // 主线程等待
        System.out.println("All threads have finished.");
    }
}

在这个示例中,主线程等待三个子线程都执行完成后才继续执行。

2. CyclicBarrier

CyclicBarrier 是子线程自发等待,CountDownLatch是主线程等待,子线程只负责递减。

CountDownLatch是主线程等待子线程执行完,CyclicBarrier是让子线程都执行到某个状态停下,粒度更细。

CyclicBarrier 是一个栅栏,允许一组线程在栅栏位置互相等待,直到所有线程都到达栅栏后再继续执行。每次使用后,CyclicBarrier 可以被重置(即“循环”使用),因此它可以被多次使用。

  • 主要方法
    • await():将当前线程挂起,直到所有线程都调用了 await() 并到达栅栏位置。
  • 使用场景
    • 多个线程需要同步执行某个阶段后再继续,例如分段计算后汇总结果。
    • 任务分阶段执行,每个阶段完成后所有线程都需要同步到达某个点。
示例代码:
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierExample {
    public static void main(String[] args) {
        int threadCount = 3;
        CyclicBarrier barrier = new CyclicBarrier(threadCount, () -> System.out.println("All threads reached the barrier."));

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " is working.");
                    Thread.sleep(1000); // 模拟任务执行
                    barrier.await(); // 等待所有线程到达屏障
                    System.out.println(Thread.currentThread().getName() + " passed the barrier.");
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

在这个示例中,三个线程都到达屏障后继续执行。CyclicBarrier 可以循环使用,适合分阶段的任务同步。

3. Semaphore

Semaphore 是一个信号量,用于控制同时访问某个资源的线程数量。Semaphore 通过一个计数器控制可用资源的数量,当计数器值大于 0 时,线程可以获取资源;否则线程会被阻塞,直到有其他线程释放资源。

  • 主要方法
    • acquire():获取一个许可,如果计数器为 0 则阻塞等待。
    • release():释放一个许可,将计数器加 1。
  • 使用场景
    • 限制对共享资源的并发访问数量,例如限制数据库连接池的最大连接数。
    • 用于实现流量控制或限流。
示例代码:
import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    public static void main(String[] args) {
        int permits = 2; // 最大许可数
        Semaphore semaphore = new Semaphore(permits);

        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    semaphore.acquire(); // 获取许可
                    System.out.println(Thread.currentThread().getName() + " acquired a permit.");
                    Thread.sleep(2000); // 模拟资源使用
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    System.out.println(Thread.currentThread().getName() + " released a permit.");
                    semaphore.release(); // 释放许可
                }
            }).start();
        }
    }
}

在这个示例中,最多允许两个线程同时访问资源,其他线程将会阻塞,直到有线程释放资源。

4. Exchanger

Exchanger 是一个用于线程间数据交换的工具类,它可以让两个线程在一个同步点交换数据。每个线程通过 exchange() 方法将数据传递给对方,并接收来自对方的数据。

  • 主要方法
    • exchange(V x):将数据 x 传递给另一个线程,并接收另一个线程传递的数据。
  • 使用场景
    • 在两个线程之间交换数据,例如两个任务之间传递中间结果。
    • 适用于配对任务场景,如生产者-消费者模式下的数据交换。
示例代码:
import java.util.concurrent.Exchanger;

public class ExchangerExample {
    public static void main(String[] args) {
        Exchanger<String> exchanger = new Exchanger<>();

        Thread t1 = new Thread(() -> {
            try {
                String data = "Data from Thread 1";
                System.out.println("Thread 1 is exchanging: " + data);
                // 注意是传递加接收。
                String receivedData = exchanger.exchange(data);
                System.out.println("Thread 1 received: " + receivedData);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                String data = "Data from Thread 2";
                System.out.println("Thread 2 is exchanging: " + data);
                String receivedData = exchanger.exchange(data);
                System.out.println("Thread 2 received: " + receivedData);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        t1.start();
        t2.start();
    }
}

在这个示例中,Thread 1Thread 2 通过 Exchanger 交换数据。exchange() 方法会阻塞,直到两个线程都调用了 exchange(),然后完成数据交换。

5. 对比总结

工具类功能使用场景
CountDownLatch计数器倒数至 0,所有等待的线程继续执行等待一组任务完成后再执行主任务
CyclicBarrier线程到达栅栏位置后一起继续执行多个线程需要同步执行某个阶段后再继续,例如分段计算后汇总结果
Semaphore控制同时访问资源的线程数量流量控制、资源限制,如数据库连接池
Exchanger让两个线程交换数据两个线程间的数据交换,如任务间的中间结果传递

6. 总结

  • CountDownLatch 适合一个线程等待多个线程完成任务。
  • CyclicBarrier 适合一组线程在某个时间点同步后再继续执行。
  • Semaphore 适合限制资源访问数量,用于流量控制。
  • Exchanger 适合两个线程之间的同步数据交换。

通过这些并发工具类,Java 程序可以更灵活地实现线程间的协作和同步,有效提升多线程程序的性能和可控性。

16. CyclicBarrier和CountDownLatch有什么区别?

CyclicBarrier是可重用的(从Cyclic也能看出来),其中的线程会等待所有的线程完成任务。届时,屏障将被拆除,并可以选择性地做一些特定的动作。CountDownLatch是一次性的,不同的线程在同一个计数器上工作,直到计数器为0。

CyclicBarrier 设置10,关注点是线程,表示需要10个线程准备好一起执行。

CountDownLatch 设置10,其实可以由一个线程count down 10次 。 一般就是完成一个任务就-1