1. 从Thread.start()到死锁,多线程进阶到底进阶在哪
1.1 为什么你学完创建线程的五种方式,依然写不好并发代码
刚接触JavaSE多线程的朋友,大概率经历过这样的阶段:能背出继承Thread、实现Runnable、实现Callable、使用线程池这几种创建线程的方式,也看得懂synchronized基本用法,但真要上手写一个带共享数据的业务模块,线程一多还是会出现数据错乱、卡死甚至OOM。
原因很简单:创建线程只是入门动作,线程之间的通信、协作、调度、资源隔离才是真正的核心。进阶和初级的差别不在API用得有多花哨,而在对"共享资源竞争"这件事的理解深度。多线程问题的本质,是多个执行流同时访问同一份数据时,如何保证数据的一致性和程序的确定性。
这里先打个比方:单线程像一条单人流水线,工人按顺序干活,永远不会出现两个工人抢同一个零件的情况。多线程就是开了几条流水线,零件、工具、工作台全都是共享的,如果每个工人都按自己的节奏伸手去拿,轻则拿错零件,重则把机器卡死。多线程进阶知识,学的就是怎么给这些流水线制定"协作规则"。
所以这篇文章我不会从头讲线程怎么创建,也不会贴大段API文档,而是按实际项目中从简单到复杂的演进路径,把JavaSE多线程进阶中最关键的内容拆开讲透:从线程生命周期开始,到锁的底层模型,再到生产者-消费者这种经典协作场景,然后是JUC工具类和线程池,最后加一点多线程对比的视角和实战中容易踩的坑。
1.2 线程生命周期里隐藏的进阶线索
很多教程会把线程生命周期画成一张六状态图,看一眼觉得懂了,但实际排查问题时才发现图是图,代码是代码。这里不复制那张图,只讲几个进阶必须吃透的关键点。
线程的状态从NEW(新建)、RUNNABLE(可运行)、BLOCKED(阻塞)、WAITING(等待)、TIMED_WAITING(限时等待)、TERMINATED(终止)这几个状态之间流转。注意,Java里的RUNNABLE实际包含了操作系统层面的"正在运行"和"排队等待CPU时间片"两个情况,所以RUNNABLE状态居高不下,不一定说明线程真的在跑,也可能在抢CPU。
进阶时最容易忽略的是BLOCKED和WAITING的区别。BLOCKED是因为没抢到同步锁被挡在门外,WAITING是拿到了锁之后主动调用wait()或者join()让出CPU,等待别的线程来唤醒。这两个状态在生产环境排查线程问题时非常关键。你用jstack导出的线程快照里,如果大量线程卡在BLOCKED,说明锁竞争激烈;如果卡在WAITING,往往是等通知等不到,可能出现死锁或者信号丢失这类逻辑问题。
另外补充一个我在实际排查中养成的习惯:查看线程快照时,先看有没有成对出现的"Waiting to lock"信息,再看线程栈顶部的调用方法。如果是wait()、notify()这类方法相关,重点检查等待条件有没有在异常路径上漏掉notify;如果全是lock、synchronized相关,重点检查锁的粒度是不是太大了。这套路定位过好几次线上问题,比对着文档翻API有用得多。
提示:真正的进阶,是看到状态流转时能立刻联想到"这段代码在什么场景下会卡在这里"以及"谁在什么时机把它唤醒或解除阻塞"。
2. synchronized底层藏着的Monitor模型,搞懂它才算真正入门进阶
2.1 从字节码看synchronized的实现
很多Java开发者把synchronized当成"玄学关键字",加了之后线程安全了,但问原理就说不清。其实synchronized的底层实现和JVM的Monitor(管程)模型绑定在一起。
用javap -c反编译一段简单的同步方法,你会发现同步代码块在字节码层面是通过monitorenter和monitorexit两条指令实现的。进入monitorenter时线程尝试获取Monitor的所有权,退出时执行monitorexit释放。这就是为什么synchronized是可重入的:同一个线程可以多次进入同一把锁,因为Monitor内部记录着持有者的线程ID和重入计数。
高版本JDK对synchronized做了大量优化,锁对象头里用Mark Word记录锁状态,存在无锁、偏向锁、轻量级锁、重量级锁几条升级路径。偏向锁针对的是"只有一个线程访问同步块"的场景,轻量级锁针对的是"多线程交替访问"的场景,重量级锁才走到底层的互斥量。换句话说,JVM用一堆优化手段,目的是让synchronized在低竞争场景下尽量不阻塞线程。
不过这里要提醒一句:不要因为JVM有优化就完全不在意锁性能。偏向锁在高版本JDK里其实已经在逐步废弃,而且一旦出现真正的竞争,锁升级成重量级之后,性能开销依然不小。关键还是设计层面减少竞争,而不是依赖JVM兜底。
2.2 synchronized、wait、notify是一条协作链路
进阶阶段容易卡住的一个点,是没有把synchronized和wait()/notify()当成一套协作机制来理解。很多人只把synchronized当成"互斥锁",忽视了它同时也是"协作锁"。
wait()的语义是:当前线程必须持有某个对象的Monitor(也就是处于该对象的synchronized代码块内),然后释放Monitor并进入WAITING状态。notify()的语义是:唤醒一个正在该对象Monitor上等待的线程,被唤醒的线程需要重新竞争Monitor才能继续往下走。
这两句话合在一起,产生了多线程协作的基本框架。举个生活化的例子:厨房里有一个灶台(共享资源),厨师A负责做菜(生产者),厨师B负责收盘子(消费者)。如果两者同时用灶台,厨房就乱了,这是互斥问题,用synchronized解决;如果菜做完了要通知收菜的来端走,这是协作问题,用wait()/notify()解决。一个完整的厨房运作系统,必须同时处理互斥和协作两件事,缺一不可。
我在带新人时经常拿出来考的一个问题是:wait()和sleep()有什么区别?进阶的人必须脱口而出:wait()会释放Monitor锁,sleep()不会释放任何锁,只是让线程暂时让出CPU。这个区别直接决定了你在什么场景用哪个。需要让出锁让别的线程有机会修改共享状态的,用wait();仅仅是想让当前线程停一停的,用sleep()。
3. 生产者-消费者:从手写wait/notify到BlockingQueue的三层演进
3.1 手写经典版本时,最容易踩的坑是"信号丢失"
生产者-消费者模式是多线程进阶绕不开的经典案例,标题热词里它也占了重要位置。这个模式之所以经典,是因为它几乎涵盖了多线程编程的所有核心要素:共享缓冲区的并发安全、生产者和消费者的速度匹配、任务队列的容量控制。
先看一个很常见的错误示范。假设用ArrayList当缓冲区,synchronized保证互斥,然后生产者往里加数据,消费者往外取数据。问题出在条件判断上:
synchronized (queue) { if (queue.size() == MAX_CAPACITY) { queue.wait(); // 错误点:用if而不是while } queue.add(item); queue.notify(); }如果用if,等线程被唤醒后会直接往下执行,不会重新检查条件。真实场景里存在"伪唤醒"(spurious wakeup),而且多个生产者消费者互相唤醒时,经常出现被唤醒后条件已经不满足了,于是取不到数据或者覆盖数据。正确做法是用while循环重新检查条件,这是Java并发编程里一条铁律:等待条件要放在循环里,不能在if里。
再一个坑是notify()和notifyAll()的选择。notify()只随机唤醒一个等待线程,如果被唤醒的线程类型不对(比如又唤醒了生产者,但缓冲区其实是满的),它会重新等回去,可能会导致"信号丢失"——本应被唤醒的消费者永远叫不醒。实践中我基本只用notifyAll(),让所有等待线程都竞争,虽然浪费一点性能,但安全性高得多。
3.2 用BlockingQueue替换手写逻辑:简单到令人怀疑
手写版本写完,你会发现核心逻辑就是"队列满就等,队列空就等,加一个通知一个"。这套逻辑在JDK里早就封装好了,就是BlockingQueue。用到它之后,生产者消费者代码会简化到让人怀疑是不是写漏了什么:
public class Producer implements Runnable { private final BlockingQueue<Task> queue; public Producer(BlockingQueue<Task> queue) { this.queue = queue; } @Override public void run() { while (true) { try { Task task = createTask(); queue.put(task); // 队列满时自动阻塞 } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }消费者那边无非是queue.take(),队列空时自动阻塞。put()和take()内部已经处理好了等待通知逻辑,不需要你手动写wait()/notify()。但我建议进阶者别急着用BlockingQueue,先手写一遍经典版本,因为只有手写过,你才真正理解while循环检查和notifyAll()的意思,后面排查问题时才能看穿封装。
常用的BlockingQueue实现各有侧重:ArrayBlockingQueue有界、基于数组,适合需要限制容量的场景;LinkedBlockingQueue有界无界均可,吞吐量通常更高;SynchronousQueue不存储元素,每个put()必须等待一个take(),适合线程间直接交接;PriorityBlockingQueue支持优先级排序,适合按优先级处理的任务。选型时核心看两点:队列要不要有界,以及吞吐量优先级高不高。
3.3 生产环境里生产者-消费者模式的几个真实压测数据
这里分享一组我去年压测一个消息转发模块时的实际数据,场景是8个生产者线程、4个消费者线程,任务对象是大小约2KB的消息。
- 用
synchronized+ 手写ArrayList缓冲,队列长度限制2000,8个生产线程全部启动后,吞吐量大约在每秒1.2万条,CPU使用率一度冲到90%,锁竞争非常明显; - 换成
LinkedBlockingQueue,同样配置,吞吐量提升到每秒2.8万条左右,CPU使用率降到60%多,因为LinkedBlockingQueue内部用了两把锁分别控制队头和队尾的入队出队操作,生产者和消费者之间竞争大大降低; - 再叠加
ArrayBlockingQueue(有界且容量2000),吞吐量约每秒2.1万条,没有LinkedBlockingQueue高,但胜在容量上限可控,内存占用不会无限膨胀。
这几个数字不一定照搬到别的机器上,但能说明一个趋势:锁竞争是多线程性能的第一杀手。数据结构和算法选得好,比盲目堆线程数有效得多。生产者-消费者模式在真实项目中往往不是简单的两方,而是链条式的:上游消息进来先入队列,一组线程处理第一道逻辑,再放到下一个队列,另一组线程处理第二道逻辑。这种流水线式的设计,能让每段逻辑独立伸缩,这是进阶阶段值得重点掌握的架构思路。
4. 你以为学了synchronized就够了?JUC里这些工具才是真进阶
4.1 CountDownLatch:一个火箭发射倒计时的故事
synchronized解决的是互斥和协作,但如果协作场景更复杂一点,光靠它写起来非常吃力。比如主线程要等5个子线程全部把任务做完,再汇总结果。用join()也能实现,但问题在于join()是"死等",你想加超时控制就很难受。CountDownLatch就是为解决这种"多个任务完成后再继续"的场景设计的。
CountDownLatch的用法像火箭发射倒计时:先指定一个计数N,每有一个线程完成任务就调用countDown()把计数减一,主线程调用await()等待计数归零。关键是这个"归零"事件是不可逆的,计数一旦到0就不能重置。所以它只适合用一次的场景,比如程序启动时等待多个初始化任务全部就绪。
有个细节是await(long timeout, TimeUnit unit)这个带超时的版本。生产环境里我几乎都会用它,因为如果某个子线程因为异常没有执行countDown(),主线程会被卡死,这在线上是致命问题。加上超时,至少能把控制权抢回来,然后记录日志、走降级逻辑。这是实战里很重要的一课:所有阻塞等待都要有超时,除非你有绝对把握。
4.2 CyclicBarrier:所有人到齐了才能出发
如果说CountDownLatch是倒计时,那CyclicBarrier就是"全员到齐再出发"。一个典型的场景是并发测试:多个线程同时准备测试数据,准备完成后必须所有人都就位,才能同时开始发请求,模拟真正的并发峰值。
CyclicBarrier的核心API是barrier.await(),每个线程执行到这里都会阻塞,直到指定数量的线程全部到达,然后一起放行。和CountDownLatch最大的区别是它可以循环使用:一次计数完成后自动重置,下一次继续拦截。这也决定了它的典型应用是"分阶段同步",比如每个线程跑完一轮任务,在下一轮开始前统一对齐一次。
选CountDownLatch还是CyclicBarrier,我提供一个简单判断标准:如果你是"等待N个任务完成",用CountDownLatch;如果你是"N个线程互相等待、齐头并进",用CyclicBarrier。前者偏向上游等下游,后者偏向同级互相等,概念搞清楚了就不容易混。
4.3 Semaphore:限流许可证机制
前面讲的都是多线程协作,Semaphore则是多线程限流。它可以看作一个发放许可证的信号灯:初始化时指定许可证数量,线程执行前先acquire()拿许可证,没有许可证就阻塞等待,执行完后release()归还。
我实际用得最多的场景是数据库连接池的保护。某个服务有50个数据库连接,但并发的调用方可能有几千个,如果全放过去,数据库直接被打崩。用Semaphore限制同时只有40个请求能拿到连接,其余排队等待,就能对数据库起到保护作用。相比BlockingQueue做限流,Semaphore优点在于许可证本身和任务队列解耦,你可以在多个入口分别acquire()同一个信号量,实现全局限流。
Semaphore还有一个值得注意的公平性问题。构造时传入new Semaphore(10, true)可以启用公平模式,让等待时间最长的线程先获得许可证。但公平模式会带来额外的性能开销,一般场景非公平就够用,除非你的业务对饥饿敏感。
4.4 三个工具一个表格看清差异
把三个工具放一起对比,方便记忆:
| 工具 | 核心语义 | 可否复用 | 典型场景 |
|---|---|---|---|
| CountDownLatch | 倒计数门闩 | 不可重置 | 主线程等待多个任务完成后继续 |
| CyclicBarrier | 栅栏/分阶段同步 | 可循环利用 | 多线程对齐后同时出发 |
| Semaphore | 许可证限流 | 永久可用 | 控制并发访问数据库/接口的线程数 |
还有一个容易被忽略的小组成员是Exchanger,它能让两个线程在某个汇合点交换数据。实践中用得不多,但有时用来实现两个线程间的数据传递比队列更轻量,了解即可。
5. 线程池背后的设计哲学:从参数到底层队列
5.1 线程池七个参数,每个都是项目调优的抓手
项目里用线程池几乎成了标配,但很多人是从网上抄一段"线程池工具类"就完事,参数含义似是而非。线程池的七个核心参数,每一个都对应一种生产决策:
corePoolSize:核心线程数,线程池保持存活的最小线程数量;maximumPoolSize:最大线程数,线程数超过核心数且队列满了之后,才会创建新线程到最大值;keepAliveTime:非核心线程的空闲存活时间,超过这个时间没有新任务就会被回收;workQueue:任务队列,核心线程都忙时,新任务先排队;threadFactory:线程工厂,控制线程命名、是否为守护线程;handler:拒绝策略,线程池满且队列满时新任务怎么处理。
日常选型时最纠结的是workQueue。LinkedBlockingQueue默认是无界队列,意味着maximumPoolSize形同虚设,因为任务永远不会排队满;ArrayBlockingQueue有界,容量则直接影响拒绝策略触发的时机;SynchronousQueue不缓存任务,每个任务必须立刻有一个线程来处理,适合任务量小且频繁的场景。
5.2 核心线程数到底配多少:一个经验公式反而害了很多人
网上流传一个公式:CPU密集型线程数 = CPU核数 + 1,IO密集型线程数 = CPU核数 * 2。这个公式是很好的入门起点,但不能当金科玉律。真实项目里绝大多数任务都是混合型的,有计算也有等待,单纯套公式容易配出要么浪费内存、要么排队严重的线程池。
我个人在实践中的做法是:先按任务性质估一个初始值,再拿压测数据校准。比如一个处理HTTP回调的服务,任务大部分时间在等下游响应,属于IO密集,初始值给到CPU核数的3到4倍;另一个做图片压缩的服务,任务几乎全是计算和内存操作,属于CPU密集,初始值就给CPU核数加1。配合JVisualVM观察线程池的活跃线程数和队列积压趋势,一两个星期就能调到一个相对合理的档位。
拒绝策略的选择也需要提前规划。默认的AbortPolicy是抛RejectedExecutionException,如果业务方没有捕获,任务会直接丢掉并打断调用线程。CallerRunsPolicy是我在很多内部系统里倾向用的:线程池满了就让提交任务的线程自己执行这个任务,天然形成"反压",不让任务无限积压。DiscardOldestPolicy适合丢弃最旧任务、保最新任务的场景,但一般用于日志类非关键任务。
5.3 ThreadLocal与线程池的组合:一个容易水灵灵踩坑的组合
压轴讲一个线程池进阶时非常隐蔽的坑:ThreadLocal。
ThreadLocal本身的机制是每个线程一个变量副本,本来没问题。但搭配线程池使用时,线程是复用的,上一个任务往ThreadLocal里塞的数据,会被下一个任务读到,造成严重的数据串扰。经典场景是网关服务用ThreadLocal存储当前请求的用户ID,如果某个任务没在finally里清理,下一个请求拿到的就是前一个用户的身份。
解决办法只有一个:在任务执行完的finally块中调用remove()清空。没有捷径,没有侥幸。ThreadLocal的值通常还强引用着大对象,如果不及时清理,线程池里常驻的线程会一直引用这些对象,也可能出现无法被GC回收的内存泄漏问题。这两重风险叠加起来,已经足够让每一个使用线程池的团队把它写进代码审查清单。
6. Java多线程和Python/Qt多线程:从对比中更懂Java的设计
6.1 Python多线程为什么被吐槽"鸡肋",GIL是怎么回事
看到热搜里频繁出现"Python多线程",就多说两句对比。Python因为GIL的存在,同一时刻只有一个线程能执行Python字节码,所以纯计算型的多线程任务在Python里并不能真正利用多核CPU,顶多做到并发I/O。很多人因此说Python多线程是"鸡肋",其实这话只对了一半:在I/O密集型场景(网络请求、文件读写)下,Python多线程依然有很好的性能,因为在等待I/O时线程会释放GIL,让别的线程执行。
Java没有GIL这种全局锁,多线程可以直接跑满多核,所以Java并发编程的复杂度和重要性都比Python高一大截。Java的锁、内存模型、JUC工具,本质上都是在和真正的并行环境打交道;而Python的很多"多线程默认就安全"的感觉,其实只是GIL带来的托底。理解这点,你就明白为什么JavaSE阶段的多线程知识要学得这么细。
6.2 Qt多线程和Java多线程:框架线程模型的不同视角
Qt多线程和Java多线程表面上都是创建线程、加锁、发信号,但设计哲学差异很大。Qt的核心是信号槽机制,线程之间通过信号->槽的连接来通信,而且默认连接类型会让信号在不同的线程间安全排队分发,开发者不需要手写wait/notify。Java这边的对应物是BlockingQueue、Future、CompletableFuture这些,但它们不是语言内建的通信机制,需要开发者自己选择合适的工具并注意边界。
从教学角度看,Qt的信号槽是"框架帮你管线程通信",Java是"语言给你并发原语,你自己组装"。这没有绝对的优劣,Java的灵活性更高,但犯错面也更大。我见过不少Qt出身转Java的同事,上手写生产者-消费者时会习惯性地去找类似信号槽的自动通信机制,找半天发现Java里得自己组装队列和锁,于是开始理解Java并发编程的学习曲线为什么陡峭。反过来,Java程序员去看Qt多线程,会觉得到处都是现成的"护具",但一旦涉及高性能底层数据共享,还是要回到锁和原子操作这些本质问题。
7. 进阶路上我踩过最深的几个坑,希望你直接绕开
7.1 死锁定位:jstack不是万能的,但它是第一步
死锁是进阶者必经的坎。我印象很深的一次是在一个分布式调度模块里,两个线程分别持有A锁和B锁,又互相等待对方的锁,整个模块卡死。当时第一反应是看日志,结果日志停在某一行不动了,第二反应才是上服务器执行jstack 进程ID导出线程快照。
jstack输出里有一种明确的死锁标志,会看到类似"Found one Java-level deadlock"的提示,并且给出两个线程各自持有的锁和等待的锁。但养成习惯之后,我更重视的是从快照里看出"谁在等谁"的链路,因为很多死锁并不是传统的两个锁互等,而是三个、四个锁形成环形等待。这类死锁在jstack里不会直接标出"deadlock"字样,需要你人工顺着线程栈里每一条"Waiting to lock"去串成环。
后来我吸取教训,把"获取多把锁时必须全链路按同一顺序"写进了团队规范。多把锁的获取顺序是所有死锁问题的总根源,没有例外。
7.2 原子性、可见性、有序性:volatile只能解决三分之一
volatile在多线程进阶里是个高频词,但很多人对它有过高期待——甚至有人以为加了volatile就线程安全了。必须说清楚,volatile解决的是可见性和有序性,它保证一个线程对变量的修改会对其他线程立即可见,并且禁止指令重排序,但它不解决原子性问题。对一个volatile变量做i++操作依然是线程不安全的,因为"读-改-写"这三步不是原子的。
判断一个并发问题该用volatile还是synchronized还是AtomicInteger,标准很简单:如果是"多个线程同时写一个变量",用AtomicInteger或锁;如果是一个线程写、多个线程读,可以用volatile;如果读操作依赖当前值做复合计算,老老实实加锁。volatile的经典搭配是状态标志位,比如volatile boolean running控制线程启停,这才是它的主场。
7.3 线程数与性能:加线程不总是提速,这是很多人的认知误区
接触多线程一段时间后,很容易产生"线程越多越快"的错觉。实际上线程多了,CPU上下文切换开销、锁竞争概率、内存占用都会同步上升,性能可能在某个点之后反而急剧下降。压测过一套数据处理管线,线程数从4调到8,吞吐量提升明显;从8调到16,提升幅度变小;从16调到32,吞吐量不升反降,CPU大量消耗在上下文切换和锁等待上。
从这个教训出发,我现在评估线程数量时会同时关注两个指标:CPU使用率和线程阻塞率。如果CPU使用率还没吃满但线程大量BLOCKED,说明锁竞争是瓶颈,加线程没什么用;如果CPU已经接近100%而线程基本都在RUNNABLE,说明是计算密集型,加到CPU核数附近就该收手。多线程的进阶,很大程度是学会"观察系统"而不是"堆配置"。
最后再分享一个细节:线上排查多线程问题,别急着改代码,先完整保留线程快照、GC日志和压测数据三样东西。有了这三样,大多数问题都能定位到根因;缺一样,排查就会变成猜谜。这算是我在多线程这条路上摸爬滚打多年,最想送给后来者的一句话。