1. 先搞清楚:高并发到底在解决什么问题
经常有同学跑来问我:"Java高并发怎么学?是不是背八股文就够了?"我说你先回答我一个问题:你们业务被流量冲击的时候,系统是慢、是挂、还是直接内存溢出?这三个答案对应的是完全不同的技术方案。如果连问题本身都没定义清楚,学再多并发工具也是散装知识。
我最早接触高并发是很多年前的一次线上事故。当时一个促销活动刚上线,流量比日常涨了几十倍,数据库连接池被打满,接口从平均50ms直接飙到3秒,然后雪崩式地出现一堆超时和报错,最后整个订单服务不可用。复盘的时候发现,锅不在某个单独环节,而在"每一层都没有做容量设计"。从那以后我明白了一个道理:高并发不是一个技术点,是一整套系统工程。
从定义上说,高并发就是单位时间内系统要处理的请求量非常大,导致计算资源、存储资源、网络资源被激烈争抢。它最核心的两个指标是:
- 吞吐量(QPS/TPS):每秒能处理多少个请求或事务。
- 延迟(RT/响应时间):一个请求从发起到收到响应需要多少毫秒。
两者之间有个经典公式:QPS ≈ (1000ms / RT) × 并发线程数。这个公式看起来简单,实际上蕴含了高并发优化的所有秘密。你的RT是50ms,单线程每秒能处理20个请求,开20个线程就能扛400 QPS。但如果RT涨到500ms,同样20个线程只能扛40 QPS。所以高并发优化的本质,要么降低RT,要么合理提高并发度,要么两者都做。
但"并发数是不是越高越好"?当然不是。我刚工作那会儿也觉得线程池开得越大越厉害,后来压测发现线程数超过某个临界点,吞吐量反而往下掉。原因是线程太多会导致频繁的上下文切换,CPU在"切换工作"上花的时间比"干活"还多。这就是高并发里的"木桶效应"——每个环节都有物理上限,你优化了A环节,瓶颈会跑到B环节。
那怎么定位瓶颈在哪?我建议从一次完整的请求链路去看。以最简单的场景为例:用户点了一个按钮,请求经过DNS解析、网关路由、应用服务的Controller、Service、Dao,再到数据库返回结果。你可以在压测时逐层加日志或采样,看时间都花在哪个环节。常见瓶颈:数据库连接池耗尽、缓存频繁失效导致大量请求击穿到DB、JVM频繁Full GC导致应用停顿、线程池队列被占满后的拒绝策略触发。找到瓶颈比堆机器的优先级高得多——很多所谓"高并发方案",本质上是在正确的位置放了正确的缓存和队列。
还有人问:为什么高并发方案都在Java这里讨论得最热闹?一方面Java在互联网业务后端存量巨大,大厂核心交易链路大量使用Java系技术栈;另一方面Java生态里并发工具非常完整,从synchronized、JUC到Netty、Spring WebFlux,覆盖了从多线程基础到高性能网络IO的完整链路。这也是我写这篇东西的目的:把基础工具、底层原理、分布式方案、面试考核串成一条线,让你读了之后能真正落地,而不是背了一堆名词。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程池与异步化:最常被问也最常被用错的并发基础
2.1 线程池参数怎么定?先讲流程再讲公式
ThreadPoolExecutor是Java并发里出现频率最高的类之一,面试必问,平时也是真用。但大多数人只背了参数含义,没搞懂任务提交的完整流转逻辑。我先把流程给你捋一遍:
- 提交任务时,如果当前工作线程数小于corePoolSize,创建新线程执行任务。
- 如果工作线程数已经达到corePoolSize,任务放入workQueue队列等待。
- 如果队列满了,判断当前线程数是否小于maximumPoolSize,是则继续创建线程执行任务。
- 如果线程数已经达到maximumPoolSize且队列也满了,执行RejectedExecutionHandler拒绝策略。
很多人忽略一个点:这个顺序决定了线程池的行为是"先排队后扩线程"。也就是说corePoolSize设10、maximumPoolSize设20、队列容量1000,那么前1000个任务都进队列,只有队列满了才开始创建第11到第20个线程。如果你的业务是短平快的请求,队列设置太大,线程数可能一直打不满,堆个几千任务在那儿排队,RT就上去了。
关于参数设置,网上有一套经验公式:
- CPU密集型任务:corePoolSize = CPU核数 + 1。因为这类任务大部分时间在计算,线程数超出核数意义不大,多出来那个是在某些线程因缺页中断或暂停时顶上用的。
- IO密集型任务:corePoolSize = CPU核数 × 2。因为IO任务阻塞时间长,CPU大部分在等待,换更多线程进来利用CPU空闲时间。
- 更精确的估算:线程数 = CPU核数 × (1 + IO等待时间 / CPU计算时间)。你可以通过埋点打日志统计出一个方法里"计算耗时"和"等待耗时"的占比,代入公式算。
核心线程数是否要设置得很大?不少项目上来就设200个核心线程,觉得这样"并发能力强"。实际上一个线程默认就要占约1MB的栈空间(取决于-Xss配置),200个线程光栈就吃掉200MB,加上上下文切换开销,系统会变得非常迟钝。我见过的生产环境里,大部分业务线程池的核心线程数在10~50之间就已经能扛住相当高的流量。
还有一个特别容易踩的坑:拒绝策略的选择。JDK内置四种:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 抛RejectedExecutionException | 不希望静默丢弃任务时 |
| CallerRunsPolicy | 由调用者线程直接执行该任务 | 不想丢任务、可接受减速 |
| DiscardPolicy | 直接丢弃任务 | 可容忍丢任务的场景 |
| DiscardOldestPolicy | 丢弃队列中最老的任务,再提交 | 优先处理新任务时 |
实际生产中我优先推荐CallerRunsPolicy。它的好处是:当线程池满时不会丢任务,而是让提交任务的线程(比如Tomcat的工作线程)自己去执行,这相当于天然把压力往上游推,起到"背压"效果,而且不会因为任务被丢弃导致数据不一致。相比之下,AbortPolicy在线上很容易因为瞬时流量直接抛出异常,导致大量接口报错。
再提醒一点:不要用Executors快捷工厂方法。newFixedThreadPool和newSingleThreadExecutor底层用的是无界LinkedBlockingQueue,任务无限堆积会导致内存溢出,正好呼应那个OOM报错(outofmemoryerror: insufficient memory)。newCachedThreadPool用的是SynchronousQueue,来了任务就建线程,高峰时可能创建出成千上万个线程。生产环境一定要自己new ThreadPoolExecutor,把参数显式控制住。
2.2 异步化改造:CompletableFuture如何编排多个依赖请求
高并发场景里,很多请求慢不是因为本身计算量大,而是因为同步等待了太多下游接口。比如我去查用户详情页,需要调用户服务、订单服务、优惠券服务,串行调用每个要200ms,加起来600ms。如果并行调用,总耗时直接降到200ms,RT降了3倍。
Java里最早的Future可以实现并行,但有个硬伤:future.get()是阻塞的,虽然任务并行跑了,你还是要在那儿等结果。等到结果后想串接下一步逻辑,又得重新提交新任务,代码绕来绕去。JDK8引入的CompletableFuture把异步编排做到位了,它的核心思路是给异步任务挂"回调",任务完成时自动触发下一个动作。
我用一个实际场景演示。假设订单详情页需要并行查三个服务:
java复制CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync(() -> userClient.getUser(userId), executor);
CompletableFuture<List<Order>> orderFuture =
