并发工具类
CountDownLatch、CyclicBarrier、Semaphore 区别?
CountDownLatch
CountDownLatch 是一个倒计时门闩。
它的核心特点是:一个或多个线程等待其他线程完成任务。
典型场景:
CountDownLatch latch = new CountDownLatch(3);
latch.countDown(); // 计数减 1
latch.await(); // 等待计数归零特点:
| 点 | 说明 |
|---|---|
| 计数方式 | 初始化一个计数值,每调用一次 countDown() 减 1 |
| 阻塞方法 | await() |
| 是否可复用 | 不可复用 |
| 典型场景 | 主线程等待多个子任务完成 |
例子:主线程等 5 个子线程都执行完,再继续执行。
CyclicBarrier
CyclicBarrier 是一个循环屏障。
它的核心特点是:多个线程互相等待,直到所有线程都到达某个屏障点,再一起继续执行。
典型场景:
CyclicBarrier barrier = new CyclicBarrier(3);
barrier.await(); // 等待其他线程也到达特点:
| 点 | 说明 |
|---|---|
| 计数方式 | 等待固定数量线程到达屏障 |
| 阻塞方法 | await() |
| 是否可复用 | 可复用 |
| 典型场景 | 多个线程分阶段协同执行 |
例子:3 个线程都完成第一阶段后,再一起进入第二阶段。
Semaphore
Semaphore 是一个信号量。
它的核心特点是:控制同时访问某个资源的线程数量。
典型场景:
Semaphore semaphore = new Semaphore(3);
semaphore.acquire(); // 获取许可证
try {
// 访问资源
} finally {
semaphore.release(); // 释放许可证
}特点:
| 点 | 说明 |
|---|---|
| 控制对象 | 许可证数量 |
| 阻塞方法 | acquire() |
| 释放方法 | release() |
| 是否可复用 | 可复用 |
| 典型场景 | 限流、连接池、资源访问控制 |
例子:数据库连接池最多允许 10 个线程同时获取连接。
三者对比总结
| 工具 | 作用 | 是否可复用 | 典型用途 |
|---|---|---|---|
| CountDownLatch | 一个或多个线程等待其他线程完成 | 否 | 主线程等待子线程完成 |
| CyclicBarrier | 多个线程互相等待,到齐后继续 | 是 | 多线程分阶段任务 |
| Semaphore | 控制并发访问数量 | 是 | 限流、资源池 |
一句话总结:CountDownLatch 是"别人干完我再干";CyclicBarrier 是"大家到齐一起干";Semaphore 是"最多允许 N 个线程干"。
BlockingQueue 有哪些实现?
BlockingQueue 是 Java 并发包中的阻塞队列接口,常用于生产者消费者模型。
常见实现
| 实现类 | 特点 |
|---|---|
| ArrayBlockingQueue | 基于数组,有界阻塞队列 |
| LinkedBlockingQueue | 基于链表,可有界也可近似无界 |
| PriorityBlockingQueue | 支持优先级排序的无界阻塞队列 |
| DelayQueue | 延迟队列,元素到期后才能取出 |
| SynchronousQueue | 不存储元素,每个 put 必须等待 take |
| LinkedTransferQueue | 基于链表的无界传输队列 |
| LinkedBlockingDeque | 双端阻塞队列 |
重点实现说明
ArrayBlockingQueue
基于数组,必须指定容量。
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);特点:
- 有界
- FIFO
- 使用一个 ReentrantLock
- 适合固定容量的生产者消费者场景
LinkedBlockingQueue
基于链表,可以指定容量,不指定时默认容量非常大。
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(100);特点:
- 默认近似无界
- FIFO
- 通常使用两个锁:putLock 和 takeLock
- 吞吐量通常比 ArrayBlockingQueue 更高
PriorityBlockingQueue
带优先级的阻塞队列。
BlockingQueue<Task> queue = new PriorityBlockingQueue<>();特点:
- 无界
- 不保证 FIFO
- 元素需要实现 Comparable 或传入 Comparator
- 适合任务优先级调度
DelayQueue
延迟队列。
特点:
- 元素必须实现 Delayed
- 只有延迟时间到期后才能被取出
- 适合定时任务、缓存过期、订单超时取消
SynchronousQueue
不存储元素的队列。
特点:
- 容量为 0
- 每个 put 必须等待一个 take
- 每个 take 必须等待一个 put
- 常用于
Executors.newCachedThreadPool()
ThreadLocal 是什么?
ThreadLocal 是 Java 提供的一种线程本地变量机制。
它可以让每个线程都拥有变量的独立副本,线程之间互不影响。
private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
threadLocal.set(100);
Integer value = threadLocal.get();
threadLocal.remove();比如多个线程都访问同一个 ThreadLocal 对象:
Thread-1 -> value = A
Thread-2 -> value = B
Thread-3 -> value = C它们拿到的是各自线程自己的值,不会互相干扰。
ThreadLocal 的底层原理
每个 Thread 对象内部都有一个 ThreadLocalMap。
大致结构可以理解为:
Thread {
ThreadLocalMap threadLocals;
}ThreadLocalMap 中存储的是:
- key = ThreadLocal 对象
- value = 线程本地变量值
也就是说,数据并不是存在 ThreadLocal 里面,而是存在当前线程的 ThreadLocalMap 里面。
常见使用场景
| 场景 | 说明 |
|---|---|
| 用户上下文 | 保存当前登录用户信息 |
| 请求上下文 | 保存 traceId、requestId |
| 数据库连接 | 每个线程维护自己的连接 |
| 日期格式化 | 解决 SimpleDateFormat 线程不安全问题 |
| 事务管理 | 保存当前线程事务状态 |
核心作用:ThreadLocal 用来在同一个线程内共享变量,在不同线程间隔离变量。
ThreadLocal 为什么可能内存泄漏?
核心原因
ThreadLocalMap 的 key 是弱引用,但 value 是强引用。
结构类似:
Entry extends WeakReference<ThreadLocal<?>> {
Object value;
}也就是说:
- key → ThreadLocal,弱引用
- value → 实际对象,强引用
如果 ThreadLocal 对象没有外部强引用了,GC 时 key 会被回收,变成 null。
但是 value 仍然被 ThreadLocalMap 强引用着。
于是出现这种情况:
key = null
value = 某个大对象如果线程一直不结束,比如线程池中的核心线程长期存活,那么这个 value 就可能一直无法释放,造成内存泄漏。
为什么线程池更容易出现?
普通线程执行完后,Thread 对象销毁,它内部的 ThreadLocalMap 也会被回收。
但是线程池中的线程会被复用,生命周期很长。
所以如果没有及时调用:
threadLocal.remove();旧数据可能一直留在线程中。
正确使用方式
一定要在 finally 中调用 remove():
try {
threadLocal.set(value);
// 业务逻辑
} finally {
threadLocal.remove();
}ThreadLocal 内存泄漏的根本原因是线程长期存活时,ThreadLocalMap 中 key 被回收但 value 仍被强引用;解决方式是用完后调用 remove。
ConcurrentHashMap JDK 1.7 和 1.8 有什么区别?
JDK 1.7:Segment 分段锁
JDK 1.7 中,ConcurrentHashMap 的核心结构是:
ConcurrentHashMap
Segment[]
HashEntry[]
HashEntry -> HashEntry -> HashEntry也就是:Segment 数组 + HashEntry 数组 + 链表
其中 Segment 继承了 ReentrantLock。
每个 Segment 相当于一个小的 HashMap。
并发控制靠的是分段锁。
| 点 | JDK 1.7 |
|---|---|
| 数据结构 | Segment + HashEntry + 链表 |
| 锁粒度 | Segment 级别 |
| 锁机制 | ReentrantLock |
| 并发度 | 默认 16 个 Segment |
| 查询 get | 通常不加锁,使用 volatile 保证可见性 |
| 扩容 | 每个 Segment 单独扩容 |
JDK 1.8:CAS + synchronized + 红黑树
JDK 1.8 中,ConcurrentHashMap 取消了 Segment 分段锁,结构变成:
Node[] table
Node -> Node -> Node链表过长时会转成红黑树:数组 + 链表 + 红黑树
并发控制方式:
- 插入空桶时使用 CAS
- 桶中已有元素时使用 synchronized 锁住桶头节点
- 链表长度过长时转红黑树
- 扩容时多个线程可以协助迁移数据
| 点 | JDK 1.8 |
|---|---|
| 数据结构 | Node 数组 + 链表 + 红黑树 |
| 锁粒度 | 桶级别 |
| 锁机制 | CAS + synchronized |
| 是否有 Segment | 保留类定义但不再作为核心结构 |
| 查询 get | 不加锁 |
| 扩容 | 多线程协助扩容 |
| 性能优化 | 降低锁粒度,提高并发性能 |
JDK 1.7 和 1.8 对比
| 对比项 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 底层结构 | Segment 数组 + HashEntry 数组 + 链表 | Node 数组 + 链表 + 红黑树 |
| 锁粒度 | Segment | 桶头节点 |
| 锁实现 | ReentrantLock | CAS + synchronized |
| 是否有红黑树 | 没有 | 有 |
| 扩容方式 | Segment 内部单独扩容 | 多线程协助扩容 |
| 并发度 | 受 Segment 数量影响 | 更细粒度,按桶控制 |
| 查询 | 基本无锁 | 无锁 |
| 插入 | 锁 Segment | CAS 或锁桶头 |
为什么 JDK 1.8 改用 synchronized?
因为 JDK 1.6 之后,synchronized 做了大量优化,例如:
- 偏向锁
- 轻量级锁
- 锁膨胀
- 锁消除
- 自适应自旋
在低竞争场景下,synchronized 性能已经很好,而且代码更简单。
总结:JDK 1.7 的 ConcurrentHashMap 使用 Segment 分段锁,底层是 Segment 数组加 HashEntry 数组加链表,每个 Segment 继承 ReentrantLock,锁粒度是 Segment。JDK 1.8 取消了 Segment 分段锁,底层变成 Node 数组加链表加红黑树,插入空桶用 CAS,发生冲突时用 synchronized 锁桶头节点,扩容时支持多线程协助迁移,因此锁粒度更细,并发性能更好。