大家好,我是晚安code。
学 Java 并发编程那阵子,我最怕的不是死锁,是 JUC 这堆辅助类的名字:CountDownLatch 和 CyclicBarrier 哪个是减法哪个是加法?Semaphore 到底管什么的?阻塞队列那四个 API 又有什么区别?面试被问一次、背一遍,过两周又混。今天我把它们放进一篇里串起来讲,每个配一段能直接跑的代码和一个生活化场景,再给一张阻塞队列的速查表,看完基本就忘不掉了。示例基于 JDK 8,2026 年 8 月整理,这些类的接口在 JDK 8~21 一直稳定。
先点个收藏,我们开始。
一、Callable:线程也要有返回值
Runnable 最大的遗憾就是 run() 没有返回值——当你需要让线程干完活把结果交回来时,就该上 Callable + FutureTask,这也是面试常问的「Callable 接口用法」。
Callable:Java 提供的带返回值任务接口,泛型 V 就是 call() 方法的返回类型。你可以把它理解成「能领工资的 Runnable」——同样是交给线程跑,但干完活会给你带回一个结果,还允许抛异常。
问题来了:new Thread() 只认 Runnable,接不了 Callable。中间的桥就是 FutureTask:它本身是个 Runnable,同时又能把 Callable 算出的结果存下来,等你去取。
public class CallableTest {
public static void main(String[] args) throws Exception {
FutureTask<String> task = new FutureTask<>(new MyCallable());
new Thread(task, "线程A").start();
String result = task.get(); // 没算完会阻塞等待
System.out.println(result);
}
}
class MyCallable implements Callable<String> {
@Override
public String call() {
return "返回结果"; // 这里放耗时业务
}
}
整条链路是:主线程创建 FutureTask → 子线程执行 call() → 结果存进 FutureTask → 主线程 get() 拿到返回值。看这张时序图,眼睛顺着箭头走一遍就记住了:

这里有两个细节,踩过的都懂:
- 同一个 FutureTask 丢给多个线程跑,结果会被缓存。 第二个线程再 get,直接拿缓存返回,不用重新算——我当初以为每个线程都会重跑一遍 call(),其实只有第一个跑。
- get() 会阻塞。 我第一次用没注意,直接在业务线程里
task.get(),主线程被卡到怀疑人生。

