零基础学Java的人,十有八九会在多线程这块被绊住。尤其是"等待唤醒机制"和"线程池"这两个点,一个牵扯到线程之间的协作通信,一个牵扯到并发任务的工程化管理,理解了它们,你才算真正摸到了并发编程的门槛。这篇文章我打算把这两块掰开揉碎了讲清楚——先搞懂wait/notify这套老前辈的协作机制,再讲清楚线程池这套现代Java并发编程的标配工具,中间穿插面试常考的原理细节和我实际跑代码踩过的坑。
1. 先搞懂一件事:线程为什么要"等待"和"唤醒"
如果你刚学多线程,你可能会觉得:线程不就是一个独立干活的工人吗?各干各的不就完了,为什么要等来等去?
但现实中线程之间经常需要"配合"。最简单的例子:一个线程往容器里放数据,一个线程从容器里取数据。如果容器满了,放数据的线程就得停下来等;如果容器空了,取数据的线程也得停下来等。谁负责通知它们"现在可以继续了"?就是等待唤醒机制。
Java里这套机制主要由三个方法组成:wait()、notify()、notifyAll()。它们不是Thread类的方法,而是Object类的方法。为什么放在Object里?因为任何一个Java对象都可以作为锁对象,而wait和notify操作的前提就是"持有某个对象的锁"。这个设计本质上是在告诉所有新手:线程通信必须建立在"锁"的基础上,不能天马行空地乱等乱叫。
打个比方:你去办事大厅排队,叫号系统就是某个对象。你取完号(拿到锁)发现还得再等一会儿,于是你调用wait(),相当于把号码牌交了、去椅子上坐着(释放锁)。等窗口喊号的时候——也就是另一个线程调用notify()或notifyAll()——你和其他等待的人重新回到队伍里抢着办(重新竞争锁),抢到的那个线程从wait()方法返回,接着往下执行。
这个过程里最关键的一个认知是:wait()执行的时候,线程是会把锁释放掉的。这一点和sleep()截然不同,sleep就算睡十秒,锁也一直攥在自己手里不放开。我见过不少刚入门的同学把wait写成了一把死等,程序直接卡死,就是因为没理解"释放锁"这个动作。
那notify和notifyAll的区别在哪儿呢?简单说:
notify():唤醒一个正在等待该对象锁的线程,具体唤醒哪一个由JVM决定,你控制不了。notifyAll():唤醒所有正在等待该对象锁的线程,它们一起去抢锁,谁抢到谁先执行。
实操中如果你不确定该用哪个,优先用notifyAll()。倒不是说它效率高,而是它能避免"唤醒了一个线程但这个线程不满足条件又睡回去,结果其他该被唤醒的线程一直没被叫醒"这种恶心问题。后面讲到生产者消费者的时候你就能感受到了。
2. 为什么必须在synchronized块里调用wait/notify:先补上这块"底层认知"
很多初学者记了一堆规则,但不知道为什么wait和notify必须放在synchronized代码块或synchronized方法里。不这么写的话,代码一运行就会抛出IllegalMonitorStateException。
原因需要从"锁的归属权"说起。JVM内部维护了一把对象监视器锁(Monitor),某个线程想调用某个对象的wait(),它必须先持有这个对象的Monitor。否则JVM根本不知道你是在哪个对象上等,也不确定你是否具备操作这把锁的资格。这就好比你去银行柜台说"我要挂失",但你手里连身份证(锁)都没拿出来,柜台不可能给你办任何业务。
代码上长这样:
synchronized (lock) { while (条件不满足) { lock.wait(); } // 条件满足,做正事 }另一个同步的方法是wait(long timeout),带超时时间的版本。这个在日常开发里非常实用,比如从队列取数据,等1秒还没等到就先返回,避免线程无限期挂在那里。很多真实项目里的消息拉取、缓存刷新的逻辑都会用到它,因为它给了线程一条"被迫苏醒"的退路。
这里顺便说一个面试高频对比题:wait()和sleep()的区别。我建议你总结的时候抓住三条核心:
- wait必须配合同步锁使用,sleep不需要。
- wait会释放锁,sleep不会。
- wait是Object方法(基于对象锁),sleep是Thread的静态方法。
理解了这些,你不光会写等待唤醒的代码,面试的时候也能直接说出个所以然来,而不是靠死记硬背。
3. 说不定你没注意到的"坑":为什么官方强烈建议用while而不是if判断条件
我见过很多初学版生产者消费者代码,大家习惯写成这样:
synchronized (lock) { if (list.isEmpty()) { lock.wait(); } return list.remove(0); }看着没问题,但细心一点你就会发现:线程被唤醒之后,从wait()返回,会接着执行if花括号后面的代码,它不会重新检查list是否为空。如果此时list又被别的线程取空了,那它取到的就是null,甚至抛出数组越界。
所以正确的写法必须用while:
synchronized (lock) { while (list.isEmpty()) { lock.wait(); } return list.remove(0); }唤醒之后先回到while条件再判断一次,如果条件依然不满足,就继续wait()。这就是所谓的"循环等待"。
为什么必须这样?除了逻辑上的严谨性之外,还有一个非常现实的"虚假唤醒"问题。所谓虚假唤醒,指的是线程可能在没有收到任何notify信号的情况下被唤醒,这在底层硬件和操作系统的某些场景下确实可能发生。JDK官方文档里也白纸黑字写了:wait方法应该始终在循环中使用。
这段不是我编的,而是开发规范层面的硬性要求。你用while写,就算遇到虚假唤醒,线程回到条件判断时发现不满足,自动回去继续等,程序不会崩溃。你要是用if写,一次虚假唤醒直接让程序逻辑错乱。
所以从今天起,写等待唤醒代码一律用while。这属于"面试必问、工作必写"的细节,早养成习惯不吃亏。
4. 从零手写一个生产者-消费者模型:让等待唤醒落地
光讲概念,学完就忘,咱们直接上一个经典的生产者消费者代码。我用一个环形缓冲区来做共享数据区,缓冲区容量是10:
class Buffer { private final int[] items = new int[10]; private int count; // 当前元素个数 private int putIndex; // 下一个放元素的位置 private int takeIndex; // 下一个取元素的位置 // 生产者调用:放数据 public synchronized void put(int value) throws InterruptedException { while (count == items.length) { // 缓冲区满了,生产者必须等待 wait(); } items[putIndex] = value; putIndex = (putIndex + 1) % items.length; count++; // 唤醒可能正在等待的消费者 notifyAll(); } // 消费者调用:取数据 public synchronized int take() throws InterruptedException { while (count == 0) { // 缓冲区空了,消费者必须等待 wait(); } int value = items[takeIndex]; takeIndex = (takeIndex + 1) % items.length; count--; // 唤醒可能正在等待的生产者 notifyAll(); return value; } }然后写两个线程来测试:
public class ProducerConsumerDemo { public static void main(String[] args) { Buffer buffer = new Buffer(); // 一个生产者线程,不停往里放数据 Thread producer = new Thread(() -> { int value = 0; while (true) { try { buffer.put(value); System.out.println("生产了: " + value); value++; Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }, "生产者"); // 一个消费者线程,不停从里面取数据 Thread consumer = new Thread(() -> { while (true) { try { int value = buffer.take(); System.out.println("消费了: " + value); Thread.sleep(150); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }, "消费者"); producer.start(); consumer.start(); } }注意我在put和take方法里都用synchronized修饰了整个方法,这样天然持有this这把锁,wait和notifyAll都针对this对象,逻辑能对上。
这个demo跑起来之后,你会看到生产和消费交替进行,缓冲区永远不会出现"生产到溢出"或"消费到负数"的情况。你可以在put和take里各加一个计数器,记录生产总数和消费总数,跑一段时间你会发现生产的总比消费的多,因为生产速度(100ms一个)比消费速度(150ms一个)快。但这不影响正确性,因为缓冲区满的时候生产者自己会等在while循环里。
这个例子很有价值,我建议你别直接复制,自己敲一遍,然后把两个Thread.sleep的时间换一换,比如生产变成200ms,消费变成100ms,你会发现消费者经常等着。再把缓冲区容量从10改成1,你又能看到生产者和消费者像接力一样交替执行。每一种变化都能帮你加深对"何时等待、何时唤醒"的感觉。
5. 从"手动管理线程"到"线程池":从一段痛苦的经历说起
等待唤醒是"线程内部的协作",但线程本身从哪里来、怎么管理,就是另一个大问题了。我早期写一个并发下载模块的时候,图省事,直接for循环new Thread:
for (int i = 0; i < fileCount; i++) { new Thread(() -> downloadFile(urls.get(i))).start(); }文件少还好,文件一多就出事了。每个线程创建和销毁都要开销,一百个任务就要创建一百个线程,线程之间的上下文切换把CPU都快耗光了,甚至系统直接抛OutOfMemoryError: unable to create new native thread。
从那时候我就明白:生产环境里绝对不能手动创建裸线程,必须用线程池。线程池本质上是一个"线程复用管理器",它的作用可以类比成银行里的窗口柜员——不需要为每个客户都临时雇一个柜员,只需要雇固定数量的柜员,客户多就排队等着,柜员闲着就接待下一个。这样线程的创建和销毁成本被分摊了,系统的并发数也能被控制在合理范围内。
Java里最核心的线程池是ThreadPoolExecutor,它是一个扩展性很强的类。Executors工具类里的newFixedThreadPool、newCachedThreadPool这些方法,底层都是通过创建ThreadPoolExecutor实例来实现的。
面试题里经常问的"线程池的参数有哪些",指的其实就是ThreadPoolExecutor构造方法里的7个参数。这7个参数是:核心线程数(corePoolSize)、最大线程数(maximumPoolSize)、空闲存活时间(keepAliveTime)、存活时间单位(unit)、阻塞队列(workQueue)、线程工厂(threadFactory)、拒绝策略(handler)。逐个弄明白它们的含义,你才能真正用好线程池。
6. ThreadPoolExecutor的核心参数逐个拆解:面试和实战都靠它们
每个参数背后的逻辑都很值得写一写。我先说任务提交到线程池之后,线程数增长的完整流程,这个流程理解了,7个参数就相当于自动记住了:
- 提交任务时,如果当前线程数小于
corePoolSize,那么不管有没有空闲线程,都创建新线程来执行这个任务。 - 如果当前线程数已经达到
corePoolSize,任务会被丢进workQueue中排队。 - 如果队列已经满了,并且当前线程数小于
maximumPoolSize,那么线程池会继续创建新线程(这些线程叫"非核心线程")来执行任务。 - 如果队列满了且线程数已经达到
maximumPoolSize,线程池就该执行拒绝策略了。
这个流程用一句话概括就是:优先加人,再排队,队满了再加人,人加满了就拒绝。很多人把顺序记反,以为队列满了才加线程、还没到corePoolSize就先排队——实际上只要线程数没到corePoolSize,新任务来了直接创建线程跑,根本不会进队列。这一点一定要记牢,面试官很喜欢挖这个顺序。
各参数细节我再补充一下:
| 参数 | 含义 | 注意事项 |
|---|---|---|
| corePoolSize | 核心线程数 | 默认情况下核心线程创建后不会被回收,哪怕空闲 |
| maximumPoolSize | 最大线程数 | 包括核心线程+非核心线程的总上限 |
| keepAliveTime | 非核心线程空闲存活时间 | 超过这个时间非核心线程会被回收 |
| workQueue | 任务阻塞队列 | 缓冲排队任务,不同队列行为差异极大 |
| threadFactory | 线程工厂 | 统一设置线程名、是否守护线程等 |
| handler | 拒绝策略 | 队列满且线程满时的处理方式 |
线程工厂这个参数经常被忽略,但实际项目里很重要。如果不设置,线程池创建的线程名字都是pool-1-thread-1,出问题的时候日志里根本看不出是哪块业务。所以千万要自定义线程名:
ThreadFactory factory = new ThreadFactory() { private final AtomicInteger count = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { return new Thread(r, "download-worker-" + count.getAndIncrement()); } };看到日志里出现download-worker-3抛异常,你一眼就知道是下载模块的线程出了问题,排查效率翻倍。
拒绝策略有四种,记牢它们的行为:
AbortPolicy(默认):直接抛出RejectedExecutionException,这是最常用的,因为你能第一时间发现任务被丢了。CallerRunsPolicy:谁提交的任务,谁来执行这个任务。比如主线程提交,那线程池满了,主线程自己跑来执行,等于把压力回推给调用方。DiscardPolicy:直接丢弃新任务,不抛异常。DiscardOldestPolicy:丢弃队列中最老的任务,把新的加进来。
实操里我一般用AbortPolicy,然后配合自定义的异常处理,把被拒绝的任务做降级处理或补偿重试。你要是直接选DiscardPolicy,哪天任务被静默丢掉你都不知道,线上出Bug你只能干瞪眼。
7. 线程池的阻塞队列选择:三种队列,三种命运
关于阻塞队列,我见过太多人只是背名字,到了真正选型的时候一脸懵。线程池里可选的队列主要有三个:LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue。它们的差别直接决定了线程池的"排队性格"。
| 队列 | 特性 | 默认容量 | 典型场景 |
|---|---|---|---|
| LinkedBlockingQueue | 链表实现,支持有界/无界 | Integer.MAX_VALUE | Executors.newFixedThreadPool默认用无界版本 |
| ArrayBlockingQueue | 数组实现,必须有界 | 必须指定 | 需要精确控制排队容量的场景 |
| SynchronousQueue | 不存储元素,直接移交 | 0 | Executors.newCachedThreadPool默认用这个 |
先说LinkedBlockingQueue。Executors的newFixedThreadPool底层用的就是无界版本,也就是队列容量为Integer.MAX_VALUE。无界带来的问题是:当任务产生速度远超处理速度时,任务全堆在队列里,内存水涨船高,终究会OOM。所以在生产环境里,我不推荐直接无脑用newFixedThreadPool,除非你能确保任务量绝对可控。
ArrayBlockingQueue是有界队列,创建的时候必须指定容量,比如new ArrayBlockingQueue<>(100)。它逼着你思考"最多允许排队多少个任务",一旦队列满了,就启动非核心线程或触发拒绝策略。这是正规军打法。我自己写线程池的时候几乎都用ArrayBlockingQueue,因为它让系统行为变得可预测。
SynchronousQueue比较特殊,它完全不存东西。生产者在往队列放任务的时候,必须等一个消费者线程同时来取,不然就阻塞。放到线程池的场景里,它意味着"任务不会被排队,一定立即被某个线程拿走去执行"。newCachedThreadPool配SynchronousQueue和maximumPoolSize=Integer.MAX_VALUE,所以来多少任务就创建多少线程,高并发下极其容易创建过万线程。这不是危言耸听,真有过线上事故就是这么来的。
选队列的时候,不要只听网上说"LinkedBlockingQueue性能好"就无脑选。要结合你的业务场景来回答:如果系统必须控制排队任务数、不能让内存无界增长,优先ArrayBlockingQueue;如果任务要求低延迟、不能被排队积压,可以考虑SynchronousQueue;如果任务量很小且绝对可控,LinkedBlockingQueue的默认实现也不是不行,但风险你得承担。面试的时候能把这个取舍逻辑讲明白,比单纯背队列实现要加分得多。
8. 从"会用线程池"到"会配线程池":我的配置建议和踩坑记录
最后这部分我讲点实际配置思路。线程池参数怎么配,网上公式一大堆,但最重要的是先看任务类型。
CPU密集型的任务(比如大量计算、排序、图像处理),线程数适合设为CPU核数+1或核数的两倍,因为这类任务几乎不等待,线程多了反而在抢CPU时间片,上下文切换开销巨大。
IO密集型的任务(比如远程调用、读文件、查数据库),线程大部分时间在等待IO返回,这时候可以多配一些线程。常见估算公式是CPU核数 / (1 - 阻塞系数),其中阻塞系数通常取0.8~0.9。也就是说如果你的服务是4核,阻塞系数0.9,那线程数可以配到40。但别真把自己机器压满了,要给系统留点余量。
举一个我实际做过的配置例子:一个数据同步服务,4核CPU,任务主要是从接口拉数据然后写库,典型的IO密集。我最后配的是corePoolSize=8、maximumPoolSize=20、ArrayBlockingQueue(200)、keepAliveTime=60秒、拒绝策略走CallerRunsPolicy。这样做的理由:8个核心线程足够应付大部分时间段的流量,20是峰值上限防止突发任务把队列彻底挤爆,200的队列容量给了一定缓冲又不至于积压太多,调用方执行拒绝策略的情况很少发生,但真发生了也算一种熔断,能防止下游数据库被冲垮。
在这里我必须提一个很多教程不会强调的坑:业务代码里自己写的Runnable任务,如果内部用了try-catch吞掉了所有异常,那线程池里出的事你完全看不见。默认情况下线程池里跑的任务如果抛出异常,这个异常不会直接抛给提交者,而是会被线程池吞掉(execute方法提交时),只在日志里留下痕迹,或者直接让线程池销毁这个工作线程再创建一个新的。这个行为非常隐蔽,我一同事就曾经线上诡异DataNull,排查半天才发现是线程里抛了NPE,日志被刷掉了。
所以用线程池的时候,一个最稳妥的习惯是:任务内部自己对异常负责,至少打一条完整日志;或者使用submit()方法提交任务,让返回的Future去接收异常,这样异常能重新暴露出来供你处理。
再提一个和等待唤醒机制挂钩的知识点,以防面试官把两章连起来问:线程池里任务的等待,本质上是提交者把任务丢进阻塞队列后自己返回;而队列的take、put操作底层,用的正是我们前面讲的wait/notify(或者说它们的增强版AbstractQueuedSynchronizer条件队列)。也就是说,你前面认真学的等待唤醒机制,在线程池里每天都在默默运作。这个呼应可以从底层把整篇文章串起来。
给零基础同学的结论性建议很简单:先亲手写一遍wait/notify的生产者消费者模型,再亲手用一个ArrayBlockingQueue自建一个ThreadPoolExecutor,把7个参数挨个传一遍。整个过程跑完,你比背二十道八股文都有用。等这两块都通了,再回头看什么"线程安全""并发包""锁升级",你会觉得都是同一棵树的枝枝叶叶,心里对Java并发就算真正有了底。