做服务端开发这几年并发编程是绕不过去的坎。不管你是刚从“会用Thread”阶段毕业还是已经在生产环境被线上事故教育过最终都会落到同一个问题多线程并发到底应该怎么组织才能既扛住流量又不至于把自己坑死。面试被问到“你的项目里并发是怎么设计的”时很多人能背出线程池参数和synchronized但一旦问到“你这个场景为什么选这个模式”往往就卡壳了。这篇文章我就把多线程并发处理这套东西从底层逻辑到工程实践完整捋一遍把常见的并发模式、同步机制、参数设计和排查手段都展开讲透适合那些想在并发编程上真正形成体系化认知的人。1. 先搞清楚并发到底在解决什么问题1.1 多线程的本质把等待时间变成计算时间先说一个最容易被忽视的前提。多线程不是为了让程序“跑得更快”而是为了把阻塞浪费掉的时间利用起来。举个例子你给每个用户发一条短信如果单线程循环调用短信服务商接口每个请求耗时大约100毫秒那么发100条短信就是10秒。但如果用10个线程并发发理想情况下只需要1秒左右。这里线程带来的加速不是CPU算得快了而是把等待接口返回的时间并行化了。搞清楚这个本质很重要因为它直接影响你后续的选择。如果你的任务本身是纯CPU计算比如复杂数学运算那么线程数超过CPU核心数之后增加线程只会带来更多的上下文切换开销反而变慢。如果任务是IO密集比如调外部接口、读数据库、读写文件那么多线程的价值就非常大。所以多线程要解决的核心问题不是“并行计算”而是“并发等待”。1.2 三个逃不掉的经典并发陷阱并发编程难难在它同时引入的三类问题原子性、可见性、有序性。这三个问题不解决多线程带来的不是性能提升而是各种诡异的bug。原子性i看起来是一行代码但在CPU层面是“读取-修改-写入”三步操作。两个线程同时执行i结果可能只加了一次。这个我当年第一次遇到时调了半天最后加了个synchronized才解决。可见性每个线程运行时会把主内存的变量拷贝到自己的工作内存CPU缓存里。一个线程修改了变量另一个线程不一定能立刻看到。所以才会需要用volatile保证可见性。有序性编译器和CPU为了优化可能对指令进行重排。在单线程下这没问题但在多线程下指令重排可能导致一个线程的“代码顺序”和另一个线程看到的效果不一致。这三个问题我用生活化类比解释一下多线程共享变量就像好几个人共用同一个账本。每个人手里都有一份复印的账页线程工作内存有人改了自己的那页但没同步回真账本主内存其他人看的就是旧数据。至于原子性就像两个人同时往账本上记账一笔没写完另一笔又盖上去账目就乱了。要让这个账本安全就得定规矩要么加锁同一时间只有一个人能记要么原子操作用专门的记账笔一笔完成要么干脆各记各的再合并比如LongAdder那种做法。1.3 什么时候不该用多线程这一点说出来可能有点反直觉但非常重要。不是所有场景都适合上多线程我甚至见过因为滥用多线程导致性能更差的案例。以下三类情况建议谨慎任务执行时间极短比如就做个字符串拼接创建线程和上下文切换的开销远大于任务本身的计算量纯亏。共享状态过多且复杂比如一个对象被十几个线程频繁读写你花在加锁和避免死锁上的精力远远超过收益。任务之间强依赖每个任务必须等前一个任务的结果这种本质上是串行逻辑硬拆成多线程只会增加协调成本。记住一个原则多线程是工具不是目的。你用它是因为它能解决问题如果它制造的问题比解决的还多那就别用。2. 主流并发模式逐层拆解2.1 线程池模式最务实的默认选择线程池是目前Java服务端最常见的并发模式。它解决的核心问题是“线程生命周期开销”和“资源上限控制”。如果每次都new Thread()在高并发下光是创建和销毁线程就能挤占大量资源而且不可控。线程池本质上是一个“线程复用池”把线程数量限制在一个范围内任务来了从池子里取线程执行。用ThreadPoolExecutor时要理解它的几个核心参数它们共同决定线程池的行为corePoolSize核心线程数。即使空闲也不会被回收除非设置了allowCoreThreadTimeOut(true)。maximumPoolSize最大线程数。线程不够用时可以扩到这么多。workQueue任务队列。核心线程都忙时新任务放入队列等待。rejectedExecutionHandler拒绝策略。队列也满、线程也到上限时新任务如何处理。这里有一个很多新手都会踩的坑误以为来了任务就会直接扩容到maximumPoolSize。实际情况是任务提交顺序是“先核心线程 - 再队列 - 最后扩线程”。比如corePoolSize4maximumPoolSize8任务来了5个的时候第5个会进入队列而不会新建线程。只有队列也满了才会创建核心线程之外的额外线程。理解这个顺序你才能解释为什么自己的线程池明明设置了8个最大线程却始终只有4个在跑。我一般推荐的生产配置组合是有界队列比如ArrayBlockingQueue或LinkedBlockingQueue带容量上限CallerRunsPolicy拒绝策略。有界队列强制你面对“任务过多怎么办”这个问题而不是让无界队列默默积压到OOMCallerRunsPolicy则是当任务实在处理不过来时把任务交回提交线程执行相当于一种天然背压不会突然把请求全拒绝掉。还有一个被忽略但非常实用的细节自定义ThreadFactory给线程起有意义的名称。比如order-async-worker-1排查问题时看线程dump一眼就能定位是哪个业务线程池出问题而不是看到一堆pool-3-thread-5。2.2 生产者-消费者模式解耦的不二法门生产者-消费者模式在多线程里出现频率极高。它的核心思想是生产任务的线程和消费任务的线程不直接通信而是通过一个缓冲区解耦。这个缓冲区就是各种BlockingQueue。典型场景比如异步写日志、异步发送通知、处理上报事件等。生产者把日志消息放入队列消费者线程从队列里取出来批量写磁盘。这样做的好处有三个一是生产者和消费者的处理速度可以不同步生产者不需要等消费者处理完二是可以缓冲流量尖峰三是两边可以独立扩容消费者处理不过来就加消费者线程。选队列时要注意它们的差异ArrayBlockingQueue有界底层是数组可以指定公平性fairtrue时按FIFO顺序获取。LinkedBlockingQueue底层是链表可以无界也可以有界。默认构造是无界的容易内存暴涨必须注意。SynchronousQueue不存任务生产者直接把任务“递”给消费者没有缓冲区。适合一对一衔接要求很高的场景。DelayQueue延迟队列任务要等到指定延迟时间后才能被消费适合做定时任务的简单实现。我实际使用中发现很多线上事故都出在无界队列上。生产者速度一旦超过消费者任务就会无限堆积占满内存。所以只要用队列就一定要设容量上限。同时建议在队列里埋一个水位监控当队列长度超过阈值时报警这样你能在内存爆掉之前介入。2.3 Fork/Join分治模式天然适配可拆分的任务Fork/Join模式的核心是“分而治之”。当一个大任务可以被拆成多个互不依赖的子任务时用Fork/Join把子任务分发到不同线程每个线程处理一部分最后汇总结果。最典型的例子是大规模数组求和、归并排序、并行计算等。Fork/Join框架最核心的设计是“工作窃取”Work-Stealing。每个线程维护一个双端队列自己处理完队列里的任务后会去其他线程的队列“偷”任务来干这样能最大化线程利用率。这一点和普通线程池不同普通线程池的任务量可能分配不均有的线程忙死有的线程闲死Fork/Join通过窃取机制让闲下来的线程帮忙。用Fork/Join时最容易犯的错误是在子任务里做阻塞IO操作。比如在compute()里调数据库或者外部接口这样一来工作窃取的线程会大量阻塞在等待上得不偿失。Fork/Join适合的是计算密集型任务不是IO密集型任务。还有一个要注意的点ForkJoinPool.commonPool()是JDK并行流parallelStream默认使用的线程池。如果你在并行流里跑的是耗时操作会和其他使用并行流的代码互相干扰。我之前排查过一次性能抖动最后发现是有个地方的并行流里调了慢接口把整个commonPool的线程都拖住了导致其他用并行流的业务也跟着变慢。所以并行流里千万不要放阻塞操作真要放就自己建一个独立的ForkJoinPool。2.4 Actor模式与消息驱动把共享状态藏起来Actor模式的思想在Java生态里没有在Erlang/Go里那么普及但它的设计理念值得每个做并发的人学习。核心就一句话不共享内存通过消息通信。每个Actor有自己的状态状态不对外暴露其他Actor只能通过发送消息让它处理。Actor之间天然没有锁竞争因为状态永远只属于某一个Actor不存在多个线程同时操作同一个对象的问题。这个模式对Java开发者的启发是并发问题的根源在于“多人同时改一个东西”那如果从一开始就设计成“消息传递单一拥有者”很多问题就不存在了。其实你也不用非得引入重型框架。很多轻量级设计本质上就是Actor思想的简化比如单线程事件循环所有任务扔进一个队列由一个线程顺序处理天然无锁。Netty的EventLoop就是典型代表一个Channel上的事件永远只由一个EventLoop线程处理所以Channel内部状态不需要加锁。这种“尽可能减少共享用队列和消息来协作”的思路在并发设计里是最高级的。能不加锁就不加锁能局部加锁就不全局加锁能用消息协作就不用共享状态这些原则才是真正能降低复杂度的。2.5 Future模式与异步编排让依赖关系清晰起来Future模式解决的是“异步结果获取”的问题。传统做法是提交任务后线程阻塞等待结果但Future实现的是“先做别的事需要时再要结果”。Java里最基础的FutureTask配合Future.get()使用已经能实现这种效果。但真正的重头戏是JDK8引入的CompletableFuture它把“回调”和“组合”做到了语言层面。你能直观地表达“任务A完成后异步执行任务B然后把结果和任务C的结果合并”整个依赖链条像流水线一样清晰。CompletableFutureInteger a CompletableFuture.supplyAsync(() - 1); CompletableFutureInteger b CompletableFuture.supplyAsync(() - 2); CompletableFutureInteger c a.thenCombineAsync(b, (r1, r2) - r1 r2);这段代码的意思是a和b并行执行都完成后把结果加起来。在实际业务里我经常用它来并行调用多个下游接口然后汇总结果返回。比如一个页面需要同时展示用户信息、订单列表、优惠券状态这三个接口相互独立串行调用要300毫秒用CompletableFuture并行调用能把耗时压到最慢的那个接口时间。用CompletableFuture有几个容易踩的坑忘记配置线程池。supplyAsync()默认使用ForkJoinPool.commonPool()同样会被其他任务拖累。重要业务建议显式传入自定义线程池。异常处理链不能断。任何一个环节抛异常后面的组合操作都会直接短路你需要用exceptionally()或handle()接住异常。超时控制。虽然JDK9开始有orTimeout()但如果你的项目还停在JDK8就得在任务内部自己做超时保护或者配合Future.get(timeout, TimeUnit)使用。3. 同步与协作锁、CAS、并发容器3.1 锁怎么选synchronized 还是 LockJava里最基础的锁是synchronized从JDK1.6开始做过大量优化引入了偏向锁、轻量级锁、重量级锁的升级过程。在锁竞争不激烈的情况下性能已经很好而且使用简单不会忘记释放。很多场景下它是首选不需要一上来就上ReentrantLock。但有些场景synchronized确实不够用需要可中断的锁获取线程尝试拿锁时可以被打断。需要带超时的获取拿不到锁就等待一段时间而不是永久阻塞。需要公平锁按请求顺序分配锁。需要用Condition做复杂的等待唤醒协作比如“队列满时生产者等待队列空时消费者等待”。ReentrantLock在这些场景下更合适。但使用它必须记住一个铁律在finally块里释放锁否则一旦中间抛异常锁会一直被持有其他线程就永久阻塞了。维度synchronizedReentrantLock底层实现JVM层面经优化后性能优秀JDK层面基于AQS获取方式自动获取/释放手动lock/unlock可中断不支持支持 lockInterruptibly()超时获取不支持支持 tryLock(timeout, unit)公平性非公平可设置公平/非公平条件等待只有wait/notify支持多个Condition对象使用复杂度低中实际开发里最实用的还是tryLock在拿不到锁时快速失败而不是一直卡着。比如多台机器上的定时任务有互斥需求本地用一个tryLock配合超时抢不到锁就跳过本轮避免重复执行。3.2 CAS与无锁编程向左还是向右CASCompare And Swap是无锁编程的基础。它是一条CPU指令比较内存中的值与预期值一致则更新为新值整个过程是原子的。Java里AtomicInteger、AtomicLong、AtomicBoolean等都是基于CAS实现的。CAS避免了锁带来的线程阻塞和上下文切换但也不是银弹它有两个典型问题ABA问题一个值从A变成B又变回ACAS检测不到变化。解决思路是加版本号比如AtomicStampedReference。自旋开销CAS失败后线程不会阻塞而是不断重试在高竞争场景下会白白消耗CPU。所以CAS适合竞争不激烈的场景竞争激烈时锁反而更高效。这里我特别想提一下LongAdder它是JDK8引入的计数器在AtomicLong的基础上做了分段优化把单点竞争拆成多个单元最后求和。在高并发下计数性能比AtomicLong好得多。像我之前做限流器的滑动窗口计数QPS高的时候用LongAdder比AtomicLong稳定很多。但注意LongAdder更适合“统计”而不是“精确计数”因为它最终是汇总值中间态的sum可能略有偏差。3.3 并发容器怎么选一图看懂它的演进思路并发容器是并发编程的最佳实践集锦。以ConcurrentHashMap为例JDK1.7时代用了分段锁把整个Map分成多个Segment降低锁竞争JDK1.8以后放弃了分段锁改用了CAS synchronized锁单个桶桶内链表头节点锁粒度更细并发度更高。实际选型时我一般按这样的思路来高频读、低频写ConcurrentHashMap基本能胜任读操作无锁写操作只锁冲突的桶。读多写少、且数据量不大CopyOnWriteArrayList读操作完全无锁写操作复制一份副本。适合读频繁、写极少的场景比如白名单、配置信息。写多了性能很差因为有数组复制开销。需要按序处理各种BlockingQueue按需求选有界、无界、延迟等。需要线程安全的复杂复合操作优先考虑用锁包住业务逻辑而不是依赖单个容器的原子操作。比如“检查存在再插入”这种check-then-act光靠ConcurrentHashMap不能保证复合操作原子的得用computeIfAbsent这类原子方法。3.4 线程协作工具CountDownLatch、CyclicBarrier、Semaphore这三个工具经常被放在一起比较但它们的语义完全不同CountDownLatch是“倒计时门闩”。一个或多个线程等待其他线程完成指定数量的事情然后放行。它是不可复用的计数到0就作废。CyclicBarrier是“循环栅栏”。一组线程互相等待全部到达后同时继续执行。它是可复用的一轮结束后可以重新使用。Semaphore是“信号量”。控制同时访问某个资源的线程数量。相当于一个可动态释放的限流闸门。我用过最典型的CountDownLatch场景是并发测试启动N个线程每个线程准备好后await()主线程调用一次countDown()放行所有线程让它们同一时刻发起请求。CyclicBarrier更适合那种需要多线程分阶段协同的任务比如多阶段并行计算每个阶段所有线程到齐后再统一进入下一阶段。Semaphore最常见的用途是简单限流比如一个接口最多允许5个线程同时访问数据库。4. 工程上最容易被问爆的参数与性能考量4.1 线程数到底怎么定这是很多人面试或者设计系统时最关心的一个问题。网上流传的“CPU密集N核1IO密集2倍核数”只是个粗略的经验值并不适用所有情况。更科学的方法是套用一个简单模型最佳线程数 Ncpu * Ucpu * (1 W / C)其中Ncpu是CPU核心数Ucpu是目标CPU利用率比如你希望CPU跑多少比例通常设为0.8~1.0W是线程等待时间IO等待、锁等待C是线程计算时间。如果W远大于C比如调外部接口要100毫秒本地计算只要1毫秒那么W/C就是100最佳线程数可以很激进地放大。这也是为什么IO密集场景下几百个线程也能合理存在。反过来如果C远大于W比如纯CPU计算那么线程数接近核心数就足够了。举个例子。一个4核机器目标CPU利用率0.8每个请求等待下游接口耗时200ms本地计算耗时10ms。那么最佳线程数 4 * 0.8 * (1 20) 67.2约等于67个线程。这个公式的好处是能把你的估算逻辑讲清楚而不是随手填一个数字。4.2 上下文切换的成本多线程之所以“快”本质上是在把等待的时间给利用起来。但线程切换本身是有代价的从用户态切换到内核态保存和恢复线程上下文CPU缓存失效等。当线程数量远远超过合理值时性能可能会不升反降。怎么判断线程是不是太多了最直接的方式是用vmstat看cs列上下文切换次数。如果上下文切换每秒几十万次同时CPU的wa等待很低很可能是线程过多了。另一个工具是jstat配合JVM线程统计能看到线程数量和运行状态。我之前有个非常直观的经历一个服务把线程池参数设置成200觉得这样吞吐一定高。结果压测时发现TPS只有3000CPU却已经很高了。把线程池降到32之后TPS反而到了5000CPU也更低了。原因就是线程太多导致大量上下文切换白白浪费了CPU。从那以后我就意识到线程数不是越大越好合理范围内的“恰到好处”远胜于盲目堆线程。4.3 锁粒度与锁剥离小锁与大锁的博弈锁的粒度直接影响并发能力。最理想的情况是无锁其次是小粒度锁最差的是大粒度锁。但这个结论不能走向极端因为锁粒度越细获取锁的次数往往越多死锁的风险也越高。关键是要学会“按需锁”。举个例子读多写少的缓存场景。如果用ReentrantReadWriteLock多个读线程可以并行写线程独占比全量synchronized并发能力好很多。但Java中有个更极致的优化StampedLock它支持乐观读读操作不加锁先直接读取数据读取完了再验证期间有没有发生写操作没有就成功返回有则升级为悲观读。这种设计在读多写少且一致性要求不是绝对苛刻的场景下性能非常亮眼。除了粒度还有一个实战技巧叫“锁剥离”不要把一把锁保护所有资源而是按资源ID分片。唯一要注意的是分片过多会带来爆增的对象开销。像ConcurrentHashMap选择锁定单个桶而不是全局锁已经是最经典的锁剥离示范了。5. 常见并发问题与排查实操实录5.1 死锁快速定位jstack看穿一切死锁是并发编程里最经典也最难缠的问题。两个线程各自持有一把锁又都想获取对方手里的锁然后互相等待谁也跑不动。排查死锁最快的方法是使用JDK自带的jstack直接输出线程转储Thread Dump。当发生死锁时jstack会在末尾明确输出“Found one Java-level deadlock”的字样并列出涉及的锁和线程。我通常会在压测环境复现问题然后连着抓几次jstack对比线程状态是否一直卡在BLOCKED上。避免死锁要从源头入手最有效的策略有两个一是“固定锁顺序”所有线程都按同一个顺序获取多个锁就不会出现循环等待二是用tryLock加超时抢不到锁就回退释放已持有的锁宁可失败重试也不死等。5.2 线程池被占满导致服务雪崩这是一个我踩过很深的大坑。现象是高峰期服务突然大量超时日志里出现RejectedExecutionException。一开始大家都以为是数据库撑不住结果查下来是线程池满任务被快速拒绝大量请求直接失败连锁反应把下游拖垮。根因有两个一是某个慢调用占用了线程池里的所有线程二是有界队列被填满。最常见的情形是核心业务线程池里混入了某个调外部接口的慢操作这个操作平均耗时3秒直接把几十个线程全占满队列也塞满了后续请求。应对方案线程池隔离把慢调用、非核心业务放到独立的线程池互相不干扰。这相当于给系统做了资源隔离。合理设置拒绝策略不要用默认的AbortPolicy直接抛异常根据业务选择CallerRunsPolicy调用者自己执行形成背压或让任务降级。监控队列深度对ThreadPoolExecutor的队列长度做监控超过阈值就报警在雪崩发生前提前介入。给任务设超时Future.get加超时避免线程无限阻塞在一个慢调用上。5.3 异步提交任务里的异常被静默吞掉这是多线程编程里“最安静”的坑。用executor.execute(task)提交任务时如果任务内部抛异常默认情况下异常不会打印到日志也不会影响其他任务就像什么都没发生过一样。你只知道某些数据处理失败了但完全找不到原因。我第一次踩这个坑是线上某个统计数据一直不对排查了一下午最后才发现是异步任务里某个字段解析抛了NullPointerException被线程池悄悄吞掉了。从那以后我定了一个规矩所有提交给线程池的任务内部必须有try-catch兜底并且打上日志。或者统一设置一个ThreadFactory给线程设置setUncaughtExceptionHandler捕获所有未处理异常。还有一个细节如果你用的是executor.submit(task)返回的Future会封装异常但如果你从不调用future.get()异常依然是沉默的。所以submit之后要么确保后面会get要么在任务内部自己catch住并记录。5.4 排查实战一次请求延迟抖动定位最后分享一个完整的排查案例这是我处理过的一个典型问题。某服务的P99延迟从50毫秒涨到500毫秒接口本身逻辑很简单就是查一次数据库然后返回。开始怀疑数据库慢查询但数据库指标正常。我做了三件事抓线程堆栈。连续抓3次jstack间隔5秒发现大量线程处于同一个锁的BLOCKED状态。顺着锁找代码。发现某个工具类里有个ConcurrentHashMap的高频写操作写操作内部又做了耗时2秒的外部调用。本来以为这个外部调用放在锁外但实际写了才发现某次代码重构后这个调用被挪到了锁内。确认根因。一个线程持锁做慢调用其他线程全部阻塞等待P99自然飙升。修复方案很简单把外部调用移出锁块锁只保护共享变量写入P99立刻恢复。这次排查给了我三个经验值得每一个并发开发记住第一线程dump是排查并发问题的第一工具一定要会用第二锁块里绝不能有IO、网络等慢操作这是铁律第三任何一次代码重构都可能悄悄改变锁的临界区范围上线前的性能回归测试不能省。我个人这几年最大的体会是并发编程没有什么一劳永逸的方案它考验的是你在“性能”和“复杂度”之间做权衡的能力。线程池不是越大越好锁不是完全不用才好模式也不是越高级越正确。多拆解自己线上遇到的并发问题比看再多理论都管用。希望这篇东西能给正在折腾多线程的各位一些实在的参考。