大概每个写过一段时间Java多线程的人都会有这种感觉:学的时候每个知识点都能听懂,Thread、synchronized、锁、线程池、ConcurrentHashMap,单独拎出来讲都能聊上几句,可真到线上出了问题,或者在设计一个并发模块时,脑子里的知识点全变成了一盘散沙,不知道从哪个下手。我这个JAVA进阶系列的Thread学习写到第12篇,按计划本来该继续往下讲新东西,但写到这我反而觉得,最该做的是一次“多线程小结”。不是简单罗列学过的内容,而是把那些散落的知识点重新串成体系,顺便把这些年实际写并发代码踩过的坑、总结的判断逻辑一并收进来。
这篇小结是写给两类人的:一类是刚学完Java基础、正在啃多线程的开发者,看完以后能知道哪些知识点是真正的核心主线,哪些是边角料;另一类是已经在写业务代码、但遇到并发问题还是靠猜的开发者,这篇里会给出排查死锁、配置线程池、选择并发容器的具体判断链路。小结类文章最忌讳写成目录式的知识点堆砌,所以我不会挨个复习API,而是按“为什么这些知识点会以这样的方式存在”来讲——当你理解了每个设计背后的矛盾,知识才算真正是你的。
1. 为什么学到第12篇我才觉得“终于入门了”
1.1 多线程的本质是“资源竞争的治理”,不是“开线程的语法”
很多人学多线程的第一个动作就是看Thread类和Runnable接口的用法,跑通一个demo之后就觉得自己会了。但说句不好听的,光会启动线程和调用start(),距离“会写多线程”还差着十万八千里。多线程编程的真正难点从来不在“怎么创建线程”,而在于你要同时让多个线程共享数据、互相协作、还要保证性能和正确性,这本质上是一道资源竞争治理题。
举个最常见的例子:一个电商库存扣减逻辑,单线程下就是库存 > 0然后减一,没有任何问题。一旦并发,两个请求同时读到库存为1,都判断“够了”,然后都执行减一,库存变成了-1。你没改任何业务代码,只是引入了多线程,正确性就崩了。所以我在总结时把所有的知识分成两类:一类是解决“数据安全”的(synchronized、锁、volatile、原子类、ThreadLocal),另一类是解决“任务调度”的(线程池、Future、CompletableFuture、同步工具类)。两者互相独立又互相配合,这构成了整张知识地图的第一层骨架。
学到这里我慢慢想明白一件事:并发编程里那些眼花缭乱的技术方案,本质上都是在对同一个问题进行回答——多个执行流同时访问共享资源时,如何让结果仍然正确,且性能还能接受。
1.2 我用三条主线把知识点收拢起来
如果让我把整个多线程的知识体系压缩成三句话,我会这样概括:
第一,线程是执行任务的载体,它的生命周期、调度方式决定了程序运行时底层发生了什么;第二,数据在线程之间怎么流动才是安全的,依赖的是Java内存模型(JMM)那套规则;第三,把单任务抽象成批量任务后,如何管理线程的创建、复用、排队和拒绝,这是线程池和异步编排工具解决的。
这三条主线不是平行的,它们是层层嵌套的关系。线程池是管理线程的容器,而线程池里的任务在执行的瞬间,内部仍然会发生数据竞争;你用CountDownLatch去协调多个线程的进度,但等这些线程真正开始读写共享变量时,又回到了可见性和锁的问题上。想通了这三层嵌套,再看任何并发代码,你都能第一时间判断出作者在解决哪一层的问题。
这也是我在写第12篇小结时最大的感受:知识不是学得越多越好,而是要让每个知识点都能安放在它该在的位置上。比如Thread.yield()这种冷门方法,你单独背它的语义永远记不住,但放在“线程状态切换”这条主线上,你就理解了它只是把当前线程从运行状态让出来,重新竞争CPU,仅此而已。
1.3 这篇小结适合谁看
我先说清楚这篇小结的适用范围,避免读者走弯路。如果你完全没接触过多线程,连new Thread()都要查文档,建议先去把Java基础语法过一遍,再看这篇,否则部分段落会缺少上下文。如果你已经会用synchronized、会写ExecutorService,但遇到以下几个场景依然头疼,那么这篇小结正好适合你:
- 在多个线程里共享了一个
HashMap,偶尔会卡死或数据错乱; - 写了一个多线程跑批程序,上线后任务经常丢失或者线程数暴涨;
- 排查线上问题时,看到线程dump里一堆
WAITING状态,不知道从哪里下手; - 两个服务互相调用,偶发超时,重启就好,但隔几天又犯。
总结一句话:这篇小结解决的是“知识会了,但不会用”的问题。下面每一章都会从具体矛盾出发,把相关知识点拉通来复盘。
2. 六状态与JMM:基础中最容易被低估的两块地基
2.1 线程六状态在调试日志里的真实模样
先聊一个看起来最简单、但很多人其实记不准确的话题:Java线程的生命周期。网上很多文章会告诉你线程有六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这六个名字背下来不难,难的是你在真实日志里看到这堆状态时能不能一眼判断出发生了什么。
我见过不少开发者在线上排查问题时的第一反应就是“线程卡死了”,但卡死有很多种。如果看到RUNNABLE状态的线程大量堆积,这往往是线程在跑CPU密集计算,或者正在等一个内核态的锁;而BLOCKED状态说明线程想进入一个synchronized代码块但锁被其他线程占着;WAITING是线程调用了wait()或join()之后挂起;TIMED_WAITING则是在等待一个带超时时间的操作。
这里最容易忽略的一个细节是:RUNNABLE状态其实包含了两种情况,一种是线程正在CPU上执行,另一种是线程已经就绪但正在等待CPU时间片。所以在Linux上用top命令看到某个进程CPU不高,但线程池里一堆RUNNABLE,不一定是它在忙计算,也可能是它在反复进行系统调用,比如FileInputStream的阻塞读,在Java层面依然会被标记成RUNNABLE,真正的等待被操作系统吞掉了。这个经验在排查IO密集场景时特别重要,不然你会误判线程在空转。
2.2 volatile、可见性与happens-before规则
六状态讲的是线程“在干什么”,接下来这个问题讲的是线程“能看到什么”。Java内存模型里有一条最反直觉的规则:两个线程同时读写同一个变量,A线程改了值,B线程不一定看得到。原因不复杂,每个线程在工作内存里会缓存变量的副本,CPU还有寄存器、多级缓存,写操作什么时候刷回主内存,并没有绝对的实时保证。
volatile这个关键字解决的就是这个可见性问题:它保证了对变量的写操作会立即刷新到主内存,读操作会从主内存重新加载。但它只保证单个变量读写时的可见性,不保证复合操作的原子性。比如volatile int count,count++这个操作依然是先读后写两步,两个线程同时执行时照样会丢更新。所以我才说,volatile最典型的应用场景是状态标志位:一个布尔值控制循环是否继续,一个枚举控制初始化状态,这些写少读多的场景它很合适。
比volatile更重要的是它背后牵连的happens-before规则。Java内存模型定义了一套规则,只要两个操作满足happens-before关系,前一个操作的结果就对后一个操作可见。我用大白话解释一下:它的意思是“如果A操作在时间逻辑上先于B操作,并且两者有happens-before关系,那么A对内存的修改在B执行时一定能被看到”。这里不需要你去背所有的规则条文,但有一条必须记牢:对一个锁的解锁操作,一定happens-before于后来对这个锁的加锁操作。这就是为什么synchronized能同时保证原子性、可见性和有序性,它是多线程安全兜底的那块砖。
2.3 synchronized到底“锁”住了什么:锁升级这条暗线
synchronized可能是Java里用得最多的并发关键字,但很多人用它的时候并不知道它在底层经历了什么。早期JDK里它是纯粹的重型锁,每次加锁解锁都要通过操作系统级别的互斥量,性能很差。后来JDK 1.6做了大改,引入了偏向锁、轻量级锁、重量级锁的升级路径,也就是说synchronized的性能并不是一成不变的慢,它会在竞争不激烈时退化成很轻量级的操作。
锁升级这条暗线到底怎么回事?我简化来说:一个对象第一次被某个线程加锁时,会先尝试偏向锁,相当于在对象头里记录“这个锁偏向于某线程”,之后该线程再次进入时不需要任何CAS操作;如果有别的线程来竞争,偏向锁撤销,升级成轻量级锁,这个阶段通过CAS自旋来抢锁,不阻塞;如果自旋都抢不到,说明竞争真正激烈了,再膨胀为重量级锁,进入阻塞队列。
理解这条升级路径对实际开发有直接帮助。你会发现,同样是synchronized,在低并发下和高并发下的成本差异很大,因此不要听了“同步有开销”就过度设计,也不要在高竞争场景里把希望寄托在自旋上。我在做性能排查时见过一种典型问题:某服务把一段执行耗时很短的代码块加了锁,但这段代码会被极高频调用,导致锁竞争非常厉害,线程大量阻塞。这个问题的解法不是去换一种锁,而是要么把锁粒度拆细,要么用乐观锁思路改造,后面我还会细讲。
2.4 ThreadLocal的隔离和泄漏,一个案例讲透
多线程里“共享”带来的麻烦太多,所以有一种设计干脆走反向思路:不共享。ThreadLocal就是让每个线程维护自己独立的变量副本,彼此看不见。它最常见的应用是传递上下文信息,比如把用户ID、链路追踪ID放在ThreadLocal里,在同一个线程的各个方法间共享,但又不污染其它线程。
真正让ThreadLocal从“好用”变成“危险”的,是它在线程池场景下的内存泄漏问题。原理说起来倒也直白:每个ThreadLocal实例作为key存在线程的一个Map里,这个Map的key被设计成弱引用,但value是强引用。线程池里的线程生命周期很长,线程存活期间,value会一直被这个Map引用住,如果你不用了也没调用remove(),你就永远“抱着”这个对象不放。如果value是几百兆的缓存对象,累积几次就会内存爆炸。
我之前接手过一个线上OOM问题,就是有人把一个大批量的中间结果集存在ThreadLocal里,任务执行完没有清理,线程池又是核心线程常驻的配置,结果内存一路涨到物理极限。定位方法不复杂,用内存分析工具看线程对象的关联引用,一抓一个准。从那以后我就给自己定了一条规矩:ThreadLocal的清理必须在finally块里remove(),写完就删,不留侥幸。这是教科书里不会强调、但生产环境一定会遇到的经验。
3. 锁的复盘:选择、粒度与死锁排查链路
3.1 synchronized还是ReentrantLock,我的选择标准
ReentrantLock和synchronized的选择是一个被问烂了的问题,但这里最容易犯的错误是“为了显高级而用ReentrantLock”。我需要把选择标准讲清楚:synchronized是语言层面内置的,用起来简单、不容易出错,JVM会自动管理锁的释放,发生异常也不会死锁,性能上经过锁升级优化后,绝大多数场景下已经足够;ReentrantLock的优势集中在高级能力上,比如可中断地获取锁(lockInterruptibly())、尝试非阻塞获取锁(tryLock())、公平锁、条件变量等。
我的标准是:如果只需要基本的互斥,优先synchronized;如果你需要在获取锁的过程中响应中断、需要限时等待、需要多个条件队列,再上ReentrantLock;如果是特别追求公平性(先来先服务),那synchronized做不到,得用ReentrantLock(true)。公平锁在Java里是真公平,但付出的代价是更多的上下文切换,所以我在业务代码里几乎不用公平锁——大多数场景下,我们要的只是“最终不出错”,不是“绝对公平”。
这里补充一个容易踩的坑:ReentrantLock必须在finally里解锁,而且解锁操作和加锁操作要配对。我见过一个代码,在tryLock()成功之后忘了解锁,导致业务线程越积越多,最后整个服务假死。排查到最后才发现是锁没有被释放,不是业务逻辑的问题。用ReentrantLock比用synchronized多了一份“自己负责”的成本,这是选型时必须想清楚的。
3.2 锁粒度演进:从单点锁到分段式的思路
有时候代码里只有一个锁,功能也对,但并发性能就是上不去,这时候问题往往出在锁粒度上。锁的粒度太粗,所有线程都挤在一个入口,相当于把多线程退化成了串行执行;锁粒度太细,又会因为频繁加锁解锁产生额外开销,甚至引入复杂的逻辑错误。
我在做数据缓存刷新时曾遇到过这个典型问题:一个热点HashMap承载了全量配置数据,每次配置变更就锁整个HashMap并全量覆盖。配置变更虽然不频繁,但这个锁把所有读线程也挡住了,因为读取时也要拿同一个锁保证不会读到写入一半的数据。后来我把方案改成读写锁:读并发走ReadLock,多个读者可以并行;只有写的时候才拿WriteLock。就这么一改,同一样本流量下接口耗时下降了四成。
顺着这个思路再往下走一步,就是分段锁思想。ConcurrentHashMap在JDK 1.7时代用的就是分段锁,把整个哈希表切成一段一段,每段一把锁,不同段的读写可以并行。这种思想在现代并发代码里依然很值得借鉴:当你觉得一把大锁拖垮了吞吐量,先别急着上分布式锁或者无锁数据结构,想想能不能把数据按照某种维度拆成独立的桶,让竞争只发生在同一个桶内。这才是性价比最高的演进路径。
3.3 一次死锁的定位过程:jstack完整排查链路
死锁是多线程里后果最严重的故障之一,特点是服务不崩溃,但相关线程全部卡死,接口超时堆积。纯粹的代码审查很难提前发现所有死锁路径,因为死锁需要满足四个条件同时成立:互斥、持有并等待、不可剥夺、循环等待。而实际代码里锁的顺序往往散落在不同方法中,肉眼几乎不可能穷举。
有一次线上故障,我们的一个任务调度模块突然大面积超时,重启之后恢复,但过几个小时又复发。我当时的排查链路是这样走的:先打线程dump,在Linux上用jstack <pid>输出所有线程的状态;然后过滤出BLOCKED和WAITING状态的线程,重点关注那些“locked”和“waiting to lock”成对出现的记录。很快我就发现线程A持有了一把A锁,正在等B锁;线程B持有B锁,正在等A锁,一个标准的循环等待。
下一步是定位到代码位置。线程dump里每一帧都带着类名和方法名,顺藤摸瓜找到那两处加锁的方法,很快就看清了:订单状态变更和退款操作都涉及两个资源,但两处代码加锁的顺序不一致,一个先锁订单再锁用户,另一个反过来。在低并发下两个操作碰上的概率很低,一旦流量上来,交叉等待就发生了。修复方案也很简单:统一加锁顺序,先锁订单再锁用户,并在代码注释里写清楚这条全局约定。这个习惯我建议每个团队都建立起来:凡是涉及多把锁的操作,团队内必须约定一个全局的锁顺序,并写进代码规范。
3.4 读写锁与StampedLock的真实定位
ReentrantReadWriteLock和StampedLock都是对“锁粒度优化”这个思路的延伸。先说ReentrantReadWriteLock,它把锁分成读锁和写锁:读锁之间不互斥,写锁和任何锁都互斥。这个设计对“读多写少”的场景非常友好,比如配置中心、路由表、商品详情缓存这类数据,读的频率远远高于写。但要注意,读写锁有锁升级的坑:不能在一个线程持有读锁的情况下再去获取写锁,否则会死锁。我见过有人在这个问题上栽过跟头,最后只能通过先释放读锁再抢写锁来解决。
StampedLock是JDK 8引入的更进阶的方案,它引入了乐观读模式:读取数据时不加锁,而是先拿到一个stamp(印戳),读完之后再验证这个stamp是否仍然有效。如果有效,说明读的期间没人写过;如果失效,再升级为悲观读锁或写锁重新读。这种乐观读在数据真正很少被写的时候性能极高,几乎是纯内存读的成本。但StampedLock不适合在锁内部调用可重入的操作,也不支持条件变量,所以它更像一把手术刀,用来精雕细琢特定场景,不是日常通用的那类锁。
我自己的判断是:绝大多数读多写少场景,ReentrantReadWriteLock已经够用;只有当压测数据明确告诉你读路径确实成了瓶颈时,才值得用StampedLock去抢那一点极致性能。并发工具很多,但成本、复杂度总是跟着性能提升一起增长,选型要控制自己的贪心。
4. 线程池:生产环境真正的“并发阀门”
4.1 七大参数的决策逻辑:先想清楚回调,再写代码
线程池是一片被误解很深的知识点。很多人看到ThreadPoolExecutor的构造方法里有七个参数就头大,但我想换个角度来讲:把这七个参数当成一次“服务容量规划”的决策过程,而不是死记参数含义。
corePoolSize是常态下的线程数量,相当于你的服务每天需要保底多少人干活;maximumPoolSize是极限情况下最多能扩到多少人;keepAliveTime是超出核心的线程空闲多久后可以裁掉;workQueue是任务太多、核心线程都忙时,任务先在哪里排队;threadFactory控制线程如何创建,命名、是否守护;handler是队列都满了之后,新任务怎么处理。
关键的运行逻辑必须捋清楚:任务来了先找核心线程,核心满了就塞队列,队列满了才开始扩线程,直到maximumPoolSize,再满了才触发拒绝策略。这个顺序和很多人的直觉相反——线程池并不是“先扩线程再排队”,而是“队列比扩容优先”。理解这一步,你才能解释为什么maximumPoolSize设得再大,队列没满时也触发不了扩容。
我在设计线程池时一共要敲定四件事:线程数、队列类型、拒绝策略、线程命名。后面三个都可以对应到具体场景拍板,但线程数往往需要结合一个经验公式来估算,下面会专门聊。
4.2 队列选型的差异,以及为什么无界队列要警惕
workQueue是线程池里最容易被忽略、但影响最大的参数。它的类型决定了任务在内存里如何排队,也决定了你在高流量下会最先遇到什么问题。队列主要分成有界和无界两类,有界队列又有数组实现和链表实现之分,ArrayBlockingQueue容量固定、LinkedBlockingQueue默认可以无界(也可以设置容量),SynchronousQueue则是一个零容量的直传队列,不存储任务,直接转给线程。
这里最要命的是无界队列的陷阱:如果用了无界队列,maximumPoolSize和拒绝策略实际上永远没有触发机会。因为队列永远不会满,任务只会一直往内存里堆。这个在短期内看是“永不拒绝”,似乎很安全,但本质是拿内存换安全。我见过一个定时任务服务,消息生产速度偶尔暴涨,线程池用的是无界队列,核心线程处理不过来,消息积压了几个小时,内存被堆到OOM。如果当时用的是有界队列,最多是触发拒绝策略,配合监控报警,问题早就能暴露。
所以在生产环境的线程池配置里,我几乎总是选择有界队列,容量要根据业务情况进行估算,宁可偶尔触发拒绝,也绝不能让内存悄无声息地爆掉。控制风险的前提是让风险显性化,无界队列恰恰抹掉了这道预警信号。
4.3 拒绝策略的设计:比默认策略多做两件事
当队列和线程都满了,新提交的任务就交给拒绝策略处理。JDK默认提供了四种策略:AbortPolicy直接抛异常,CallerRunsPolicy让提交任务的线程自己去执行(等于把压力回推给调用方),DiscardPolicy静默丢弃,DiscardOldestPolicy丢弃队列里最老的任务。很多人不假思索地用默认的AbortPolicy,结果高流量下一堆RejectedExecutionException打到业务层,接口一片报错模板。
我的做法是即便选了AbortPolicy或者CallerRunsPolicy,也一定会做两件额外的事:第一,自定义ThreadFactory,把线程名统一命名成可辨识的格式(比如order-async-worker-1),这样线程dump里一眼能看出异常来自哪个线程池;第二,在拒绝策略里加监控埋点,把拒绝数量、线程池活跃度、队列剩余容量打成日志或指标,配合告警系统。
记录一下我踩过的具体教训:有一次大促流量上来,一个线程池的队列容量设得太小,触发了大量拒绝。因为默认的AbortPolicy只是抛异常,异常虽然被业务层抓到了,但日志里没有统计拒绝次数。等到故障复盘时,大家才从接口超时和线程dump往回想,绕了一大圈才找到真正的短板。如果当时拒绝策略里打了指标,这个故障可以用十分钟定位出来。所以我会说:拒绝策略不是收尾动作,它是线程池自我保护的最后一道警戒线。
4.4 为什么Executors快捷方法被那么多文章说“不要用”
Executors工具类提供了几个看着很省事的快捷方法:newFixedThreadPool、newCachedThreadPool、newScheduledThreadPool、newSingleThreadExecutor。这些方法背后的ThreadPoolExecutor配置都被预设好了,问题就出在预设上。newFixedThreadPool和newSingleThreadExecutor用的都是无界LinkedBlockingQueue,这正好踩了上面说的无界队列陷阱;newCachedThreadPool则相反,用的是SynchronousQueue,线程数随任务数无限扩张,理论上最大线程数等于Integer.MAX_VALUE,任务洪峰时能把机器资源直接打爆。
我的态度不是彻底否定这些快捷方法,而是提醒一句话:快捷方法的默认参数适合跑demo和压测,不适合直接进生产。在生产环境我宁可多写几行代码,把ThreadPoolExecutor完整参数配出来,也不愿意图省事埋下隐患。这里有一个看起来微小但影响很大的细节:线程池一定要设置合理的线程名。用Executors生成的线程默认叫pool-1-thread-1这种名字,出了问题查线程dump,根本不知道这是哪个业务模块的线程,排查成本直接翻倍。自定义ThreadFactory看似多写了几行,但它省下的排查时间是这几行的几十倍。
5. 并发工具与容器的选型地图
5.1 三个同步器的分工:CountDownLatch、CyclicBarrier、Semaphore
CountDownLatch、CyclicBarrier、Semaphore这三个类用法长得有点像,都是让线程之间互相等待或者限制并发,但如果用错场景,代码会变得特别别扭。我在实际使用中总结的口诀是这样的:CountDownLatch是倒计时门闩,一个或多个线程等待“倒计数”归零,一次性用完就废,适合主线程等待多个子任务全部完成的场景;CyclicBarrier是循环栅栏,一组线程互相等待,等大家都到齐了再同时出发,而且可以循环使用,适合分阶段并行计算的场景,比如多线程分片处理完一批数据后再合并进入下一批。
Semaphore和前两者的逻辑不一样,它本质上是一个“许可证”系统,线程要执行必须先拿到一个许可,用完归还。它最合适的场景是限流,比如一个接口同时只能有5个线程去调外部接口,多了就排队等许可。这里最容易犯的错误是把Semaphore的许可数量当成固定的资源上限,却不考虑许可获取和释放的异常路径——如果没有在finally里释放许可,许可会越用越少,最后所有线程都被卡死。我在写代码时对这类“获取-释放”型的工具,一律采用try-finally模板,几乎形成条件反射。
5.2 ConcurrentHashMap的版本演进告诉我们的并发思路
ConcurrentHashMap是学习并发容器的必修课,但比记住它的API更值得琢磨的,是它在JDK版本演进中展现出的并发设计思路变化。JDK 1.7时代的实现是分段锁:哈希表被分割成多个Segment,每个Segment是一把独立的锁,不同Segment的写操作能并行,这对应了我前面讲过的锁粒度拆分思想。
JDK 1.8以后实现方式有了很大调整:放弃了Segment分段锁,改用CAS(Compare And Swap)配合synchronized锁住单个桶的头节点。写入时先用CAS尝试直接插入;如果发生哈希冲突,再对桶头节点加synchronized。这个演进的本质是:在冲突不激烈时尽量走无锁路线,只有真正发生竞争才退化为锁。这种“无锁优先、锁兜底”的思路,放到今天做任何并发设计都不过时。
实际开发中我对ConcurrentHashMap的使用还有一个经验:它虽然保证了单个读写的线程安全,但不保证复合操作的原子性。又是一个经典场景:map.putIfAbsent(key, value)是原子的,但先get再判断再put就不是原子的。如果需要类似“不存在才放”的语义,一定要用putIfAbsent这类原子方法来完成,不要自己拼三段式逻辑。
5.3 CopyOnWriteArrayList:读多写少场景的取舍
CopyOnWriteArrayList这个名字透露了它的设计:写操作时不直接在原数组上改,而是复制一份新的数组,在副本上修改,然后把容器内部的引用指向新数组。这样读操作完全不加锁,因为读的永远是一个不可变的快照。它最典型的应用场景是读多写少、且对读的实时性要求不高的地方,比如监听器列表、白名单这类数据。
但这个类的缺点也很明显:每次写操作都要复制整个底层数组,写成本极高。如果你在一个频繁更新的数据结构上用它,性能会惨不忍睹;而且读操作可能读到旧数据,不是“强一致”。有一次我在一个缓存列表的场景里误用了它,列表每几秒钟就更新一次,而列表本身有几万个元素,每次更新复制一遍数组,内存和CPU都绷不住了。复盘后换成了读写锁方案,才把性能拉回来。所以我把它列入选型地图时总要强调一句话:CopyOnWriteArrayList适合“几乎不写”的场景,如果写频率超过你的容忍度,趁早换方案。
5.4 CompletableFuture:让异步编排从回调地狱里走出来
在CompletableFuture之前,Java做异步主要靠Future和回调。Future的硬伤是它只支持阻塞式获取结果(get()会卡住),异步任务之间要串成链路时非常痛苦,得在一个线程里阻塞等待前一个任务结束再去处理下一个任务,等于把好端端的异步做成了同步。而回调虽然不阻塞,但层层嵌套的写法会让后续维护的人读代码读到崩溃。
CompletableFuture做的事情是把“任务编排”这件事显式化了。它允许你通过thenApply串行接续处理、thenCompose把异步操作扁平化组合、whenComplete在结果就绪后统一收尾、exceptionally处理异常路径。我在做多服务聚合接口时深有体会:原来需要同时调用户服务、订单服务、商品服务,再人工合并结果,用CompletableFuture加allOf可以一次性并发发起这三个请求,全部完成后回调合并,代码层次清楚,耗时也从三者之和降到了最慢的那一个。
这里有一个细节值得注意:使用CompletableFuture时最好显式指定线程池,比如CompletableFuture.supplyAsync(supplier, executor)。如果不指定,它默认使用ForkJoinPool.commonPool(),这个公共池被所有代码共享,一旦其中一个任务的执行时间长或者抛出的异常吞噬了线程,会影响全局的异步任务调度。我偏好给异步编排单独建一个线程池,隔离更干净。
6. 小结之后,我把并发代码重构了一遍
6.1 一个任务分发场景的选型推演
理论知识复盘完之后,最好的检验是找一个真实场景把整个选型过程重新推演一遍。我用一个经常出现的业务场景来说明:系统每天有一批待处理的任务,需要并发地拉取外部数据、做本地清洗、再写入结果表。任务数量不确定,高峰期可能有几万个,每个任务耗时也不一样,有的几十毫秒,有的几秒。
按我小结得出的判断链路来走:第一,任务的并发执行统一走线程池,不直接在循环里new Thread,线程创建销毁有开销,而且无法限制并发数;第二,线程池参数方面,这是IO密集型场景(拉数据、写表都涉及网络和磁盘),核心线程数不能按CPU核数拍脑袋,要按IO密集的经验公式估算;第三,线程池队列要选有界的,任务洪峰时宁可触发拒绝和告警,也不能无界堆积把内存打爆;第四,多个批次任务之间需要并发分片,批次内再用CountDownLatch同步等待全部完成;第五,如果任务之间有依赖关系,比如A完成后才能做B,优先拆成多个阶段用CompletableFuture编排,而不是硬套一把锁。
这套推演的价值不在于结论本身,而在于每个选择都有一条可以解释的路径。以后遇到新场景,只要把场景特征往这些维度上套一遍,方案就自然出来了。
6.2 线程数与队列长度的估算公式,以及压测验证
线程数怎么估,业界流传比较广的经验公式有两个。对CPU密集型任务,核心线程数大约等于CPU核心数加一(N+1);对IO密集型任务,更合理的估算公式是N * (1 + 等待时间/计算时间)。举个例子,一个任务平均计算耗时10毫秒,等待磁盘或网络IO耗时90毫秒,那么每个线程真正占用CPU的时间只有10%,理论上可以用大约N * 10个线程来把CPU利用率拉满。不过这毕竟是估算,工程上还要留余量,我通常取估算值的70%到80%作为初始值,然后交给压测去验证。
队列长度的估算思路是反推的:队列容量决定了一次洪峰下最多在内存里积压多少任务。如果单个任务平均占用1KB内存,队列设10万就意味着最多积压100MB内存,这个量级你要能承受。另一方面,队列太长会导致任务“滞留时间”变长,数据写到队列里很久才被消费,业务上是否允许这种延迟,也得想清楚。
压测时我还习惯观察几个指标:线程池活跃线程数曲线、队列深度、拒绝次数、任务平均等待时间。如果线程数一直顶到maximumPoolSize,说明并发需求超出预期;如果队列深度一直上涨,说明消费速度跟不上生产速度;如果拒绝次数上升,说明容量已经到极限了。这一套指标可以让人很快看清线程池配置是否和实际流量匹配,而不是等到线上出问题才去改参数。
6.3 上线前并发自检清单(七问)
最后这套自检清单是我在复盘后给自己定的硬规矩,写并发代码上线前必须过一遍,能挡掉很大一部分潜在问题:
- 有没有多个线程共享同一个可变对象?如果有,是靠锁、原子类还是不可变设计来保证正确性?
- 涉及多把锁的操作,加锁顺序在全团队是否有一致的约定?
ThreadLocal用了之后,是否保证在finally里remove()?- 线程池的队列是否是有界的?拒绝策略是否打了监控埋点?
- 线程池的线程名是否能一眼认出属于哪个业务模块?
- 异步任务是否都设置了超时机制?如果外部接口一直不返回,线程会不会被一直占住?
- 并发修改的数据结构,选型是否真正匹配这个场景的读写比例和一致性的要求?
这七问看起来简单,但每一条都对应了线上真实发生过的事故。我在重构成那一版任务调度代码时就发现,原来有一处用了无界队列、一处ThreadLocal没有清理、一处异步任务完全没设超时。如果不是先做一次全面的小结复盘,这些问题是不会在代码评审里被逐条盯住的。
多线程学到中段最忌讳的是“贪多”,想一口气把并发包里的类全部学完。这12篇学下来,我的体会是:把线程生命周期、Java内存模型、锁的本质、线程池参数背后的逻辑、并发容器的设计取舍这五块基石弄透,再去碰其他进阶内容,一切都会变得很顺。继续往下学之前,我会真的停下来把这套自检清单用在新项目上,遇到问题再回到对应的章节去翻源码。知识这种东西,只有被反复使用和复盘,才会真正长在自己身上。