高并发这个词,凡是做Java的应该都不陌生。每年面试季,“Java多线程和高并发”相关的题目都能刷屏,从线程池参数到锁的选型,从CAS原理到限流算法,随便拎一个出来都能追问出一连串问题。但说句实话,很多同学对高并发的理解还停留在背面试题的层面,真把一套高流量系统丢到面前,往往不知道从哪儿下手。
这篇文章不打算给你灌理论。我更想从一个一线开发者的角度,把高并发场景下Java程序员真正要掌握的东西捋一遍:从并发基础、线程池配置,到锁和同步器的选择,再到缓存、限流、异步这些高并发必备策略,最后聊聊生产环境里踩过的坑和排查思路。适合正在学Java并发、准备面试,或者刚工作不久想进阶的同学。如果你是老手,可以直接跳到第5章看常见问题和调优案例,那部分都是我实际遇到过的东西。
1. 高并发到底是什么:先搞清楚概念再动手
1.1 从一次抢购说起:高并发的本质
先看个场景。某电商平台做秒杀活动,10万件商品,开售后1秒钟涌入100万个请求。这一秒钟里,系统每台服务器可能要同时处理成千上万个请求,这就是典型的高并发。
但高并发的本质并不是“请求数量多”这么简单,而是“单位时间内涌入的请求量,逼近甚至超过了系统当前的处理能力上限”。对Java后端来说,每个请求通常都对应一条完整的处理链路:建立连接、读取参数、执行业务逻辑、访问数据库或外部服务、组装响应返回。当并发量上来以后,几个最明显的问题会接连出现:CPU被打满、内存被撑爆、数据库连接耗尽、响应时间直线上升,严重的时候整个服务直接雪崩。
把处理思路拆开看,高并发涉及的核心其实就是三个维度:吞吐量、延迟、资源占用。吞吐量是单位时间内系统能处理的请求数,延迟是单个请求从进来到底层返回用了多久,资源占用则是CPU、内存、线程、连接这些系统资源的使用情况。这三个维度互相牵扯:你想把吞吐量提上去,延迟和资源占用往往会跟着恶化;你想压延迟,就得用更多资源去堆。高并发开发的所有手段,本质上都是在三者之间找平衡。
1.2 Java凭什么在高并发场景里站主角
Java能成为企业级后端的主流语言,一个很重要的原因是它的并发编程基础设施足够成熟。JDK从1.5开始提供了java.util.concurrent包,也就是大家常说的JUC,里面把线程池、锁、原子类、并发集合、同步工具都给你准备好了。你想用线程池,不用自己从零造轮子,直接拿ThreadPoolExecutor就能配;你想实现一个线程安全的计数器,不用手动给每个方法加锁,AtomicLong直接搞定;你想让多个线程协作,CountDownLatch、Semaphore、CyclicBarrier都现成可用。
更关键的是,Java内存模型(JMM)为并发编程提供了语言级规范。什么时候该加volatile,什么时候用synchronized,什么时候用CAS,这些都有明确的规则和配套工具。业务系统里遇到的大部分高并发问题,其实都不用发明新概念,把JUC里的东西用对、用到位,80%的场景都能解决。
我常跟同事说一句话:高并发不是玄学,是你把并发控制、资源管理、容错策略这三件事做对了之后的水到渠成。接下来就从这三个方面具体展开。
2. 并发基础必须扎实:线程与线程池
2.1 创建线程的正确姿势与常见误区
先说最基本的——在Java里发起并发任务,绕不开Thread、Runnable、Callable这三样。很多新手最开始都是这么写并发代码的:
new Thread(new Runnable() { @Override public void run() { // 业务逻辑 } }).start();这个写法本身没错,但如果你把每笔业务都这么处理,问题很快就来了。
第一,线程的创建和销毁开销很大。每new一个Thread,底层都要创建对应的操作系统线程,涉及用户态到内核态的切换。如果每个请求都这么干,线程创建销毁的代价可能比业务逻辑本身还要高。
第二,线程数量完全不受控。如果系统里100个地方都在直接new Thread,并发一起来,线程数量会失控,导致CPU争抢、内存飙升,服务还没被流量打垮,先被自己创建的线程拖垮了。
所以正确姿势是用线程池,统一管理线程的生命周期、数量和复用。一个最基本的用法:
ExecutorService pool = Executors.newFixedThreadPool(8); for (int i = 0; i < 100; i++) { pool.execute(() -> System.out.println(Thread.currentThread().getName() + " 处理任务")); } pool.shutdown();但这里必须提醒一句:开发中我不建议直接使用Executors提供的快捷方法,比如newFixedThreadPool、newCachedThreadPool。这些方法的参数都是预设的,不一定匹配你的业务模型。比如newFixedThreadPool底层用的是无界队列LinkedBlockingQueue,如果任务积压太多,队列会无限增长,内存迟早被撑爆。正确做法是用ThreadPoolExecutor手动指定核心参数,这一步不能省。
2.2 线程池的核心参数:配置原理与计算公式
ThreadPoolExecutor的构造参数有七个,每一个都值得你认真琢磨:
| 参数 | 含义 | 配置要点 |
|---|---|---|
corePoolSize | 核心线程数 | 常驻线程数,即使空闲也不会被回收 |
maximumPoolSize | 最大线程数 | 线程池允许的线程上限 |
keepAliveTime | 非核心线程空闲存活时间 | 超过该时间且任务队列为空,多余线程会被回收 |
unit | 时间单位 | 配合keepAliveTime使用 |
workQueue | 任务队列 | 没有空闲线程时,新任务暂存的地方 |
threadFactory | 线程工厂 | 定义线程命名、是否守护线程等 |
handler | 拒绝策略 | 线程数达到上限且队列已满时的处理方式 |
参数之间的关系可以用一句话总结:提交任务时,如果当前线程数小于核心线程数,就创建新线程执行;如果线程数已经大于等于核心线程数,优先把任务放进队列;队列满了之后,才继续把线程数扩大到最大线程数;线程数已经到了最大值且队列也满了,就触发拒绝策略。
那核心线程数到底怎么配?网上流传最广的经验是按任务类型区分。CPU密集型任务,也就是计算为主、几乎不等待的任务,核心线程数建议设置为CPU核心数或核心数加1。IO密集型任务就不一样了,线程大部分时间都在等待IO返回,可以开更多线程,常见的经验公式是CPU核心数乘以2,更精细的版本是:
线程数 = CPU核心数 / (1 - 阻塞系数)这里的阻塞系数可以理解为线程等待IO的时间占比。实际业务系统里,像大量读写数据库、调用外部接口的服务,阻塞系数通常在0.8到0.9之间。举个例子,一台8核服务器,跑一个以数据库读写为主的服务,按公式算:8除以(1-0.9)等于80。理论上可以把核心线程数配到80,但真这么配,往往还没被请求打垮,线程上下文切换就会把CPU耗死。
所以我的做法是:先用公式算出一个初始值,再通过压测逐步调整。比如先设置核心线程数16、最大线程数32,压测观察CPU使用率、响应时间、线程等待时间,再决定往上调还是往下调。不要指望一个公式就能一劳永逸。
2.3 队列与拒绝策略:选错就是埋雷
任务队列是线程池里最容易忽略也最容易埋雷的地方。常用的队列有这几种:
ArrayBlockingQueue:有界数组队列,必须指定容量,容量一旦设置不可改变。LinkedBlockingQueue:链表队列,可以设置容量。Executors.newFixedThreadPool用的默认容量是Integer.MAX_VALUE,等于无界队列,这就是隐患所在。SynchronousQueue:不存储元素,每个插入操作必须等待另一个移除操作。Executors.newCachedThreadPool用的就是它,所以这个线程池才会不断创建新线程。PriorityBlockingQueue:支持优先级的无界队列,任务按优先级出队。
实际项目里,我基本都会使用有界队列,容量根据业务模型估算。比如一个订单处理系统,每秒提交500个任务,每个任务耗时200毫秒,单线程每秒能处理5个,理论上需要100个工作线程才能保证队列不积压。但峰值流量往往难以预测,所以队列容量要当成缓冲存在,给峰值流量留空间,同时也要防止它无限积压。
拒绝策略有四种,每种适用场景不一样:
AbortPolicy:默认策略,直接抛RejectedExecutionException。如果你没处理这个异常,任务就悄悄丢了。CallerRunsPolicy:由提交任务的线程自己执行这个任务。相当于变相限流,提交线程被占用后,其他想提交任务的人只能等着。DiscardPolicy:直接丢弃,不抛异常。适合丢了也无所谓的非核心任务。DiscardOldestPolicy:丢弃队列里最旧的任务,再尝试提交新任务。
我的个人偏好是:在核心业务系统里,如果不想丢任务,就用CallerRunsPolicy。任务不会丢,但提交线程会被迫参与执行,响应会变慢,这其实是一种自我保护。如果是日志上报、统计埋点这类非核心任务,用DiscardPolicy就好,丢了不影响主流程。
3. 锁与同步器:保证数据一致性的关键
3.1 synchronized 与 ReentrantLock 到底选哪个
高并发场景绕不开锁。多个线程同时修改共享变量,如果没有同步控制,数据一致性瞬间崩溃。Java里最基础的同步方式是synchronized关键字,它天生具备可重入性和自动释放锁的特性,底层通过JVM的monitor机制实现,写法也很简单:
public synchronized void updateStock() { stock--; }但synchronized在某些场景下不够灵活:它无法响应中断,无法设置获取锁的超时时间,也不支持尝试获取锁。这时候就需要ReentrantLock出场。
ReentrantLock提供了更丰富的API:lockInterruptibly()可以让线程在抢锁过程中响应中断,tryLock(timeout)可以设置等待超时时间,还能指定公平锁或非公平锁。公平锁按线程等待的先后顺序分配锁,代价是吞吐量会下降;非公平锁允许线程“插队”,效率更高,但可能出现个别线程长时间抢不到锁的情况。默认是非公平锁。
我在项目里怎么选?如果逻辑简单,只是保护一个临界区,优先用synchronized。简洁、可靠、不容易出错。如果需要尝试获取锁、设置超时、可中断等待,或者要用多个条件队列(Condition),就换ReentrantLock。说白了,synchronized是默认选项,ReentrantLock是增强选项,两者并不是替代关系。
顺带提一个容易被问到的点:volatile关键字。它保证共享变量的可见性,禁止指令重排序,但不保证原子性。典型的单例模式双重检查锁里,单例对象必须用volatile修饰,就是为了防止JVM指令重排导致其他线程拿到未初始化完成的对象。面试题里经常问“volatile能不能替代synchronized”,答案是不能,因为i++这种复合操作,volatile完全管不住,它只管可见性,不管原子性。
3.2 并发工具类:CountDownLatch、Semaphore、CyclicBarrier
JUC包里还有几个并发工具类,在高并发场景下使用频率极高,也几乎是面试必考。我一个个来说。
CountDownLatch,理解成一把倒计时的门闩。一个或多个线程调用await()等待,其他线程每完成一个任务就调用countDown()把计数减一,计数归零时所有等待的线程被释放。一个典型场景:查询接口需要并行调用三个底层服务拉取数据,主线程等三个子任务全部完成后合并结果再返回。用CountDownLatch非常合适。
CountDownLatch latch = new CountDownLatch(3); ExecutorService pool = Executors.newFixedThreadPool(3); pool.submit(() -> { queryUserInfo(); latch.countDown(); }); pool.submit(() -> { queryOrderInfo(); latch.countDown(); }); pool.submit(() -> { queryCouponInfo(); latch.countDown(); }); latch.await(3, TimeUnit.SECONDS); // 合并结果返回注意我在await里传了超时时间,这是工程里的关键细节。如果不设超时,万一某个子任务挂住了,主线程会一直等下去,接口就永久阻塞了。加超时后,即使调用超时,也能返回部分结果做兜底。
Semaphore,信号量,用来控制同时访问某个资源的线程数量,本质上是一个计数器加锁的组合。acquire()获取许可证,release()释放许可证,最常见的用途就是限流。比如一个数据库连接池最多允许10个连接同时被使用,就可以用Semaphore(10)来保护。
CyclicBarrier,循环栅栏。多个线程互相等待,等大家都到达栅栏位置后一起放行。和CountDownLatch的区别在于:CountDownLatch是一次性的,计数减到0后不能复用;CyclicBarrier可以循环使用,而且所有线程是互相等待的。它适合多阶段并行计算的场景,比如分批次处理数据,每轮都等所有线程完成再进入下一轮。
这三个工具类用起来都不难,难的是搞清楚语义区别。我自己的记忆口诀:CountDownLatch是“等别人做完再走”,Semaphore是“限制同时干活的人数”,CyclicBarrier是“互相等齐再一起走”。
3.3 原子类与CAS:乐观锁的底层原理
除了悲观锁(先加锁再操作),Java还提供了一种乐观锁的思路——CAS(Compare And Swap,比较并交换)。它的核心思想很朴素:更新一个值之前,先看看当前值是不是我读到的值。如果是,说明没人改过,我就把新值写进去;如果不是,说明有人动过了,我就重新读、重新试。
Java里的原子类AtomicInteger、AtomicLong、AtomicReference都是基于CAS实现的。拿AtomicLong的incrementAndGet来说,底层就是一个死循环里不断尝试CAS,直到成功。这种机制不会阻塞线程,效率比synchronized高很多。高并发下用AtomicLong做计数器,性能优势非常明显,因为CAS是在CPU指令层面完成的。
但CAS也有代价。竞争激烈的时候,CAS的循环重试次数会非常多,CPU空转严重,反而可能不如悲观锁。另外一个声名狼藉的问题是ABA问题:线程A读到值是1,准备改成2;线程B把1改成3又改回1;线程A继续执行CAS时发现当前值还是1,就以为没人改过,实际上值已经被折腾过一轮。解决思路是加版本号,每次改动版本号加1,Java里的AtomicStampedReference就是为此设计的。
还有一个实战中非常值得用的类——LongAdder。高并发下AtomicLong的CAS竞争会很激烈,JDK 1.8引入了LongAdder,采用分段累加的思路:把单一的计数单元拆成多个单元,不同线程分散在不同单元上累加,最后求和。它牺牲了一定的即时一致性,但吞吐量大幅提升。ConcurrentHashMap内部的计数器用的就是这个思路。如果是统计QPS、请求总数这类对一致性要求不高的场景,直接用LongAdder会比AtomicLong稳得多。
4. 高并发场景下的核心策略:缓存、限流与异步
4.1 缓存设计:穿透、击穿、雪崩的应对
高并发场景里,数据库往往是最大的瓶颈,把热点数据放进缓存是业内共识。但缓存用不好,比不用还坑。三个最常见的坑:缓存穿透、缓存击穿、缓存雪崩。
缓存穿透:请求的数据在缓存和数据库里都不存在,所以每次请求都直接打到数据库。比如恶意请求一个不存在的商品ID,每次都穿透到数据库。解决办法有两个:一是缓存空值,并设置较短的过期时间;二是用布隆过滤器,把所有可能存在的数据key先放进去,不存在的请求在缓存层直接被拦截,根本到不了数据库。
缓存击穿:缓存里某个热点key过期了,就在过期的那一瞬间,大量请求同时打到这个key上,全部穿透到数据库。解决办法:对重建缓存的逻辑加锁,让只有一个线程去查数据库并重建缓存,其他线程等待;或者采用逻辑过期方案,value里存一个逻辑过期时间,发现逻辑过期后返回旧值,同时后台异步刷新缓存。
缓存雪崩:大量key在同一时间过期,或者缓存服务整体不可用,海量请求直接压向数据库,数据库被打死,服务彻底雪崩。解决办法:过期时间加随机值,把过期时间分散开,避免大量key同时失效;针对缓存服务不可用的情况,做多级缓存或者服务降级。
实践里我还会特别注意缓存更新策略。最常见的两种方式:先更新数据库再删缓存,或者先删缓存再更新数据库。前者如果删除缓存失败,数据会持续不一致;后者在更新数据库期间的读请求会读到旧值,短暂不一致。工程里比较常用的手段是延迟双删:先删缓存,更新数据库,隔一小段延迟再次删除缓存,把中间窗口读到并回写的脏数据清掉。
4.2 限流方案:从计数器到令牌桶
高并发场景的另一半是保护系统。流量太猛时,与其让系统被打死,不如主动把多余流量拦在外面。限流算法常用的有四种:固定窗口计数器、滑动窗口计数器、漏桶、令牌桶。
固定窗口计数器是最简单的实现,以分钟为单位,每进来一个请求计数加1,超过阈值就拒绝。缺点非常明显:窗口边界会出现双倍流量。比如一分钟限制100个请求,前59秒没人请求,最后一秒进来100个,下一分钟的第一秒又进来100个,两秒内实际处理了200个请求。
滑动窗口计数器把窗口切成多个小格,比如一分钟切成6个10秒的小格,每格单独计数,滑动时把过期格子的计数减掉。窗口边界的问题得到缓解,但仍不是完全平滑。
漏桶算法,请求先进桶里,桶底按固定速率漏水。它把流量强行整形为固定速率,适合保护下游系统,但无法应对突发流量。令牌桶算法则是在桶里以固定速率生成令牌,请求拿到令牌才放行,拿不到就等待或拒绝。它的好处是允许一定程度的突发流量——只要桶里还有令牌,高峰时也放行一批。大部分生产场景里,令牌桶比漏桶更实用。
Java里最简单的限流可以用Semaphore实现,但严格说Semaphore只是控制并发数,不是真正的限流。要快速实现令牌桶,可以用Guava的RateLimiter,使用非常简单。要做分布式限流,我项目里用得比较多的是Redis加Lua脚本,因为单机限流在集群环境下会失效,多台机器共享一个限流器才能保证整体流量可控。Lua脚本保证判断和扣减在Redis中是原子操作,代码也不复杂,是生产环境比较靠谱的方案。
4.3 异步化与消息队列:把同步等待变成异步解耦
高并发调优还有一个重要方向是异步化。很多业务链路包含多个非核心步骤,比如下单后要发短信、写操作日志、加积分、更新风控数据。这些步骤如果都同步执行,响应时间会变得很长,而且只要其中一个下游服务变慢,整个链路都会被拖住。
把这些非核心步骤丢给异步线程池或者消息队列,主链路只保留核心逻辑,响应时间能显著下降。Java里最简单的异步化是线程池加CompletableFuture。它提供了非常方便的异步编排能力,比如thenCombine可以并行执行两个任务再合并结果,exceptionally可以处理异常,thenApply可以做结果转换。
CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync(() -> userService.getUser(userId), pool); CompletableFuture<OrderInfo> orderFuture = CompletableFuture.supplyAsync(() -> orderService.getOrder(orderId), pool); userFuture.thenCombine(orderFuture, (user, order) -> buildPageResponse(user, order)).join();这段代码的含义是:用户信息和订单信息并行查询,两个都完成后组装页面响应。直接同步串行执行可能需要400毫秒,并行后可能只需要200毫秒。
消息队列则是更大范围的异步方案,引入MQ后,生产者和消费者完全解耦。生产者只管把消息投递出去,消费者按自己的消费能力拉取和处理,队列本身能起到削峰填谷的作用。流量洪峰来了,消息先堆在队列里,消费者按固定速率慢慢消费,系统不会被瞬间流量打垮。
但引入MQ也意味着系统复杂度上升,可能出现重复消费、消息丢失、消息顺序乱掉这些新问题。所以我在做架构决策时有一个原则:先用线程池异步,真的扛不住了再上MQ。不要为了用MQ而用MQ,每引入一个中间件,都意味着新的运维成本和故障点。
5. 常见问题与排查技巧实录
5.1 死锁问题:怎么定位,怎么避免
高并发下多线程互相持有对方需要的锁,又都不释放,就会形成死锁。死锁一旦发生,涉及到的线程会永久卡死,而且往往不是一两个线程,是一批请求同时卡在那里,服务直接表现为大面积超时。
定位死锁最直接的方式是拿到线程快照。生产环境执行jstack命令,把Java进程的线程栈导出来,JVM会明确检测到死锁,并指出哪些线程持有哪些锁、正在等待哪些锁。
我自己排查死锁的固定步骤:
- 先用
top -Hp <pid>找到CPU占用异常或者卡住的线程,或者直接看哪个接口大面积超时。 - 用
jstack <pid> > dump.txt抓取线程dump文件。 - 在dump文件里搜索“deadlock”或“waiting to lock”,定位涉及的两把锁和线程。
- 回到代码里检查加锁顺序,最终都会发现两个地方获取锁的顺序不一致。
避免死锁最简单的方式是统一加锁顺序。比如同时更新账户A和账户B,不管调用方传参顺序如何,内部都先锁ID小的账户,再锁ID大的账户。这样永远不可能出现互相等待。还有一个容易忽略的坑:不要在持锁期间调用外部服务或做耗时操作,否则锁的持有时间会变长,等待线程积累越多,死锁概率越大。
5.2 线程池崩溃与线程泄漏:从现象到底层原因
线程池相关的生产事故里,最常见的现象是“接口突然超时,CPU不高但线程不释放”,或者日志里突然冒出RejectedExecutionException。
我遇到过最有代表性的一次是:某定时任务线程池核心线程数4、最大线程数4,队列用的LinkedBlockingQueue。原以为任务量不大就没多管,结果有一次任务里的外部RPC调用一直不返回,4个线程全部被长时间占住,后续任务全部积压到无界队列里,内存一路涨,最后OOM。
这个案例说明了三件事:第一,外部调用必须配超时时间,这是基本的工程素养。第二,队列选择要结合任务积压评估,无界队列在某些场景下真的要命。第三,线程池必须监控,至少把活跃线程数、队列积压数、拒绝次数暴露到日志或监控系统里去。
线程泄漏也是高并发场景的隐患。常见原因是线程内的异常没被捕获,任务提前结束,但线程占用的资源没有释放。还有一个经典问题:使用ThreadLocal没有在finally里remove()。线程池中的线程是复用的,ThreadLocal里的值会被带到下一个任务里,轻则数据串了,重则内存泄漏。我在团队里定了一个规矩:所有使用ThreadLocal的地方,必须在finally块中调用remove(),没有例外。
5.3 一次完整的压测与调优过程
分享一次简化过的真实调优案例。场景是一个查询接口,业务逻辑是查Redis缓存,缓存没有就查数据库并回写缓存。压测前的配置:8核机器,线程池最大线程数200,队列容量1000,Redis缓存过期时间固定10分钟,数据库连接池最大50。
压测刚开始,QPS从500提升到1000时,接口响应时间开始直线上升,CPU冲到接近100%。我先用jstack抓线程栈,发现大量线程阻塞在数据库连接获取上,说明数据库连接池成了瓶颈。连接池只有50个连接,线程池却有200个线程,200个线程大部分时间都卡在等连接上。这其实就是典型的资源错配。
调优步骤:把数据库连接池最大连接数从50提升到100,把线程池最大线程数从200压到64。听起来很反直觉,调小线程数反而更快。原因是8核机器上跑200个线程,上下文切换开销巨大,线程数远超过CPU核心数时,每个线程分到的CPU时间片变少,有效吞吐反而下降,大量CPU时间都浪费在切换上了。
调整后压了一轮,QPS稳定在3000左右,接口平均响应时间从800毫秒降到200毫秒。接着再看缓存,发现命中率只有80%,有些热点key集中过期了,回源数据库压力偏大。于是把过期时间改成固定值加随机0到5分钟,让过期时间分散开,缓存命中率上升到95%以上,压测QPS最终稳定在4000左右。
这个案例放在这里,只是想说明一件事:高并发调优没有银弹,但套路是清晰的——先压测、看指标、抓线程栈、定位瓶颈、逐项调整。而且每次只改一个变量,改完再压,不要一拍脑袋同时改好几个参数,否则你根本不知道是哪个改动起了作用。
我在这行干了这么多年,从最开始一听到高并发心里就发虚,到现在线上出问题能比较冷静地去定位,最大的体会是:并发问题的核心不是复杂,而是失控。线程数失控、连接数失控、队列增长失控,这些失控最后都会变成系统的崩溃。Java给的并发工具已经足够强大,但工具再好,也得知道什么时候该用、什么时候不该用。
最后分享一个小习惯:我会在每一个生产项目的线程池、连接池初始化处,把参数和配置理由写成注释。不是为了凑代码规范,而是因为三个月后你回来看这段代码,大概率已经忘了为什么是16而不是32。原因写在代码旁边,能省掉很多重复踩坑的时间。高并发这条路没有终点,先把手上的基础和这些常用策略吃透,再往分布式锁、消息可靠性、全链路压测这些方向深入,你会慢慢发现,高并发其实没那么可怕。