
高并发这个词凡是做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提供了更丰富的APIlockInterruptibly()可以让线程在抢锁过程中响应中断tryLock(timeout)可以设置等待超时时间还能指定公平锁或非公平锁。公平锁按线程等待的先后顺序分配锁代价是吞吐量会下降非公平锁允许线程“插队”效率更高但可能出现个别线程长时间抢不到锁的情况。默认是非公平锁。我在项目里怎么选如果逻辑简单只是保护一个临界区优先用synchronized。简洁、可靠、不容易出错。如果需要尝试获取锁、设置超时、可中断等待或者要用多个条件队列Condition就换ReentrantLock。说白了synchronized是默认选项ReentrantLock是增强选项两者并不是替代关系。顺带提一个容易被问到的点volatile关键字。它保证共享变量的可见性禁止指令重排序但不保证原子性。典型的单例模式双重检查锁里单例对象必须用volatile修饰就是为了防止JVM指令重排导致其他线程拿到未初始化完成的对象。面试题里经常问“volatile能不能替代synchronized”答案是不能因为i这种复合操作volatile完全管不住它只管可见性不管原子性。3.2 并发工具类CountDownLatch、Semaphore、CyclicBarrierJUC包里还有几个并发工具类在高并发场景下使用频率极高也几乎是面试必考。我一个个来说。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还提供了一种乐观锁的思路——CASCompare 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就以为没人改过实际上值已经被折腾过一轮。解决思路是加版本号每次改动版本号加1Java里的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可以做结果转换。CompletableFutureUserInfo userFuture CompletableFuture.supplyAsync(() - userService.getUser(userId), pool); CompletableFutureOrderInfo 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队列容量1000Redis缓存过期时间固定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。原因写在代码旁边能省掉很多重复踩坑的时间。高并发这条路没有终点先把手上的基础和这些常用策略吃透再往分布式锁、消息可靠性、全链路压测这些方向深入你会慢慢发现高并发其实没那么可怕。