后台服务偶尔卡死、接口响应忽快忽慢、数据莫名重复甚至错乱——如果你在项目里遇到过这类问题,多半已经和并发编程打了个照面。多线程是提高吞吐量的常用手段,但用不好,它带来的麻烦比单线程更让人头疼。这一章我从零开始讲清楚两件事:多线程在真实项目里到底怎么落地,以及死锁为什么是并发项目里最隐蔽的坑。适合刚接触并发的开发者,也适合准备做并发问题排查技改的人。
1. 并发编程的核心:多线程到底在解决什么问题
1.1 并发诉求来自真实业务,不是技术炫技
很多人学多线程是从“创建Thread、实现Runnable”开始的,但到了项目里才发现,问得最多的问题其实是:为什么我要开多个线程?
拿一个很常见的场景来说。某订单系统每天要批量处理一万条待支付订单,每条订单的处理链路包含:读取数据库记录、调用第三方支付渠道查询状态、回写结果。如果单线程做,每条订单耗时约300毫秒,一万条就是3000秒,整整50分钟。用户等不起,业务方也等不起。
这时候多线程的作用就很直接:既然大部分时间都耗在等待第三方接口返回上,那就可以同时发起多个查询,让CPU在等待期间去做其他任务。一万条订单拆成20个线程并发处理,理论上总耗时能压缩到两三分钟。这就是并发编程最根本的驱动力:提高资源利用率,缩短整体耗时。
再往底层说,现代服务器基本都是多核CPU。单线程程序无论怎么优化,一个时刻只能用一个核,其余核全部空转。多线程能让多个核心同时干活。这个收益听着诱人,但它不是免费的。
1.2 多线程是有代价的,不是开得越多越好
我在项目中见过有人把线程数直接配置成“越多越好”,结果系统吞吐量反而下降,CPU占用却飙到接近100%。原因在于上下文切换。
操作系统调度线程时,需要保存当前线程的执行状态,再加载下一个线程的状态,这个过程叫上下文切换。线程开得太多,CPU大量时间花在切换线程而不是执行任务上,属于典型的“管理成本超过业务收益”。
所以多线程设计的核心不是“开多少线程”,而是“在资源约束下,让任务完成得最快”。这里有个大致的参考口径:
- 计算密集型任务:线程数接近CPU核心数,常见配置是
CPU核心数 + 1。 - IO密集型任务:线程数可以比核心数多,常见经验公式是
CPU核心数 * 2,还要乘上IO等待时间占比的系数。
真实项目里我一般不照搬公式,而是先按经验值配置,再看监控调整。上线后用线程池自带的指标观察任务排队时间、线程活跃度,逐步逼近最优值。这也是做并发项目最需要养成的习惯:先跑起来,再量化调优。
2. 多线程在项目中的落地示例
2.1 从需求到任务拆分:先想清楚拆什么
理论讲完,直接上示例。假设要做一个报表批量生成服务:系统里有5000个客户需要生成月度报表,每个报表包含基础数据汇总、业务明细统计、PDF文件输出三个步骤。三个步骤中,前两个要查数据库,第三个要写文件,整体属于典型的IO密集型任务。
如果按最原始的方式写,代码是这样:
for (Customer customer : customers) { ReportData data = reportService.generate(customer); fileService.writePdf(data); }这段代码逻辑没错,但5000个客户串行处理,假设每个客户耗时400毫秒,总耗时2000秒。业务方要求15分钟内完成,串行肯定不行。
任务拆分的思路是:把每个客户当作一个独立任务,扔给线程池并发执行。于是有了改造版:
ExecutorService executor = Executors.newFixedThreadPool(16); for (Customer customer : customers) { executor.submit(() -> { ReportData data = reportService.generate(customer); fileService.writePdf(data); }); } executor.shutdown();这个版本已经能并发跑,但有两个隐患:一是直接使用Executors类创建线程池容易隐藏问题,二是提交任务后主线程直接结束了,无法感知任务是否全部完成、是否失败。
2.2 线程池参数选择:别再用Executors偷懒了
我见过不少项目直接用Executors.newFixedThreadPool,图省事,但生产环境很容易踩雷。固定线程池的队列长度默认是Integer.MAX_VALUE,如果任务提交速度远超处理速度,队列会无限堆积,内存迟早被撑爆。正确做法是使用ThreadPoolExecutor手动配置,每一个参数都心里有数。
ThreadPoolExecutor executor = new ThreadPoolExecutor( 16, // 核心线程数 32, // 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new LinkedBlockingQueue<>(2000), // 有界任务队列 new ThreadFactoryBuilder() // 给孩子起个可读的名字,排查问题靠它 .setNameFormat("report-worker-%d") .build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );各参数的作用需要多说几句:
- 核心线程数16:系统刚启动时创建的线程数量,任务再多也会保持至少这么多线程活着。
- 最大线程数32:当任务队列满了之后,线程池最多扩张到的数量。
- 队列容量2000:主线程提交任务但所有工作线程都在忙时,先放进队列排队。
这里最容易被问的是:核心线程数和最大线程数的差距到底什么时候用?按照ThreadPoolExecutor的逻辑,新任务到来时,如果当前线程数小于核心线程数,直接新建线程;超过核心线程数则先放入队列;队列满了才考虑创建非核心线程。也就是说,增大最大线程数是“队列满了之后的应急手段”,不是一开始就能享受的待遇。
至于拒绝策略,我强烈建议生产环境用CallerRunsPolicy。它意味着当线程池实在吃不消时,新任务会退回提交方,让主线程自己执行。好处是任务永远不会丢,代价是主线程会变慢,这是一种“降级而不是失败”的策略。AbortPolicy直接抛异常会中断业务,DiscardPolicy悄悄丢弃任务则更加危险,数据丢了都不知道。
2.3 线程安全的数据结构与结果收集
并发执行只是第一步,线程之间怎么协作、结果怎么汇总,才是真正的难点。
还拿报表生成举例。每个客户生成报表后,需要记录一条生产日志,把生成结果保存到一个汇总列表里。如果直接用ArrayList,多线程同时写入会丢数据甚至报错。换成ConcurrentHashMap或者CopyOnWriteArrayList就是另一回事。
Map<String, ReportResult> resultMap = new ConcurrentHashMap<>(); executor.submit(() -> { ... resultMap.put(customerId, new ReportResult(success, filePath)); });除此之外,主线程还希望等待所有任务结束后,再统一统计成功率和失败原因。这里可以用CountDownLatch:
CountDownLatch latch = new CountDownLatch(customers.size()); for (Customer customer : customers) { executor.submit(() -> { try { ... } finally { latch.countDown(); } }); } latch.await(10, TimeUnit.MINUTES);CountDownLatch的原理不复杂,就是一个计数器。每个任务完成时countDown(),主线程await()等待计数器归零。但这里有个细节:await()必须设置超时时间,否则任务卡住不返回,主线程会永久等下去,整个服务就“挂”在那里了。
更现代一点的做法是用CompletableFuture,写起来更优雅:
List<CompletableFuture<Void>> futures = customers.stream() .map(customer -> CompletableFuture.runAsync(() -> { ReportData data = reportService.generate(customer); fileService.writePdf(data); }, executor)) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .join();CompletableFuture的好处是可以很方便地组合异步任务,比如等所有任务结束后再回调,或者在任意一个任务异常时快速失败。项目代码的可读性和可维护性都提升了一个档次。
3. 死锁:并发项目里最容易忽略的定时炸弹
3.1 死锁的四个必要条件,必须刻在脑子里
多线程带来了性能提升,也带来了新问题:多个线程同时争抢共享资源时,可能陷入谁也无法继续执行的状态,这就是死锁。
先看一个生活化类比。十字路口的东、南、西、北四个方向都有车要通过,每辆车都占了当前路口的一半,同时又等待前方的车前进,结果四辆车堵成一团,谁也动不了。死锁就是类似的情况,只不过对象从车变成了线程,资源变成了锁。
死锁产生需要同时满足四个条件:
| 条件 | 说明 | 生活类比 |
|---|---|---|
| 互斥 | 资源同一时刻只能被一个线程占用 | 车道同一时刻只能被一辆车占用 |
| 持有并等待 | 线程持有资源,同时等待别的东西 | 占着路口还等前方让路 |
| 不可剥夺 | 资源不能被其他线程强行抢走 | 车道不是你交让就能让的 |
| 循环等待 | 多个线程形成环形等待链条 | 东等南、南等西、西等北、北等东 |
这四个条件要同时成立才可能死锁。反过来,只要打破其中一个条件,死锁就能被预防。这是整个避坑策略的理论基础,后面所有方法都能在四条件里找到对应关系。
3.2 一个精简易复现的转账死锁示例
理论归理论,理解最深的还是亲手跑一段死锁代码。转账是典型的死锁演示场景:两个账户互相转账,两个线程分别持有不同的锁,又都在等待对方的锁。
public class TransferService { private static final Object lockA = new Object(); private static final Object lockB = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (lockA) { System.out.println("线程1持有账户A的锁"); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockB) { System.out.println("线程1执行转账"); } } }); Thread t2 = new Thread(() -> { synchronized (lockB) { System.out.println("线程2持有账户B的锁"); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockA) { System.out.println("线程2执行转账"); } } }); t1.start(); t2.start(); } }运行这段代码,如果运气好,两个线程会依次执行完;如果时机不对,就会出现线程1持有A的锁等B,线程2持有B的锁等A,两个线程永远卡住。这个过程不需要复杂框架,只要把两个锁的获取顺序写反,死锁就会概率性出现。
这段示例最直观地验证了“循环等待”这个条件:两个线程、两把锁,谁都在等对方释放,谁也不放手。
3.3 死锁的排查路径:线程Dump是诊断金标准
死锁一旦发生,表面现象通常是:服务卡住、接口超时、任务堆积、系统负载不降。最有效的定位手段就是抓线程Dump。
在Java环境下,标准做法是用jstack命令:
jstack <pid>假设服务进程ID是12345,执行后可以看到每个线程的状态和栈信息。死锁场景下,输出中通常会出现一个专门的提示,比如:
Found one Java-level deadlock: "pool-1-thread-1": waiting to lock monitor 0x000000001a2f1e08 (object 0x000000071a5a7f30, a java.lang.Object), which is held by "pool-1-thread-2"这段输出的价值极高:它明确告诉你哪个线程持有哪把锁、哪个线程在等哪把锁、谁被谁卡住。顺着这个信息,回到代码里找到加锁顺序,问题基本就定位了。
如果是Python环境,可以用py-spy dump --pid <进程号>获取所有线程的栈信息,原理类似:把线程当前执行位置对应到源代码行号。我之前排查一个Python多进程程序无响应,就是用py-spy抓到两个线程分别卡在threading.Lock.acquire上,相互等待,锁源锁定得又快又准。
除了线程Dump,日志也是辅助排查的重要依据。平时写并发代码时,建议在加锁前后打日志:
log.info("acquire lock A, thread={}", Thread.currentThread().getName());日志打点虽然啰嗦,但一旦出问题,翻日志能快速看到线程卡在哪个资源上。排查死锁时,最怕“盲猜”,Dump加日志基本能把问题圈定到具体方法。
4. 项目实战:如何在真实代码里规避死锁
4.1 加锁顺序统一:最简单也最有效的办法
回到转账示例,死锁的根源是两个线程按相反顺序获取两把锁。解决办法其实就是“所有人按相同顺序加锁”。
假设账户都有唯一的账户ID,两个账户无论谁向谁转账,都先锁ID小的账户,再锁ID大的账户。这样线程t1和t2获取锁的顺序必然一致,循环等待就不成立了。
public void transfer(String fromAccount, String toAccount, BigDecimal amount) { String firstLock = fromAccount.compareTo(toAccount) < 0 ? fromAccount : toAccount; String secondLock = fromAccount.compareTo(toAccount) < 0 ? toAccount : fromAccount; synchronized (firstLock.intern()) { synchronized (secondLock.intern()) { // 扣款与入账逻辑 } } }这段代码的核心技巧是:给竞争资源设计一个全局排序规则,让所有线程统一遵循。这个规则不一定是账户ID,任何能区分资源的唯一标识都行。
这个方法简单到有点“没技术含量”,但它就是业界最推荐的死锁预防策略之一。代价是需要业务侧配合。比如转账接口、退款接口、对账接口,如果各写各的加锁顺序,死锁风险照样存在。实际项目中,需要把加锁顺序规则写进开发规范里,而不是依赖每个程序员自己领悟。
4.2 让锁“等待有时限”:超时机制是兜底方案
统一加锁顺序能预防大多数死锁,但有些场景资源很多、调用链很长,很难保证所有代码都严格按序。这时候就需要“不要无限期等待”的兜底机制。
Java的synchronized无法设置超时,等不到锁只能一直等。改用ReentrantLock后,可以用tryLock设定等待上限:
ReentrantLock lockA = new ReentrantLock(); ReentrantLock lockB = new ReentrantLock(); if (lockA.tryLock(3, TimeUnit.SECONDS)) { try { if (lockB.tryLock(3, TimeUnit.SECONDS)) { try { // 业务操作 } finally { lockB.unlock(); } } else { // 获取lockB超时,主动释放lockA,避免死等 } } finally { lockA.unlock(); } }tryLock的本质是“如果等不到锁就放弃本次尝试”,配合业务里的重试机制,可以有效打破“持有并等待”的条件。不过我实践中要提醒一句:超时之后不是简单报错误就行,还需要考虑业务一致性。比如转账场景,第一把锁已经拿到,第二把锁超时,后续要不要回滚、要不要重试,都需要设计好。
另一种做法是让等待可中断。使用lock.lockInterruptibly(),允许其他线程通过interrupt打断等待过程。这在某些需要优雅停机的场景下特别好用,服务在关闭时可以让所有线程解除等待,加速退出。
4.3 减少锁竞争:能不用锁就别用锁
死锁出现的前提是要有多把锁互相竞争。换个思路:如果代码里锁的数量变少,甚至完全不用锁,死锁自然无从谈起。
并发工具里的Lock-Free数据结构就是在走这条路。比如读取非常频繁、写入很少的配置数据,使用ConcurrentHashMap、CopyOnWriteArrayList、AtomicLong,都能在没有lock的情况下保证线程安全,性能也远高于加锁方案。
以统计下单量为例,不用锁也能做原子累加:
AtomicLong orderCount = new AtomicLong(0); orderCount.incrementAndGet();原子类的核心是CAS(Compare And Swap)操作。它不是一个锁,而是借助CPU的原子指令实现“比较并交换”,多个线程并发更新时,总有一个线程能成功,失败的就重试。这是“不可剥夺”条件在无锁场景下的另一种解法。
此外,还要注意锁的粒度控制。一个常见的反面案例:为了省事,整个方法加synchronized,几百行业务代码全部被锁覆盖。别的线程想处理请求,全部得排队,系统吞吐量瞬间掉下去。
正确的做法是:锁的范围尽可能小,只保护真正需要同步的临界区。比如更新账户余额只有一行,那就只锁这一行的操作,不要连“查询明细”、“生成流水”都包进锁里面。锁持有时间越短,其他线程等待时间越少,死锁概率自然越低。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
排查并发问题最困难的是“症状千变万化,根源往往相似”。我把平时遇过的问题整理成速查表,方便对照:
| 常见现象 | 可能原因 | 处理建议 |
|---|---|---|
| 接口偶发超时,重启后恢复 | 死锁或线程池任务堆积 | 先抓线程Dump,再查队列深度 |
| 数据重复写入或统计不准 | 多线程操作了非线程安全容器 | 排查HashMap/ArrayList直接并发读写 |
| CPU占用100%,业务无进展 | 线程过多,上下文切换严重 | 检查线程池配置,调整任务拆分粒度 |
| 服务启动后内存持续增长 | 无界队列堆积任务 | 改用有界队列,配合拒绝策略 |
| 任务执行顺序异常 | 并发执行取代了串行语义 | 评估业务是否需要顺序保证,必要时按唯一键分桶加锁 |
| 偶发死锁但无法稳定复现 | 加锁顺序不统一 | 按资源ID排序加锁,并在代码评审时重点检查 |
这个表格不算全能,但覆盖了并发项目里约八成的高频问题。每次排查时,先对照表格,再结合现场监控和Dump信息,效率会高很多。
5.2 一套可复用的并发问题排查套路
排查并发问题不能靠猜,我的经验是固定一套流程走下来,基本不会跑偏。
第一步,先复现。有些并发问题是概率性的,跑十次出现一次。不要急于改代码,先尽量制造触发条件,比如压测、增加线程数、加大数据量。能稳定复现,后面定位就快很多;不能复现,至少也要保留现场数据。
第二步,看线程Dump。线上服务卡死时,第一时间抓Dump。一次Dump可能不够,因为死锁是动态发生的,建议隔几秒抓两三次,对比线程状态的变化。如果线程一直卡在同一个acquire或waiting to lock,嫌疑基本锁定。
第三步,结合日志定位代码行。Dump给出的栈信息最后往往指向代码中的具体方法。回到代码里,看清楚加锁顺序、锁的持有范围、异常处理是否释放了锁。这一步要特别警惕:只要有一个finally里漏了释放锁,表面上看是死锁,实际上可能是资源泄漏加永久阻塞。
第四步,验证修复并做回归。修复以后,不是跑通一次就完事。我会把线程数调大、数据的量级提上去,用超过生产环境的压力跑一段时间,确认问题不再出现。并发问题的回归验证尤其重要,因为很多问题只在临界条件下触发。
最后分享一个实战体会:排查这类问题,最大的坑不是技术本身,而是心态。线上服务卡死,业务方着急,很容易有人“凭感觉”去改配置、加线程,结果问题反而更严重。我自己的做法是拒绝瞎动,先拿Dump和日志把现场固定下来,再动手。数据说话,永远比感觉可靠。
这个内容后续其实还能往更深的方向扩展,比如分布式环境下的锁竞争、数据库层面的死锁处理、协程与线程的取舍。不过对刚接触并发的读者来说,先把单机多线程和死锁的基本功练扎实,后面那些难题都会轻松很多。