很多人写 Java 并发是这么开始的:接口一压测就慢,凭感觉加了个线程池,结果队列堆满、内存涨、线上开始超时。问题不在不会用 API,而在于没搞清每个并发核心库到底替你挡了哪一类麻烦。这篇按「先分类再选型再排错」的顺序,把 JUC 那几块核心组件串成一条可执行的路子,顺带说清什么场景下它并不适用。
很多人写 Java 并发是这么开始的:接口一压测就慢,凭感觉加了个线程池,结果队列堆满、内存涨、线上开始超时。问题不在不会用 API,而在于没搞清每个并发核心库到底替你挡了哪一类麻烦。这篇按「先分类、再选型、再排错」的顺序,把 JUC 那几块核心组件串成一条可执行的路子。
下面每一节都尽量落到动作上:怎么判断自己属于哪类问题、参数往哪个方向调、什么情况下这套东西反而不适用。顺带说清一件事,并发库解决的是「任务怎么排、状态怎么护」,不是「外部依赖慢」——后者得靠超时和熔断,堆线程只会把故障放大。
先分清你要解决的是哪类并发问题
并发需求粗略分四类:任务并行提速、共享状态保护、限流削峰、异步编排。类别不同,默认解法完全不同。选错方向的典型表现是:明明是外部接口慢,却在调线程数;明明是共享计数器写冲突,却在加缓存。
| 现象 | 大概率瓶颈 | 优先动哪块 |
|---|---|---|
| CPU 打满、线程数不多 | 算力不够 | 减少重复计算,加线程没用 |
| 线程大量 WAITING/BLOCKED | 在等锁或等 IO | 缩临界区、换并发容器 |
| 吞吐上不去、队列堆积 | 消费速度跟不上 | 池参与队列一起看 |
| 峰值抖动、下游被打挂 | 没有削峰 | 限流与队列缓冲 |
- 先量基线:用站内 /tools/speed-test 量出接口延迟,再对比压测数据,别凭感觉定位
- 区分等待类型:等锁是 CPU 空转少、等 IO 是线程闲置多,两者优化方向相反
- 单次请求本身依赖外部慢接口时,优先做超时与熔断,而不是堆线程数
- 同一接口的并发问题往往不止一类,按「最影响成功率」的那类先处理
线程池:参数怎么定,什么时候不该用它
四个关键点拆开看:核心线程数、最大线程数、队列、拒绝策略。队列选了无界队列,最大线程数基本等于摆设——任务全堆在队列里,线程永远到不了上限。这是最常见也最难发现的坑,因为它在低峰期完全正常。
- 核心线程数
- 常态保活的线程数,定得太低会让任务长期排队
- 最大线程数
- 配合有界队列才有意义,无界队列下它基本不触发
- 队列
- 有界队列能把压力显式暴露出来,无界队列会拖到 OOM 才暴露
- 拒绝策略
- 必须显式声明并记录拒绝次数,否则线上出问题查不到
- 定类型先判断任务是 CPU 密集型还是 IO 密集型,CPU 密集型线程数贴近核数,IO 密集型可以放大但有上限
- 定上限放大倍数靠压测调,不靠拍脑袋;每次只改一个参数并留观察窗口
- 加可观测给池起有业务含义的名字,把活跃线程数、队列长度、拒绝次数打出来,方便和线程栈对时间点
- 验证压测时观察拒绝次数与延迟曲线是否同步抬升,同步抬升说明池到顶了,不同步则瓶颈在别处
什么情况下不适用:任务之间互相等待(一个任务提交另一个任务并等它的结果)时,用同一个池容易把线程全占死,形成等待环。要么拆成两个池,要么换成异步编排,别在一个池里互相等。
锁与 AQS:搞懂阻塞点,才谈得上优化
常用锁解决的是「同一份状态被多个线程改」。读多写少的场景,优先考虑读写分离的思路,而不是一律加互斥锁。AQS 那套核心就三件事:一个状态变量、一个等待队列、一套唤醒规则。理解了它,同步器、信号量、计数器这些组件的差异就只剩「谁在改状态、谁能进来」。
- 临界区里做远程调用或查数据库,锁持有时间被拉长,任何锁都会拖垮吞吐,要做的是缩小临界区
- 多把锁要有固定加锁顺序,顺序不一致就是死锁的温床
- 读多写少优先读写分离思路,纯互斥锁会把读也串起来
- 排查顺序:先看线程栈卡在哪个类,再看是锁竞争还是池排队,最后才怀疑库本身
不适用的情况也得说清:如果共享状态本来就是低频写、高频读,或者能改成不可变对象传递,那加锁本身可能就是多余设计。先把「能不能不共享」问一遍,再谈用什么锁。
并发容器与异步编排:别自己造轮子
容器选型看读写比例和一致性要求。读多写少、允许弱一致性的场景,用并发容器比在普通容器外层包一把大锁划算得多,因为前者把锁拆到了更细的粒度。原子类适合单一变量的计数与累加;多个变量需要一起保持一致时,原子类帮不上忙,该换思路。
| 需求 | 优先考虑 | 不适用情形 |
|---|---|---|
| 高频读、低频写 | 并发容器 | 需要整表强一致时 |
| 单变量计数 | 原子类 | 多变量需一起一致 |
| 多依赖并行拉取 | 异步编排 | 依赖有严格先后顺序 |
| 峰值削峰 | 有界队列加限流 | 下游本身无扩容空间 |
异步编排解决的是「多个依赖并行拉取再合并」,重点在超时与异常兜底:任一子任务失败时,是整体失败还是降级返回,得提前定死。依赖本身有严格先后顺序时,编排只会让链路更难读,老实写串行代码更好维护。
线上高并发问题的排查顺序
出问题时最忌讳一上来就翻代码。先按现象分类,再抓证据,最后做单一变量改动。下面这套顺序的目的是让你每一步都能排除掉一类可能,而不是同时怀疑所有东西。
- 看现象分类CPU 高、内存涨、响应慢、报错多,四类问题对应不同第一现场;先归类再动手
- 抓证据线程栈、GC 情况、池的活跃数与队列长度一起看,才能区分是算不过来还是堵住了
- 单一变量一次只调一个参数并留出观察窗口,多项同时改,回滚时说不清是哪一项起了作用
- 看上下游本地池指标正常但延迟仍高,多半在下游;此时先加超时与熔断,再谈扩容
- 线程栈看阻塞点,池指标看排队情况,两者时间点要对齐
- 拒绝次数持续增长说明池到顶,队列长度持续增长说明消费慢
- 内存涨要区分是任务堆积还是缓存无界,二者的修法不同
- 长期动作:把池指标、拒绝次数、锁等待时间纳入监控,别等出事才加
模型与配置变了以后,成本与额度口径也建议到 /official-api 和 /price-watch 对一遍,避免并发上去之后隐性超支。并发调优和成本是两件事,但经常一起出问题。
下一步把你的线程池参数和压测数据对照站内的价格与模型资料一起看,能更快判断是并发不够还是额度口径变了。→常见问题
学并发核心库,是先背 API 还是先搞懂原理?
先搞懂「它替你挡了哪类问题」再记 API。原理不清楚时,参数只能靠抄;但也不必把源码全啃一遍,抓住状态变量、等待队列、唤醒规则这条主线,多数组件的用法自然就推得出来。剩下靠压测和线上数据验证,别停在看书阶段。
线程池参数到底怎么定才不会翻车?
没有通用数字,只有定参流程:先判断任务是 CPU 密集还是 IO 密集,再定上限,然后用压测逼近,最后靠监控长期观察。关键是把队列、最大线程数、拒绝策略三个一起考虑,只调其中一个往往只是把问题挪到别的地方。压测基线可以用 /tools/speed-test 先量。
并发改完线上反而更慢,一般从哪查?
先看是变慢还是变卡:查线程栈看卡在锁还是队列,查池的活跃数与队列长度看是排队还是真在跑,再对照 GC 情况排除内存压力。常见原因是临界区被放大、池之间互相等待,或者超时没设导致任务永远不结束。一次只改一处再观察。