JUC的常见类
- 什么是JUC?
- Callable
-
- [为什么需要 Callable?](#为什么需要 Callable?)
- [Callable 与 Runnable 的核心区别⭐](#Callable 与 Runnable 的核心区别⭐)
- [Callable + FutureTask 的执行流程](#Callable + FutureTask 的执行流程)
- [FutureTask.get() 的阻塞机制](#FutureTask.get() 的阻塞机制)
- ReentrantLock
-
- [ReentrantLock vs synchronized 对比⭐](#ReentrantLock vs synchronized 对比⭐)
-
- [a)本质不同:关键字 vs 类](#a)本质不同:关键字 vs 类)
- b)加锁解锁机制不同
- [c)tryLock() ------ 尝试加锁,不阻塞](#c)tryLock() —— 尝试加锁,不阻塞)
- d)公平锁支持
- [e)Condition ------ 更强大的等待通知机制](#e)Condition —— 更强大的等待通知机制)
- Semaphore
-
- 什么是信号量?
- [核心概念:PV 操作⭐](#核心概念:PV 操作⭐)
- 信号量退化为互斥锁⭐
- 信号量的广义理解⭐
- CountDownLatch:倒计时协调器
-
- [CountDownLatch 是什么?⭐](#CountDownLatch 是什么?⭐)
- CountDownLatch与线程池⭐
- 关键特性
-
- [1. CountDownLatch一次性使用](#1. CountDownLatch一次性使用)
- [2. 一对多等待 vs 多对一等待](#2. 一对多等待 vs 多对一等待)
- [3. 计数器归零后,await 永远不会阻塞](#3. 计数器归零后,await 永远不会阻塞)
- 线程安全集合类
-
- [多线程使用 List](#多线程使用 List)
- 多线程使用队列(阻塞队列)
- 多线程使用哈希表
-
- [a)Hashtable ------ 简单粗暴的线程安全](#a)Hashtable —— 简单粗暴的线程安全)
- [b)ConcurrentHashMap ⭐](#b)ConcurrentHashMap ⭐)
-
- 优化1:锁桶(锁粒度变小)
- [优化2:CAS 维护 size](#优化2:CAS 维护 size)
- 优化3:渐进式扩容(化整为零)⭐
- 原子类
- 线程池
什么是JUC?
JUC是java.util.concurrent包的简称,它是Java并发编程的库,提供了一套经过精心设计的高性能并发工具。这个包涵盖了锁、同步器、原子变量、线程池、并发集合等方方面面,极大地降低了并发编程的难度。
Callable
为什么需要 Callable?
传统的 Runnable 接口有个明显短板:run() 方法没有返回值,也不能抛出受检异常。例子:
java
private static int total =0;
Runnable runnable = new Runnable() {
@Override
public void run() {
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum += i;
}
total = sum; // 只能通过共享变量传出结果
}
};
Thread t = new Thread(runnable);
t.start();
t.join(); // 必须等待线程结束才能安全读取 total
System.out.println("total = " + total);
这种写法有几个痛点:
- 需要定义静态成员变量
total来中转结果 - 必须调用
join()等待线程结束,否则可能读到错误值 - 代码不够内聚,逻辑分散
Callable 接口就是为了解决这个问题而生的。
Callable 与 Runnable 的核心区别⭐
| 对比项 | Runnable | Callable |
|---|---|---|
| 方法签名 | void run() |
V call() throws Exception |
| 返回值 | 无 | 有(泛型指定类型) |
| 异常处理 | 不能抛出受检异常 | 可以抛出异常 |
| 配合使用 | 直接交给 Thread | 需通过 FutureTask 包装后交给 Thread |
Callable + FutureTask 的执行流程
完整流程:
java
// 第1步:定义任务(只是定义,并未执行)
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for (int i = 1; i <= 100; i++) {
result += i;
}
return result; // 直接返回结果
}
};
// 第2步:包装成 FutureTask
FutureTask<Integer> futureTask = new FutureTask<>(callable);
// 第3步:交给线程执行
Thread t = new Thread(futureTask);
t.start();
// 第4步:获取结果(可能阻塞)
System.out.println(futureTask.get()); // 输出 5050
Callable只是定义任务,此刻代码并未真正执行FutureTask像一个容器/取餐小票,既包装了任务,又能在未来取出结果- 任务的真正执行,仍需要搭配
Thread对象调用start()
FutureTask.get() 的阻塞机制
get操作就是获取到FutureTask的返回值。这个返回值就来自于Callable的call方法。
get可能会阻塞,如果当前线程执行完毕,get拿到返回结果。
如果当前线程还没执行完毕,get 会一直阻塞,直到执行完毕。
包装
交给
调用get
Callable
FutureTask
Thread
拿到结果 ,可能发生阻塞
ReentrantLock
ReentrantLock vs synchronized 对比⭐
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 本质 | Java关键字(JVM内部C++实现) | Java标准库中的类(java.util.concurrent.locks) |
| 加锁/解锁 | 代码块自动控制,退出即释放 | 手动调用 lock() / unlock() |
| 释放保障 | JVM保证一定释放 | 必须在 finally 中释放,否则容易遗漏 |
| 公平锁 | 只支持非公平锁 | 默认非公平,构造参数 true 可指定公平锁 |
| 尝试加锁 | 不支持,死等 | tryLock() 尝试加锁,失败立即返回 false |
| 等待通知 | wait() / notify() |
Condition 类,功能更强大 |
简单场景
需要尝试加锁
需要公平锁
需要超时等待
需要精准唤醒
需要加锁
功能需求
synchronized
ReentrantLock
优点:自动释放
代码简洁
不用担心死锁
优点:功能更丰富
灵活性更高
a)本质不同:关键字 vs 类
java
// synchronized 是语言层面的关键字
synchronized (obj) {
// 临界区代码
}
// ReentrantLock 是标准库提供的类
ReentrantLock locker = new ReentrantLock();
locker.lock();
try {
// 临界区代码
} finally {
locker.unlock();
}
synchronized 由 JVM 底层 C++ 实现,而 ReentrantLock 是一个纯 Java 类。
b)加锁解锁机制不同
这是两者使用上最重要的区别:
- synchronized:进入代码块自动加锁,退出代码块自动释放,JVM 保证不会忘记释放。
- ReentrantLock :需要手动
lock()和unlock(),一旦忘记调用unlock(),就会造成死锁。
java
// ❌ 危险写法:unlock 不在 finally 中
locker.lock();
count++; // 万一这里抛异常,unlock 永远不执行!
locker.unlock();
// ✅ 正确写法:unlock 必须在 finally 中
locker.lock();
try {
count++;
} finally {
locker.unlock(); // 无论如何都会释放锁
}
c)tryLock() ------ 尝试加锁,不阻塞
这是 synchronized 完全不具备的能力:
java
ReentrantLock locker = new ReentrantLock();
// 尝试加锁,成功返回 true,失败立即返回 false,不会阻塞等待
if (locker.tryLock()) {
try {
// 拿到锁,执行业务
} finally {
locker.unlock();
}
} else {
// 没拿到锁,做其他事情,而不是傻等
System.out.println("锁被占用,先干别的");
}
synchronized没抢到锁就阻塞死等 ,而tryLock()让你有了自己决定下一步做什么的空间。
d)公平锁支持
ReentrantLock提供了公平锁的实现,默认是非公平的。
java
// 非公平锁(默认):不保证等待最久的线程先获取锁,吞吐量高
ReentrantLock locker1 = new ReentrantLock(false);
// 或直接
ReentrantLock locker1 = new ReentrantLock();
// 公平锁:按照请求顺序排队获取锁,公平但性能稍低
ReentrantLock locker2 = new ReentrantLock(true);
| 锁类型 | 获取顺序 | 性能 | synchronized |
|---|---|---|---|
| 非公平锁 | 不保证先到先得 | 更高 | 仅此一种 |
| 公平锁 | 严格先到先得 | 略低 | 不支持 |
e)Condition ------ 更强大的等待通知机制
synchronized 配合的是 wait() / notify(),但 notify() 只能随机唤醒一个等待线程,无法指定唤醒哪个。
ReentrantLock 搭配的 Condition 类,可以实现精准唤醒:
java
ReentrantLock locker = new ReentrantLock();
Condition condition = locker.newCondition();
// 线程A:等待某个条件
locker.lock();
try {
while (条件不满足) {
condition.await(); // 类似 wait()
}
// 条件满足,执行业务
} finally {
locker.unlock();
}
// 线程B:满足条件后,精准唤醒线程A
locker.lock();
try {
// 修改条件
condition.signal(); // 类似 notify(),但可以指定唤醒对象
} finally {
locker.unlock();
}
一个
ReentrantLock可以创建多个Condition对象,实现多路精准唤醒,这是wait/notify做不到的。
Semaphore
什么是信号量?
信号量的核心思想就是一句话:
如果是普通的 N 的信号量,就可以限制同时有多少个线程来执行某个逻辑。
它本质上是一个计数器,用来控制同时访问某个资源的线程数量。
核心概念:PV 操作⭐
信号量有两个核心操作,
| P操作 (acquire) | V操作 (release) |
|---|---|
| 申请资源,计数器-1 计数器为0时阻塞等待 | 释放资源,计数器+1 唤醒等待的线程 |
生活比喻:停车场
java
// 停车场只有 4 个车位
Semaphore semaphore = new Semaphore(4);
semaphore.acquire(); // P操作:开进一辆车,剩余车位 -1
try {
// 车停在停车场内(访问受限资源)
} finally {
semaphore.release(); // V操作:开走一辆车,剩余车位 +1
}
结果
线程申请
初始状态
许可证 +1
🗳️ 许可证 ×4
acquire()
拿走1张
acquire()
拿走1张
acquire()
拿走1张
acquire()
拿走1张
acquire()
还想拿...
✅ 执行
✅ 执行
✅ 执行
✅ 执行
⏳ 阻塞等待
release() 归还
信号量退化为互斥锁⭐
当许可数为 1 时,信号量退化为互斥锁(二元信号量)。
java
Semaphore semaphore = new Semaphore(1); // 只有 1 个许可
这就等价于一把锁:
java
// 这两个写法效果相同:
// 写法1:Semaphore 当互斥锁
semaphore.acquire();
count++;
semaphore.release();
// 写法2:synchronized
synchronized (lock) {
count++;
}
执行流程示意:
count 变量 线程2 🔒 许可证(1个) 线程1 count 变量 线程2 🔒 许可证(1个) 线程1 初始有 1 张许可证 ... 交替执行 100000 次 ... acquire() 申请许可 ✅ 拿到许可 count++ (50000次中的第1次) release() 归还许可 acquire() 申请许可 ✅ 拿到许可 count++ (50000次中的第1次) release() 归还许可 acquire() 申请许可 ✅ 拿到许可 count++ (第2次) release() 归还许可 acquire() 申请许可 ✅ 拿到许可 count++ (第2次) release() 归还许可
信号量的广义理解⭐
信号量是一个广义的锁。
🔢 信号量 Semaphore
广义的锁
new Semaphore(1)
new Semaphore(2)
new Semaphore(N)
🔒 互斥锁
(二元信号量)
同一时刻只有 1 个线程
可以访问资源
等价于 synchronized
🔐 限流器
(通用信号量)
同一时刻最多 2 个线程
可以访问资源
🔐 限流器
(通用信号量)
同一时刻最多 N 个线程
可以访问资源
CountDownLatch:倒计时协调器
CountDownLatch 是什么?⭐
三步核心流程:
- 构造方法指定参数,描述拆成了多少个任务。
- 每个任务执行完毕之后,都调用一次
countDown()方法。
- 主线程中调用
await()方法,等待所有任务执行完毕。
否
是
构造方法
new CountDownLatch(N)
子任务完成
调用 countDown()
计数器 -1
计数器 == 0 ?
await() 返回
主线程继续执行
它就像一个倒计数器------初始设定一个数字,每完成一个任务减 1,减到 0 时,等待的线程就被唤醒继续执行。
CountDownLatch与线程池⭐
java
// 第1步:创建倒计数器,初始值 = 10(表示有 10 个子任务)
CountDownLatch latch = new CountDownLatch(10);
// 第2步:创建线程池(4 个线程执行 10 个任务)
ExecutorService executor = Executors.newFixedThreadPool(4);
// 第3步:提交 10 个子任务
for (int i = 0; i < 10; i++) {
int id = i;
executor.submit(() -> {
System.out.println("子任务开始执行: " + id);
Thread.sleep(1000); // 模拟耗时操作
System.out.println("子任务结束执行: " + id);
latch.countDown(); // ⬅ 完成一个,计数器 -1
});
}
// 第4步:主线程等待,直到计数器归零
latch.await(); // 阻塞,直到 10 个任务全部完成
System.out.println("所有任务执行完毕");
executor.shutdown();
执行流程图:
线程池(4线程) CountDownLatch(10) 主线程 线程池(4线程) CountDownLatch(10) 主线程 loop [10 次] new CountDownLatch(10) 提交 10 个子任务 await() ⏳ 阻塞等待 执行子任务 countDown() 计数器 -1 ✅ 计数器归零,唤醒主线程 打印 "所有任务执行完毕"
| 方法 | 调用者 | 作用 | 特点 |
|---|---|---|---|
new CountDownLatch(N) |
主线程 | 设定倒计数的初始值 | N = 子任务数量 |
countDown() |
子线程 | 计数器 -1 | 原子操作,线程安全 |
await() |
主线程 | 阻塞等待计数器归零 | 归零后立即返回 |
关键特性
1. CountDownLatch一次性使用
java
// ❌ CountDownLatch 是一次性的,计数器归零后不能重置
latch.await(); // 等待通过
latch.await(); // 第二次调用立即返回(已经归零了),不会重新倒计数
如果需要可重用,应该用 CyclicBarrier 或 Phaser。
2. 一对多等待 vs 多对一等待
java
// 场景1:主线程等待多个子线程(一对多)
CountDownLatch latch = new CountDownLatch(5);
// 5 个子线程各执行 countDown()
// 主线程 await()
// → 最常用的场景
// 场景2:多个线程等待一个信号(多对一)
CountDownLatch startSignal = new CountDownLatch(1);
// 5 个子线程都调用 await()
// 主线程调用 countDown()
// → 5 个子线程同时开始执行(发令枪模式)
3. 计数器归零后,await 永远不会阻塞
java
CountDownLatch latch = new CountDownLatch(3);
latch.countDown();
latch.countDown();
latch.countDown();
// 归零了
latch.await(); // 立即返回,不会阻塞
latch.await(); // 再调用也是立即返回
latch.await(); // 调用多少次都是立即返回
线程安全集合类
多线程使用 List
多线程使用 List
方式1:手动加锁
synchronized / ReentrantLock
方式2:包装
Collections.synchronizedList()
方式3:写时拷贝
CopyOnWriteArrayList
| 方式 | 读性能 | 写性能 | 适用场景 |
|---|---|---|---|
| 手动加锁 | 一般 | 一般 | 通用 |
| synchronizedList | 一般 | 一般 | 通用 |
| CopyOnWriteArrayList | ⭐极高 | 差(每次复制全数组) | 读多写少 |
a)手动加锁
java
// 自己用 synchronized 或 ReentrantLock 保护
synchronized (list) {
list.add("item");
}
前面 synchronized 和 ReentrantLock 章节已经详细讨论过,不再展开。
b)Collections.synchronizedList
java
List<String> list = Collections.synchronizedList(new ArrayList<>());
- 标准库提供的线程安全包装
- 关键操作(
add、get、remove等)上都自动加了synchronized - 本质上就是对方法套了一层
synchronized
c)CopyOnWriteArrayList(写时拷贝)
核心策略是写时复制。 工作原理:
① 复制
② 修改
③ 引用原子切换
切换前
切换后
旧数组
A,B,C,D
副本
A,B,C,D
新数组
A,X,C,D
读线程始终读引用指向的数组
时间线序列图(读线程视角):
数组引用 写线程 读线程2 读线程1 数组引用 写线程 读线程2 读线程1 初始指向 [A, B, C, D] 引用仍指向旧数组 整个过程中不会读到 [A, X, C, D] 这种修改一半的数据 读取 返回 B ✅ ① 复制整个数组 副本 [A, B, C, D] 读取(写操作进行中) 返回 C ✅ 读到的是旧数组 ② 副本修改为 [A, X, C, D] 读取 返回 D ✅ 还是旧数组 ③ 原子替换引用 读取 返回 X ✅ 读到新数组了
适用场景:读多写少
eg.服务器配置热加载(reload)
java
// 服务器正在运行,需要修改配置
// 正常来说,修改配置文件需要重启服务器
// 但很多服务器提供 reload 功能
// 配置以数组/哈希形式存在内存中
// 其他线程频繁读取配置(只读不写)
// reload 时:
// ① 创建新的数组/哈希,加载新配置
// ② 加载完毕,替换引用(原子操作)
// ③ 读线程要么读到旧配置,要么读到新配置,不会读到一半新一半旧
多线程使用队列(阻塞队列)
阻塞队列 BlockingQueue
ArrayBlockingQueue
基于数组实现
LinkedBlockingQueue
基于链表实现
PriorityBlockingQueue
基于堆,带优先级
TransferQueue
最多只包含一个元素
| 队列类型 | 底层实现 | 特点 |
|---|---|---|
| ArrayBlockingQueue | 数组 | 有界,容量固定 |
| LinkedBlockingQueue | 链表 | 可有界可无界 |
| PriorityBlockingQueue | 堆 | 元素有优先级 |
| TransferQueue | --- | 最多 1 个元素,生产者直接交给消费者 |
多线程使用哈希表
多线程使用哈希表
Hashtable
ConcurrentHashMap
ConcurrentHashMap
桶3
🔒 锁3
数据
桶2
🔒 锁2
数据
桶1
🔒 锁1
数据
桶0
🔒 锁0
数据
Hashtable
🔒 全局锁 this
桶0
桶1
桶2
桶3
⚠️ Hashtable:不管操作哪个桶
都要先抢这把锁
不同桶之间也互斥
✅ ConcurrentHashMap:操作桶0 只锁桶0
操作桶1 只锁桶1
不同桶之间互不影响
| Hashtable | ConcurrentHashMap | |
|---|---|---|
| 锁粒度 | 锁整个表 | 锁单个桶 |
| 不同桶操作 | 互斥等待 | 并行执行 |
| 锁对象 | this |
每个链表的头结点 |
a)Hashtable ------ 简单粗暴的线程安全
多线程同时 put,可能导致数据丢失、链表成环(JDK7)等问题。
java
// Hashtable:所有 public 方法都加 synchronized(this)
// 等同于全局一把锁
synchronized(this) {
put(key1, val1); // 操作桶1
}
synchronized(this) {
put(key2, val2); // 操作桶2(不同桶也要竞争同一把锁!)
}
Hashtable 的锁对象是 this(整个表)
┌─────────────────────────┐
│ Hashtable │
│ ┌──────────────┐ │
│ │ 🔒 this │ │ ← 整个表只有一把锁
│ │ ┌───┐┌───┐ │ │
│ │ │桶0││桶1│ │ │
│ │ └───┘└───┘ │ │ 操作桶0 和 桶1 也会互斥
│ │ ┌───┐┌───┐ │ │ 即使它们在不同的链表上
│ │ │桶2││桶3│ │ │
│ │ └───┘└───┘ │ │
│ └──────────────┘ │
└─────────────────────────┘
任意两个线程,访问任意的两个不同元素,都会产生锁竞争!
b)ConcurrentHashMap ⭐
ConcurrentHashMap 放弃了全局锁,采用了化整为零的思想。
优化1:锁桶(锁粒度变小)
如果修改的两个元素在不同链表上,本身就不涉及线程安全问题。
直接使用每个链表的头结点作为锁对象就行了。
场景2:操作相同桶
acquire()
acquire()
🔵 线程A
🔒 锁0 桶0
🟠 线程B
✅ 线程A 拿到锁,执行
⏳ 线程B 阻塞等待
同一把锁 → 只有一个能拿到
场景1:操作不同桶
acquire()
acquire()
🔵 线程A
🔒 锁0 桶0
🟠 线程B
🔒 锁1 桶1
✅ 拿到锁,执行
✅ 拿到锁,执行
两把不同的锁 → 并行,无竞争
优化2:CAS 维护 size
想象一个场景:线程 A 正在往 ConcurrentHashMap 里添加元素,此时线程 B 想查询 map.size()。
如果没有 CAS,要保证 size 正确,就得这样写:
java
// ❌ 低效做法:对整个表加锁才能统计 size
public int size() {
synchronized (this) { // 全局锁!和所有写操作互斥
return count;
}
}
public void put(K key, V value) {
synchronized (this) { // 全局锁!和 size() 互斥
// 插入元素
count++;
}
}
后果 :读大小和写操作互相阻塞,性能极差。这又退化成 Hashtable 了。
CAS(Compare And Swap,比较并交换) 是一条 CPU 原子指令,不用加锁就能实现先检查,再更新的线程安全操作。
CAS 维护 size 就是:修改
size时不上锁,而是用 CPU 原子指令"比较并交换"。成功了最好;失败了说明别人抢先改了,那就重读新值再试一次。这样读写 size 永远不会阻塞,性能远优于对整个表加锁。
假设当前 size = 10,两个线程同时添加元素:
线程2 (期望+1) size (当前=10) 线程1 (期望+1) 线程2 (期望+1) size (当前=10) 线程1 (期望+1) size 变成了 11 最终 size = 12,正确! CAS(10 → 11) 检查:当前是10吗? ✅ 是10,更新为11,成功! CAS(10 → 11) 检查:当前是10吗? ❌ 已经是11了,不是10,失败! 重新读取 size = 11 CAS(11 → 12) 检查:当前是11吗? ✅ 是11,更新为12,成功!
整个过程没有加锁 ,但 size 从 10 正确地变成了 12。
优化3:渐进式扩容(化整为零)⭐
✅ 渐进式扩容
触发扩容
创建新数组
标记'正在扩容'
每次 put / get 时
顺手搬几个桶
多次操作后
全部搬完
搬运期间:
只锁当前搬运的桶
其他桶正常读写
❌ 传统扩容
触发扩容
锁住整张表
一口气搬完所有元素
释放锁
搬运期间:
所有 put / get 全部阻塞
如果元素很多 → 长时间等待
用时间线的形式对比两种扩容方式对线程的影响
0 0 0 0 0 0 0 0 0 0 0 0 0 🔒 全表加锁 所有线程阻塞 🔒 锁桶0 搬A ✅ 桶0-3 可访问 🔒 锁桶1 搬B ✅ 桶0,2,3 可访问 🔒 锁桶2 搬C ✅ 桶0,1,3 可访问 🔒 锁桶3 搬D ✅ 全部桶可访问 ✅ 其他线程可操作 ❌ 传统扩容 ✅ 渐进式扩容 扩容期间线程阻塞时间对比
java
// ❌ 传统扩容的逻辑(伪代码)
void resize() {
lock(整个表); // 锁所有桶
newTable = new Table[新长度]; // 建新表
for (每个元素 in 旧表) {
搬运到新表; // 一口气全搬
}
table = newTable; // 切换引用
unlock(整个表); // 搬完才放锁
}
// 问题:元素多 → 锁持有时间长 → 所有线程阻塞
// ✅ ConcurrentHashMap 渐进式扩容(伪代码)
void put(key, value) {
if (正在扩容) {
帮忙搬一个桶(); // 顺手搬一点!
}
// 正常执行 put 逻辑
}
void get(key) {
if (正在扩容) {
帮忙搬一个桶(); // 读操作也帮忙搬!
}
// 正常执行 get 逻辑
}
// 好处:每次只锁一个桶搬,搬完就释放,其他桶不受影响