尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Java多线程进阶:从wait/notify到线程池,一文吃透并发协作与任务管理
零基础学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甚至抛出数组越界。所以正确的写法必须用whilesynchronized (lock) { while (list.isEmpty()) { lock.wait(); } return list.remove(0); }唤醒之后先回到while条件再判断一次如果条件依然不满足就继续wait()。这就是所谓的循环等待。为什么必须这样除了逻辑上的严谨性之外还有一个非常现实的虚假唤醒问题。所谓虚假唤醒指的是线程可能在没有收到任何notify信号的情况下被唤醒这在底层硬件和操作系统的某些场景下确实可能发生。JDK官方文档里也白纸黑字写了wait方法应该始终在循环中使用。这段不是我编的而是开发规范层面的硬性要求。你用while写就算遇到虚假唤醒线程回到条件判断时发现不满足自动回去继续等程序不会崩溃。你要是用if写一次虚假唤醒直接让程序逻辑错乱。所以从今天起写等待唤醒代码一律用while。这属于面试必问、工作必写的细节早养成习惯不吃亏。4. 从零手写一个生产者-消费者模型让等待唤醒落地光讲概念学完就忘咱们直接上一个经典的生产者消费者代码。我用一个环形缓冲区来做共享数据区缓冲区容量是10class 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 Threadfor (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_VALUEExecutors.newFixedThreadPool默认用无界版本ArrayBlockingQueue数组实现必须有界必须指定需要精确控制排队容量的场景SynchronousQueue不存储元素直接移交0Executors.newCachedThreadPool默认用这个先说LinkedBlockingQueue。Executors的newFixedThreadPool底层用的就是无界版本也就是队列容量为Integer.MAX_VALUE。无界带来的问题是当任务产生速度远超处理速度时任务全堆在队列里内存水涨船高终究会OOM。所以在生产环境里我不推荐直接无脑用newFixedThreadPool除非你能确保任务量绝对可控。ArrayBlockingQueue是有界队列创建的时候必须指定容量比如new ArrayBlockingQueue(100)。它逼着你思考最多允许排队多少个任务一旦队列满了就启动非核心线程或触发拒绝策略。这是正规军打法。我自己写线程池的时候几乎都用ArrayBlockingQueue因为它让系统行为变得可预测。SynchronousQueue比较特殊它完全不存东西。生产者在往队列放任务的时候必须等一个消费者线程同时来取不然就阻塞。放到线程池的场景里它意味着任务不会被排队一定立即被某个线程拿走去执行。newCachedThreadPool配SynchronousQueue和maximumPoolSizeInteger.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密集。我最后配的是corePoolSize8、maximumPoolSize20、ArrayBlockingQueue(200)、keepAliveTime60秒、拒绝策略走CallerRunsPolicy。这样做的理由8个核心线程足够应付大部分时间段的流量20是峰值上限防止突发任务把队列彻底挤爆200的队列容量给了一定缓冲又不至于积压太多调用方执行拒绝策略的情况很少发生但真发生了也算一种熔断能防止下游数据库被冲垮。在这里我必须提一个很多教程不会强调的坑业务代码里自己写的Runnable任务如果内部用了try-catch吞掉了所有异常那线程池里出的事你完全看不见。默认情况下线程池里跑的任务如果抛出异常这个异常不会直接抛给提交者而是会被线程池吞掉execute方法提交时只在日志里留下痕迹或者直接让线程池销毁这个工作线程再创建一个新的。这个行为非常隐蔽我一同事就曾经线上诡异DataNull排查半天才发现是线程里抛了NPE日志被刷掉了。所以用线程池的时候一个最稳妥的习惯是任务内部自己对异常负责至少打一条完整日志或者使用submit()方法提交任务让返回的Future去接收异常这样异常能重新暴露出来供你处理。再提一个和等待唤醒机制挂钩的知识点以防面试官把两章连起来问线程池里任务的等待本质上是提交者把任务丢进阻塞队列后自己返回而队列的take、put操作底层用的正是我们前面讲的wait/notify或者说它们的增强版AbstractQueuedSynchronizer条件队列。也就是说你前面认真学的等待唤醒机制在线程池里每天都在默默运作。这个呼应可以从底层把整篇文章串起来。给零基础同学的结论性建议很简单先亲手写一遍wait/notify的生产者消费者模型再亲手用一个ArrayBlockingQueue自建一个ThreadPoolExecutor把7个参数挨个传一遍。整个过程跑完你比背二十道八股文都有用。等这两块都通了再回头看什么线程安全并发包锁升级你会觉得都是同一棵树的枝枝叶叶心里对Java并发就算真正有了底。
RELATED

相关推荐

2026大厂测试技术栈全景拆解:从自动化到质量工程的学习路线

2026大厂测试技术栈全景拆解:从自动化到质量工程的学习路线

每年年底都有不少人问我同一个问题:大厂测试到底要学什么技术栈?这个问题放到2026年,答案和三五年前真的差别很大。以前大家聊的是会不会Selenium、能不能把自动化用例跑稳定;现在大厂测试技术栈已经变成了一整套质量工程体系&…

📅 2026/10/3 23:42:31
戴尔台式机故障灯全解读:电源LED与诊断代码定位开机故障

戴尔台式机故障灯全解读:电源LED与诊断代码定位开机故障

做IT这行,不管是替企业维护几百台戴尔办公机,还是帮朋友修一两台家用台式机,你一定见过这种场景:客户电话打过来,说电脑开不了机,前面板灯一闪一闪的,像打暗号。赶到现场,戴尔机箱电…

📅 2026/10/3 23:42:31
7.4ms极速打字决策:MLX在Apple Silicon上的端侧推理优化实践

7.4ms极速打字决策:MLX在Apple Silicon上的端侧推理优化实践

1. 当打字决策被压缩到7.4毫秒,端侧推理在悄悄改变什么第一次看到“7.4ms极速打字决策模型”这个数字时,我的反应是:这大概率又是一个跑在实验室理想环境下的benchmark。毕竟在端侧做推理,尤其是涉及输入法这种高频交互场景&#…

📅 2026/10/3 23:42:31
MORE NEWS

更多资讯

📰

鸿业市政道路软件避坑指南:版本匹配、横断面与土方计算常见问题

简介:针对鸿业市政道路软件用户的常见问题解答文档,内容覆盖软件运行、土方、平面、纵断、横断、交叉口设计及其他模块,面向市政道路设计人员与相关专业学生,帮助解决菜单加载失败、土方计算异常、图面显示错乱等高频问题。压缩包…

📰

超级多智能体架构实战:DeepAgents编排、MCP工具接入与A2A通信

1. 从单体到集群:为什么我们需要超级多智能体1.1 一个真实的需求场景去年下半年我接手了一个企业内部知识助手的项目,需求听起来不复杂:帮员工查制度文档、走审批流程、生成周报。一开始我用的是单体 Agent 方案,一个模型加一堆工…

📰

hindsight:面向LLM应用的事后可观测性工程实践

1. 项目概述:hindsight 不是回溯,而是“事后视角”的工程化实践“hindsight”这个词在日常英语里常被译作“后见之明”,指事情发生之后才看清因果、识别关键节点的能力。但在当前技术语境下,尤其结合 Python、OpenAI、Anthropic、…

📰

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

📰

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

📰

QuickBlue:企业AI应用底座,打通模型到业务落地的最后一公里

上个月和一位做工业质检的老友吃饭,他公司的AI项目在测试集上准确率做到了99.3%,可项目就是迟迟上不了产线。我问他卡在哪,他掰着手指头给我数:现场数据传不上来、接口协议没人维护、操作员的反馈没有回流通道,最后还有…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