可能有人会问:futureTask.get() 把主线程卡住了怎么办?
三个办法:把 get 挪到所有线程都启动完的最后再调;用带超时的
get(3, TimeUnit.SECONDS),等不到就放弃;或者把拿结果的动作丢给另一个线程去做,主线程继续干别的。总之别让 get 堵住关键路径。
二、CountDownLatch 和 CyclicBarrier:一个关门,一个发车
CountDownLatch 和 CyclicBarrier 都有计数,但方向完全相反——一个是线程往外走、数到零关门,一个是线程往里聚、凑齐数发车。记住这一句,两者区别就不会再混。
CountDownLatch(倒计时门闩):一个初始计数,每有线程调用 countDown() 计数减一,调用 await() 的主线程一直等到计数归零才继续往下走。你可以把它想成放学锁门——等最后一个同学走出教室,阿姨才落锁。
CountDownLatch latch = new CountDownLatch(6);
for (int i = 0; i < 6; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 跑路");
latch.countDown(); // 数量 -1
}, "同学" + i).start();
}
latch.await(); // 计数归零才放行
System.out.println("关门");
跑这段代码,你会看到 6 个同学依次「跑路」,然后主线程才被唤醒,最后一行才打印「关门」——注意「关门」永远是最后出现的:
CyclicBarrier(循环栅栏):N 个线程互相等,全部调用 await() 到达屏障后,才一起放行,同时执行一个可选的集合任务。你可以把它想成集齐七颗龙珠召唤神龙——凑不齐,谁也别想先走。
CyclicBarrier barrier = new CyclicBarrier(7, () -> {
System.out.println("神龙召唤!");
});
for (int i = 1; i <= 7; i++) {
final int temp = i;
new Thread(() -> {
System.out.println("收集到第 " + temp + " 颗龙珠");
try {
barrier.await(); // 等齐 7 个线程
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
两者都是「等够数就放行」,区别就在谁等、放行之后干什么。这张对比图一眼看清:


一句话记住:CountDownLatch 是减法,数完任务执行完,主线程最后收尾;CyclicBarrier 是加法(集合),人齐了大家一起开工。
三、Semaphore:停车场里练限流
Semaphore 是 JUC 里最像限流器的工具——许可数量固定,抢到就干,抢不到就排队,release 一个让一个进来。做并发限流、控制最大线程数时它都是首选。
Semaphore(信号量):维护 N 个许可证,acquire() 拿一个、release() 还一个,没许可证就阻塞等待。你可以把它想成停车场——一共就 2 个车位,车满了,后来的只能停在外面等位。
Semaphore semaphore = new Semaphore(2); // 2 个车位
for (int i = 0; i < 6; i++) {
new Thread(() -> {
try {
semaphore.acquire(); // 抢到车位
System.out.println(Thread.currentThread().getName() + " 进停车场");
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 释放车位
}
}, "车" + i).start();
}
acquire() 拿走一个许可,release() 还回来并唤醒一个等待线程。用在哪?数据库连接池、接口限流、控制并发数,都是它的主场——多个共享资源互斥使用,就是信号量最典型的作用。

四、读写锁:读多写少的「分工授权」
普通的锁一视同仁,读和写互相挤;读写锁把「读」和「写」分开授权——读可以一起读,写必须独占。读写锁就是读多写少场景的最优解。
ReentrantReadWriteLock(读写锁):内部维护一把读锁(共享锁)和一把写锁(独占锁)。读锁可被多个线程同时持有,写锁同一时刻只能一个线程持有。你可以把它想成图书馆——读者可以好多人一起看书,但有人要改书架,其他人就得先让开。
三种组合的共存关系,一张表记住:
| 线程组合 | 能否共存 | 原因 |
|---|---|---|
| 读-读 | ✅ 共存 | 共享锁,谁读都不影响谁 |
| 读-写 | ❌ 互斥 | 写着读着会读到半成品 |
| 写-写 | ❌ 互斥 | 独占锁,同时写必然打架 |
一句话:读读共存、读写互斥、写写互斥。用它包一层缓存,比给整个 HashMap 上普通锁高效得多:
class MyCacheLock {
private final Map<String, Object> map = new HashMap<>();
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void put(String key, Object value) {
lock.writeLock().lock();
try {
map.put(key, value); }
finally {
lock.writeLock().unlock(); }
}
public Object get(String key) {
lock.readLock().lock();
try {
return map.get(key); }
finally {
lock.readLock().unlock(); }
}
}
不加锁的 HashMap 缓存,写的同时另一个线程在读,轻则读到脏数据,重则在并发 put 时 key 值丢失甚至死循环。读写锁就是专门补这个口子的。
五、阻塞队列:四组 API 一张表记牢
阻塞队列四组 API 是最容易被面试问倒的细节——add、offer、put、offer(超时) 四种添加方式,区别全在「满了怎么办」,一张表就能记牢。
BlockingQueue(阻塞队列):线程安全且自带阻塞能力的队列,放不下时 put 会阻塞,取不到时 take 会阻塞。你可以把它想成食堂打饭窗口——窗口空了厨师多做几个等着,窗口满了顾客就在队伍里排队等。
阻塞队列用在哪?最常见的就是多线程协作、线程池里塞任务的地方——生产消费两边节奏对不上,全靠它兜住。它解决的就是生产者与消费者之间的「节奏不同步」。
四组 API 对照表(背这一张就够):
| 方式 | 满了/空了直接报错 | 满了/空了返回特殊值 | 满了/空了阻塞等待 | 满了/空了超时放弃 |
|---|---|---|---|---|
| 添加 | add | offer | put | offer(值, 超时, 单位) |
| 移除 | remove | poll | take | poll(超时, 单位) |
| 探测队首 | element | peek | — | — |
add 满了抛 IllegalStateException,offer 满了返回 false,put 满了一直堵着,offer(超时) 堵几秒就放弃。以 ArrayBlockingQueue 为例,队列容量 3:
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
queue.put("a");
queue.put("b");
queue.put("c");
// queue.put("d"); // 满了,这里会一直阻塞
System.out.println(queue.take()); // 取走一个,腾出位置
System.out.println(queue.take());
System.out.println(queue.take());
这套四组 API 我当年背了 N 遍还是混,直到自己画了上面这张表才算彻底记住。


六、SynchronousQueue:一个不存元素的队列
SynchronousQueue 是全 JUC 里最特别的队列——它没有容量,put 一个元素必须等别人 take 走,否则永远堵着,它实现的是线程间的「手递手」。
SynchronousQueue(同步队列):零容量的阻塞队列,生产者 put 一个元素必须等消费者 take 走,消费者 take 也必须等生产者 put,两边一对一交接。你可以把它想成高空抛接——一个人扔,另一个人必须正好接住,中间没有筐。
BlockingQueue<String> queue = new SynchronousQueue<>();
new Thread(() -> {
// 生产者 T1
try {
queue.put("1");
System.out.println("T1 放 1,没人接,堵住了");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
// 消费者 T2
try {
TimeUnit.SECONDS.sleep(3); // 3 秒后才来接
System.out.println("T2 取到:" + queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
跑起来你会看到:T1 放 1 之后因为没人 take 而一直堵着,直到 3 秒后 T2 把它取走,两边才继续。别的队列靠「存」缓冲,它靠「等」直接交接。

可能有人会问:SynchronousQueue 又不能存元素,到底有什么用?
它的价值恰恰在「不存」——天然实现一对一手递手,没有中间堆积,生产消费严格同步。线程池的 Executors.newCachedThreadPool 内部就用它做任务队列,来一个任务就得有线程来接,没有人接就新建线程,所以它的线程数才会跟着任务数一起涨。
七、写在最后
做 Java 并发编程,与其背类名,不如记场景——CountDownLatch 管「凑齐就放行」,CyclicBarrier 管「凑齐才发车」,Semaphore 管「限流放行」,读写锁管「分工授权」,阻塞队列管「缓冲排队」,SynchronousQueue 管「一对一交接」。想清楚每个类让线程「等什么、怎么放行」,名字自然就记住了。
这几个辅助类串起来,正好是线程池和生产者-消费者模型落地前的完整拼图,下一篇可以展开线程池的七大参数怎么配。说白了就一句话:记场景,不背类名。

我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你学 Java 并发编程时最容易记混的是哪两个类?有没有被 futureTask.get() 的阻塞坑过?