从一次线上接口超时排查,到把JUC、线程状态和线程通信彻底啃透,我大概花了三天。这篇学习日记不打算讲教科书上那些定义,而是把我实际测试过的代码、踩过的坑、以及最后总结出来的记忆框架全部分享出来。如果你也在学JUC,或者准备面试,这篇文章应该能省你不少绕弯的时间。
我用的是JDK 8的环境,IDE就是普通的IntelliJ IDEA,所有示例代码都经过实测。下面的内容不是照搬官方文档,而是我在反复测试中验证过的结论,有些地方甚至和很多博客上写的说法不太一样。
1. 线程状态这块,很多人的理解从根本上是错的
线程状态如果不搞明白,后面学线程通信和JUC工具类会处处碰壁。因为不管是synchronized、Lock还是Condition,它们的底层运作都依赖状态切换。我在学习时先把Java定义的六种线程状态记下来,然后挨个用代码验证。
Java的Thread.State枚举定义了六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这里最大的误区就是很多人把RUNNABLE等同于"正在运行",其实完全不是这么回事。
1.1 到底什么才算RUNNABLE
RUNNABLE的状态,在Java层面表示线程已经就绪,可以被调度执行。但一个RUNNABLE的线程,可能正在等CPU时间片,也可能正在执行IO操作。别以为只有CPU密集型计算才叫RUNNABLE,网络读取、文件读写这些阻塞在操作系统层面的操作,在Java线程状态里依然是RUNNABLE。
这就涉及到一个我很长时间都没绕明白的点:Java的线程状态和操作系统线程状态的映射关系并不完全一致。
Java的BLOCKED、WAITING、TIMED_WAITING对应的是线程在JVM层面的等待状态,而RUNNABLE实际上包含了操作系统里的"就绪态"和"运行态"两种。我看过java.lang.Thread.State的源码注释和HotSpot的实现,确认了这个结论。所以如果你用jstack去查看一个正在做文件IO的线程,它的状态是RUNNABLE,这并不奇怪。
验证这段逻辑时我写了一个简单的程序,让一个线程循环读一个很大的文件,同时用Thread.getState()去观察它的状态。结果确实一直处于RUNNABLE。这个认知关键在后续理解BLOCKED和WAITING的区分时特别有帮助。
1.2 用代码把六种状态逐个拍下来
为了真正搞懂这六种状态,我写了一段测试代码,用不同的锁和等待条件让线程停留在不同状态,然后在主线程中不断输出目标线程的状态。这种朴素的验证方式效果极好。
java复制public class ThreadStateDemo {
private static final Object LOCK = new Object();
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "睡眠中的线程");
t1.start();
Thread.sleep(200);
System.out.println("t1 调用 sleep -> " + t1.getState());
Thread t2 = new Thread(() -> {
synchronized (LOCK) {
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "无限期等待的线程");
t2.start();
Thread.sleep(200);
System.out.println("t2 调用 wait -> " + t2.getState());
Thread t3 = new Thread(() -> {
synchronized (LOCK) {
try {
LOCK.wait(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "限期等待的线程");
t3.start();
Thread.sleep(200);
System.out.println("t3 调用 wait(10000) -> " + t3.getState());
t1.join();
}
}
执行结果如下:
code复制t1 调用 sleep -> TIMED_WAITING
t2 调用 wait -> WAITING
t3 调用 wait(10000) -> TIMED_WAITING
这里有个细节必须注意:Thread.sleep()进入的是TIMED_WAITING,不是WAITING。凡是带超时时间的等待都会进入TIMED_WAITING,包括sleep、wait(timeout)、join(timeout)、LockSupport.parkNanos、LockSupport.parkUntil。
还有一个容易弄混的点:调用sleep不需要持有锁,而wait必须持有对象监视器锁。如果不持有锁直接调用wait,会抛出IllegalMonitorStateException。我刚开始学的时候在这个异常上栽过跟头,后面仔细看了Object.wait的文档才明白,wait本身要操作的是"当前线程在对象监视器上的等待集合",所以必须先通过synchronized拿到监视器。
1.3 BLOCKED状态的触发条件比想象中要窄
BLOCKED只发生在一种情况:线程想进入synchronized代码块或方法时,发现锁被其他线程持有。这时它被阻塞在监视器入口,状态就是BLOCKED。
注意一个很容易被忽略的事实:如果你用的是java.util.concurrent.locks.Lock接口的实现类(比如ReentrantLock),线程等待获取锁时,状态不是BLOCKED而是WAITING。因为ReentrantLock内部用的是AbstractQueuedSynchronizer(AQS)的LockSupport.park机制。这一点在面试里经常被拿来当陷阱题。
我用代码验证了这个现象:两个线程竞争同一把ReentrantLock,第二个线程在等待锁的时候状态显示为WAITING,而不是BLOCKED。
java复制public class LockStateDemo {
private static final ReentrantLock LOCK = new ReentrantLock();
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
LOCK.lock();
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
LOCK.unlock();
}
}, "持锁线程");
t1.start();
Thread.sleep(300);
Thread t2 = new Thread(() -> {
LOCK.lock();
try {
System.out.println("拿到锁了");
} finally {
LOCK.unlock();
}
}, "等待锁线程");
t2.start();
Thread.sleep(300);
System.out.println("t2 等待 ReentrantLock -> " + t2.getState());
}
}
输出结果:
code复制t2 等待 ReentrantLock -> WAITING
这个区别很关键。synchronized是JVM层面的监视器锁,直接映射到操作系统的pthread_mutex,所以等待状态表现是BLOCKED。而ReentrantLock的阻塞则是通过LockSupport.park()去"禁用"线程,状态自然走进了WAITING。理解了这个,后续看AQS的源码就顺滑很多。
1.4 一张表说清六种状态的迁移路径
我后来把六种状态的转换条件整理成了一张表,学习的时候天天对照着看,帮助很大。
| 当前状态 | 触发条件 | 到达状态 |
|---|---|---|
| NEW | thread.start() |
RUNNABLE |
| RUNNABLE | 线程执行完毕或异常退出 | TERMINATED |
| RUNNABLE | 线程试图进入synchronized块但锁被占用 | BLOCKED |
| RUNNABLE | 调用Object.wait() / LockSupport.park() |
WAITING |
| RUNNABLE | 调用sleep()、wait(timeout)、join(timeout)、parkNanos() |
TIMED_WAITING |
| WAITING | 被notify() / notifyAll()唤醒 / LockSupport.unpark() |
RUNNABLE |
| TIMED_WAITING | 超时自动唤醒 / 被唤醒 | RUNNABLE |
| BLOCKED | 锁被释放后竞争成功 | RUNNABLE |
| WAITING | 等待带超时的过程因中断而退出 | RUNNABLE |
这张表最大的价值在于让我意识到,线程状态的转换方向绝大多数都是指向RUNNABLE,所以面试时问"WAITING状态的线程如何回到运行态",本质上就是在考你对唤醒机制的掌握程度。而这个唤醒机制,恰好就是线程通信的核心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程通信的本质:等待与通知的博弈
学完状态转换后,线程通信就变得自然了。所谓线程通信,核心就是"一个线程改变条件,另一个线程被唤醒"。
Java里的线程通信手段分几个层次:最底层是Object类的wait / notify / notifyAll;然后是基于此的synchronized协作;再到JUC包下基于Lock和Condition的通信,以及更高级的LockSupport工具和BlockingQueue的直接解耦。
我的学习路径是:先用synchronized + wait/notify写生产者消费者,把原理吃透;再从JUC的角度用Condition重写一遍,感知两者差异;最后用LockSupport验证park/unpark的独特之处。
2.1 经典的生产者消费者用wait/notify实现
生产者和消费者是最经典的线程通信场景。生产者往队列里放数据,满了就等待;消费者从队列取数据,空了就等待。下面这个示例是我最初学线程通信时写的标准写法。
java复制public class ProducerConsumer {
private final LinkedList<Integer> queue = new LinkedList<>();
private final int capacity = 5;
public synchronized void produce(int value) throws InterruptedException {
while (queue.size() == capacity) {
wait();
}
queue.add(value);
System.out.println("生产: " + value + ", 队列大小: " + queue.size());
notifyAll();
}
public synchronized int consume() throws InterruptedException {
while (queue.isEmpty()) {
wait();
}
int value = queue.removeFirst();
System.out.println("消费: " + value + ", 队列剩余: " + queue.size());
notifyAll();
return value;
}
public static void main(String[] args) {
ProducerConsumer pc = new ProducerConsumer();
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try {
pc.consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
for (int i = 0; i < 10; i++) {
int v = i;
new Thread(() -> {
try {
pc.produce(v);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
}
这里有两个细节,我必须强调。
第一,条件判断一定要用while而不是if。这就是所谓的"虚假唤醒"问题。线程可能在没有被notify的情况下醒来,也可能在等待多个条件共享同一把锁时被错误唤醒。如果用if判断条件,线程被唤醒后不会重新检查条件,很可能在队列仍然满或空的情况下继续执行,造成数据错误。用while的话,线程每次唤醒都会重新检查条件,条件不满足就继续wait。这个教训我从JDK官方文档里看到过,后来自己也踩过,印象深刻。
第二,notifyAll通常比notify安全。比如上面代码,如果生产者生产后调用的是notify,它唤醒的可能是一个生产者线程,这个生产者发现队列满后继续wait,而消费者永远没有被唤醒,最终可能陷入死锁。notifyAll牺牲了一点性能,换来了逻辑上的绝对安全。
2.2 Condition与wait/notify到底差在哪
学完synchronized方案后,我换成了ReentrantLock + Condition重写了一遍。差不多的场景,却有了"多路等待"的能力。
wait/notify面临的一个尴尬是:所有等待的线程都在同一个等待集合里,你无法指定只唤醒某一类线程。生产者消费者场景中,如果有一类线程在"等待队列非空"的条件,另一类在"等待队列非满"的条件,用notifyAll会把所有线程全唤醒,然后让它们各自去检查条件。
而Condition通过lock.newCondition()可以创建多个条件队列,分别对应不同的等待条件。比如生产者等待"非满"条件,消费者等待"非空"条件,用notify时就能精准唤醒对应类型,避免无效竞争。
java复制public class ConditionDemo {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final LinkedList<Integer> queue = new LinkedList<>();
private final int capacity = 5;
public void produce(int value) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.add(value);
System.out.println("生产: " + value);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public int consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
int value = queue.removeFirst();
System.out.println("消费: " + value);
notFull.signal();
return value;
} finally {
lock.unlock();
}
}
}
这里有几个关键点:
await和signal必须先持有lock。这和wait/notify必须先持有synchronized锁的逻辑完全一致。await()释放锁的方式比较特殊:线程进入等待后会释放持有的lock,等被唤醒重新拿到锁后才会从await返回。这也意味着await之后的代码一定是在持有锁的状态下执行的。lock.lock()和unlock()一定要配对,且解锁操作要放在finally里,防止异常导致锁无法释放。如果锁不释放,线程就永远卡死,死锁就是这么来的。
我用jstack验证过Condition.await()状态下线程的表现:它会让线程进入WAITING状态,并且线程会释放锁,让其他线程有机会获取锁。这是await和Thread.sleep最本质的区别——sleep不会释放任何锁,await会释放锁。
2.3 LockSupport的park/unpark与wait/notify的三大不同
LockSupport是我学到JUC时遇到的又一个关键工具,但它在整个线程通信体系里讨论得最少。它的核心是park()和unpark(Thread)方法,作用类似于阻塞和唤醒线程,但和wait/notify有明确的差异。
差异一:不需要持有锁。LockSupport.park()不需要在synchronized块里调用,也不需要ReentrantLock加锁。它的阻塞逻辑基于Unsafe类对线程的直接操作,底层通过一个permit(许可)机制实现。默认情况下线程没有permit,调用park()时线程会阻塞;而unpark(Thread)会给指定线程发放一个permit,如果这个线程正在阻塞,则被唤醒。
差异二:唤醒不需要"同时刻"。wait/notify有个很别扭的地方:如果先调notify再调wait,那么wait的线程永远不会被唤醒。我最早写多线程代码时就撞上过这个问题。但LockSupport.unpark可以在park之前调用,因为unpark会先设置permit,等线程真正执行park时发现permit已经有了,就不会阻塞。
java复制public class LockSupportDemo {
public static void main(String[] args) throws Exception {
Thread t = new Thread(() -> {
System.out.println("子线程准备park");
LockSupport.park();
System.out.println("子线程被唤醒");
});
t.start();
Thread.sleep(1000);
System.out.println("主线程先执行unpark");
LockSupport.unpark(t);
}
}
输出结果:
code复制子线程准备park
主线程先执行unpark
子线程被唤醒
unpark即使先执行了,也不会丢。这是wait/notify做不到的。
差异三:唤醒有明确目标。notify是唤醒一个等待线程,但你不知道是哪一个;notifyAll唤醒全部,又显得粗暴。LockSupport.unpark(thread)是精确到线程级别的唤醒,这在部分场景下更可控。
不过LockSupport也有它的特性:park不区分是"等待在哪个锁上",所以没有Condition那样的多条件队列。它更像一个底层的线程阻塞原语。在实际开发中,LockSupport是被AQS使用的核心机制,框架内部实现依赖它,但应用层很少直接使用。
3. JUC并发工具类,逐一亲手跑了一遍
线程通信的底层逻辑清楚以后,JUC的工具类就顺手了很多。JUC指的是java.util.concurrent包,它是Java并发编程的事实标准包,里面的大部分同步工具都建立在AQS之上。我的策略是每个工具类都用一个最小化示例去验证它的行为,不追求覆盖所有API,而是抓住核心应用场景。
3.1 CountDownLatch:一次性的门闩
CountDownLatch是最容易上手的工具之一:一个线程等待多个线程执行完毕后才继续。它的用法只有两个关键操作,countDown()和await()。
java复制public class CountDownLatchDemo {
public static void main(String[] args) throws Exception {
CountDownLatch latch = new CountDownLatch(3);
for (int i = 1; i <= 3; i++) {
int taskId = i;
new Thread(() -> {
try {
Thread.sleep(taskId * 300L);
System.out.println("任务" + taskId + "执行完毕");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown();
}
}).start();
}
System.out.println("主线程等待所有任务完成");
latch.await();
System.out.println("所有任务完成,主线程继续");
}
}
await()的语义是阻塞当前线程,直到计数器减到0。计数器初始化值不能为负数,且不可重置。这就是它和CyclicBarrier最大的区别。
我踩过的一个坑是忘了把countDown()放在finally里。只要任务线程抛出异常,计数器永远减不到0,主线程就会一直阻塞,程序卡死。所以在使用CountDownLatch时,countDown务必放进finally块。
3.2 CyclicBarrier:可以重复使用的栅栏
CyclicBarrier会让一组线程互相等待,直到所有线程都到达屏障点后才继续执行。它可以在所有线程到达后触发一个额外的动作,也可以重复使用。
java复制public class CyclicBarrierDemo {
public static void main(String[] args) {
int threadCount = 3;
CyclicBarrier barrier = new CyclicBarrier(threadCount, () ->
System.out.println("所有线程已到达屏障,执行汇总操作")
);
for (int i = 1; i <= threadCount; i++) {
int taskId = i;
new Thread(() -> {
try {
Thread.sleep(taskId * 200L);
System.out.println("任务" + taskId + "到达屏障");
barrier.await();
System.out.println("任务" + taskId + "越过屏障");
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
CyclicBarrier和CountDownLatch最大的不同在"循环"二字:CyclicBarrier的计数器在所有线程到达后会被重置,可以重复使用。而CountDownLatch是一锤子买卖。从名字上理解:CountDownLatch像个倒计时门闩,减到零就永远打开;CyclicBarrier像一堵栅栏,等所有线程凑齐后就放行,栅栏再次立起,等待下一波线程。
应用场景上,CountDownLatch适合"一个线程等N个线程完成某事",比如主线程等所有服务初始化完成;CyclicBarrier适合"N个线程互相等待,一起出发",比如多线程计算任务,等所有子任务到达某个阶段后同时进入下一阶段。
3.3 Semaphore:控制并发访问的数量
Semaphore信号量,本质上就是一个计数器加上锁的机制。它控制的是"同时能有多少个线程访问某个资源"。
java复制public class SemaphoreDemo {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(2);
for (int i = 1; i <= 5; i++) {
int taskId = i;
new Thread(() -> {
try {
semaphore.acquire();
System.out.println("线程" + taskId + "获取许可,开始工作");
Thread.sleep(1000);
System.out.println("线程" + taskId + "释放许可");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}).start();
}
}
}
输出结果中,同一时刻最多只有两个线程打印"开始工作"。
Semaphore的用途很典型:像数据库连接池、接口限流这类场景。如果有10个数据库连接,但可能有50个线程需要获取连接,用Semaphore(10)就可以限制最多10个线程同时持有连接。
它还支持公平模式的设置:new Semaphore(2, true)可以开启公平模式,让等待时间更长的线程优先获取许可。不过公平模式性能略低,非极端场景下不太必要。
3.4 ReentrantLock与ReadWriteLock的实用细节
ReentrantLock是我日常用得最多的锁,它的优势在于可中断获取锁、可超时获取锁、多条件队列、公平锁支持等。synchronized虽然有简化代码的优势,但在精细控制场景下还是不够。
java复制public class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void doWork() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始执行");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
ReentrantLock的"可重入"意味着同一个线程可以多次获取同一把锁而不会死锁。它的内部通过"AQS的状态值 + 持有线程记录"实现重入计数。每次获取成功,状态加1,每次释放状态减1,直到状态归零,锁才真正被释放。
而ReadWriteLock则适合读多写少的场景。读锁是共享锁,多个线程可以同时持有;写锁是排他锁,写的时候其他读写都不能进行。这个模型很贴近缓存体系的现实需求:缓存读频繁,偶尔更新。
我用ReentrantReadWriteLock实现过一个简单的缓存工具,在读锁保护下并发读取,在写锁保护下更新数据,实测并发性能比纯ReentrantLock锁整个类要好一个量级。不过要注意,如果读线程特别多而写线程极稀少,写锁可能一直拿不到,出现"写饥饿"问题。ReentrantReadWriteLock默认是非公平的,写线程尝试获取锁时会插队,一定程度上缓解了这个问题。
3.5 Atomic类与synchronized的取舍
java.util.concurrent.atomic包下的原子类,比如AtomicInteger、AtomicLong、AtomicReference,是基于CAS(Compare And Swap,比较并交换)操作实现的。
CAS操作有三个操作数:内存地址、期望值、新值。只有当内存地址当前的值等于期望值时,才把新值写入内存地址,否则就返回当前值并重新尝试。这个操作由CPU指令级别保证原子性,所以不需要加锁。
java复制public class AtomicDemo {
private static final AtomicInteger counter = new AtomicInteger(0);
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
counter.incrementAndGet();
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("原子计数器结果: " + counter.get());
}
}
输出稳定为100000,没有任何线程安全问题。
原子类与synchronized的取舍在于:只操作用一个变量时,原子类代码简洁、无锁、性能高;但涉及多个变量保持一致性、复合操作(比如先检查后执行)时,仍然需要锁来保证逻辑原子性。CAS也有自己的问题——ABA问题。如果一个值从A变成B又变回A,CAS无法察觉中间过程发生了变化,AtomicStampedReference可以通过版本号解决这个问题,但日常业务中遇到ABA的场景不多。
4. 学习过程中反复测试得出的重要结论
这一章是我反复写代码验证后得到的几个比较重要的结论,有些和直觉相反,有些直接来自真实的生产问题排查。如果你时间有限,优先看这一部分。
4.1 sleep和wait的差别,不只是释放不释放锁
很多教程会把sleep和wait的区别简单概括为"一个释放锁,一个不释放锁",如果仅限于此,后面会有大麻烦。
wait除了释放锁之外,还有一层保证:它让出锁的同时,会把当前线程放入该对象的等待集合。只有当另一个线程在同一个对象上调用notify或notifyAll,或者等待超时、被中断,线程才会重新参与锁竞争。
sleep除了不释放锁之外,还有一个行为特性:它在睡眠期间完全无法响应"唤醒"请求,只有在睡眠时间结束后,才会处理中断信号。而wait可以通过interrupt()立即抛出InterruptedException退出等待状态。
基于这个区别,在设计线程协作时:如果只是想让当前线程暂停一段时间,无协作需求,用sleep;如果当前线程需要等待某个条件成立,并且希望其他线程能精准唤醒自己,用wait/notify或Condition。
4.2 join的底层其实就是wait机制
Thread.join()的语义是当前线程等待子线程终止。这个方法内部到底怎么实现的?我看了源码后发现,join的实现其实就是wait。
java复制public final synchronized void join(long millis) throws InterruptedException {
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - System.currentTimeMillis();
if (delay <= 0) {
break;
}
wait(delay);
}
}
}
join在等待期间会不断在循环里调用wait,而子线程终止时,JVM会在这个子线程的监视器上执行notifyAll,唤醒等待的线程。这意味着join依赖的是"对象监视器"机制,而不是什么特殊的上层封装。
这个源码让我对wait/notify这个最底层的通信原语的理解又深了一层:连join这么常用的方法,归根到底都是wait/notify的语法糖。线程通信的底层哲学绕来绕去就这一套。
4.3 interrupt并不强制中断线程
Thread.interrupt()这个名字有很强的迷惑性。它不是"强制中断线程",而是"设置中断标志位"。线程是否响应中断,完全取决于线程自己。
java复制public class InterruptDemo {
public static void main(String[] args) throws Exception {
Thread t = new Thread(() -> {
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println("检测到中断,退出循环");
break;
}
}
});
t.start();
Thread.sleep(100);
t.interrupt();
}
}
如果线程处于WAITING或TIMED_WAITING状态(比如wait、sleep、join),收到中断信号后会抛出InterruptedException并清除中断标志位。如果线程处于RUNNABLE状态,它不会自动停止,必须由线程自己检查isInterrupted()标志。
这解释了为什么很多阻塞方法都要声明throws InterruptedException。调用方处理中断有两种风格:要么恢复中断标志(Thread.currentThread().interrupt()),要么直接向上抛出异常。实际编码时,如果不打算处理中断,最忌讳的是把异常吞掉——这会让上层完全失去对线程生命周期的控制。
4.4 BlockingQueue可能是线程通信中最容易被低估的工具
学完wait/notify和Condition之后,我意识到在大多数业务代码中,我们根本不需要手动实现这么多等待通知逻辑——BlockingQueue已经把这些细节封装好了。
ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue这些阻塞队列,其内部就已经实现了"队列满则等待"和"队列空则等待"的逻辑。用它们来实现生产者消费者,连锁都不用自己写。
java复制public class BlockingQueueDemo {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
Thread producer = new Thread(() -> {
for (int i = 1; i <= 10; i++) {
try {
queue.put(i);
System.out.println("生产: " + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread consumer = new Thread(() -> {
while (true) {
try {
Integer value = queue.take();
System.out.println("消费: " + value);
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
consumer.start();
}
}
put方法在队列满时会阻塞,take方法在队列空时会阻塞。阻塞队列本身已经处理好了锁竞争与线程通信。业务代码里如果需要生产者消费者模型,优先用BlockingQueue,而不是自己写synchronized + wait/notify。
这一点在使用线程池的时候也有体现。ThreadPoolExecutor内部就用了一个BlockingQueue作为任务队列,核心线程数满了以后,新任务会被放入这个队列等待处理。所以理解BlockingQueue的行为,对理解线程池的任务排队机制至关重要。
5. 学习JUC时实际遇到的坑,以及对应的排查方法
记录几个我在学习和测试过程中实际踩过的坑。这些问题如果没亲自动手跑一遍代码,只看文档很难感受到有多痛。
5.1 使用Condition时忘记在finally中释放锁
刚开始写Condition代码时,我把lock.unlock()放在了业务代码后,忘了放进finally块。有一次模拟消费者从队列取数据时抛出了异常,锁就没有被释放。我的测试程序从此线程全部卡在等待锁上,用jstack一查,发现大量线程处于WAITING状态,而锁的持有线程已经退出了。
排查后得出的结论很简单:一旦lock()方法调用成功,就必须保证unlock()会被执行。 唯一可靠的位置就是finally块。
java复制lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
不仅是ReentrantLock,ReadWriteLock的读锁和写锁也必须遵守这个规则。这个习惯如果从第一天就养成,后面会少踩很多坑。
5.2 错误地以为notify一定会唤醒等待线程
Object.notify()的官方文档说,它会随机选择一个在线程对象的等待集合中等待的线程唤醒。这个"随机"意味着它可能唤醒任何一个线程,而不是"最容易唤醒的"或"最先等待的"线程。
我有一次写一个简单的"两个生产者一个消费者"模型,用了一个notify。结果有时候生产者生产完数据后,notify恰好唤醒的是另一个生产者,而非消费者,而这个生产者检查条件后发现队列还是满的,又重新进入等待。消费者线程一直没被唤醒,程序进入死锁状态。
这个现象让我意识到,notify使用的场景实际上非常受限。如果条件只有一个、等待线程类型只有一种,notify是可以用的。但一旦条件多了或者线程类型多了,用notifyAll是更稳妥的选择,或者干脆用Condition做定向唤醒。
5.3 用jstack排查一个诡异的线程卡顿问题
有一段时间我在测试一个线程池程序时,发现任务执行速度突然变得极慢,但没有任何异常日志。我用jstack抓取线程堆栈,发现大量线程处于TIMED_WAITING状态,而且全部卡在同一个地方。
排查过程是这样的:
- 先抓线程快照,看到大量线程停留在
Thread.sleep上。初步怀疑是任务处理耗时太长。 - 查看线程池配置,核心线程数和队列容量设置都比较小,任务积压在队列里。
- 进一步看堆栈,发现这些线程全部阻塞在一个
BlockingQueue.take()的调用上。 - 最后定位到:生产者线程因为某个数据库连接问题停止了生产,而消费者线程在空队列上等待,陷入了"正常但无进展"的状态。
这个例子让我学会了jstack的基本用法:排查线程卡顿问题时,直接查看线程状态分布,看是否有大量线程聚集在WAITING或BLOCKED状态,然后定位到具体的代码行,就能快速找到问题点。
排查工具上我最常用的两个命令是:
bash复制jps
jstack <pid>
jps用来查看Java进程ID,jstack用来导出线程快照。有时候我也会用jstack连续抓三次快照,间隔几秒钟,对比线程状态变化,这样能更准确地判断线程到底是"短暂等待"还是"真正卡死"。
5.4 关于线程通信被忽略但很重要的一点:可见性
线程通信的另一个基础是内存可见性。两个线程通过wait/notify通信时,如果共享变量没有正确的同步机制,可能出现一个线程修改了数据,另一个线程却看不到的情况。这是因为CPU缓存和指令重排序会导致数据在不同线程视角下不一致。
synchronized、Lock、volatile是Java里三个保证可见性的核心手段。synchronized和Lock除了互斥,还具备内存语义上的"释放锁时刷新共享变量到主内存,获取锁时重新读取共享变量"的行为。volatile则保证单个变量的可见性和一定程度的顺序性,但不保证原子性。
我在学习过程中专门写了一个没有同步机制的多线程计数器,结果累计值经常小于预期,这是典型的可见性/原子性问题叠加的结果。解决这个问题后,我才真正理解为什么JUC工具类内部都要通过AQS的volatile int state来保证状态可见——这是整个并发框架的基石。
学习完JUC、线程状态和线程通信之后,我最大的感受是这三个主题是环环相扣的,汉语里的"底层机制"就是这么一层层搭起来的。线程状态是结果,线程通信是过程,JUC工具类则是更上层的封装。理解Thread的六种状态切换,才能理解wait/notify的意义;理解wait/notify的精确定位,才能读懂Condition和LockSupport的设计意图;理解了这些底层机制,JUC工具类的源码读起来就不会觉得是魔法了。
最后再分享一个学习小技巧。我复习时会把每个工具类都写成一个不到50行的小Demo,然后故意制造一个错误场景(比如忘记释放锁、用if而不使用while判断条件),再用jstack或者jvisualvm观察线程状态的变化。这种"故意写错再观察"的方式,比只看正确写法要记得牢得多。很多并发问题不是看文档能看出来的,一定要亲自动手触发一次,才能形成真正的肌肉记忆。
