ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java高并发实战:从线程池到锁的QPS优化指南

Java高并发实战:从线程池到锁的QPS优化指南 写高并发这个话题我其实犹豫了很久。网上讲Java高并发的文章一抓一大把但大多是八股文式的概念堆砌——线程池参数背得滚瓜烂熟真到线上出问题照样抓瞎。这几年我接手过好几个从几百QPS被业务冲到几万QPS的系统踩过的坑比很多教程里写的最佳实践多得多。今天不聊虚的就从Java工程师日常能接触到的层面把高并发这件事从原理到实操彻底捋一遍。不管你是在准备大厂面试还是正在接手一个流量暴涨的项目这篇文章应该能帮你少走不少弯路。先说清楚一个概念高并发并不是玄学它是一组可以被量化、被测试、被优化的工程指标。你的系统每秒能扛住多少请求、每个请求平均多久响应、在流量翻倍时会不会雪崩这背后取决于线程模型、锁的粒度、容器的设计、数据库的连接策略。我会按问题拆解 → 核心工具 → 实操案例 → 坑位排雷的顺序来讲最后还会附上我实际排查线上问题时的思路和命令这些都是文档里不会写的东西。1. 高并发到底在解决什么问题1.1 高并发不是玄学是一组可量化的指标很多初学者一提高并发脑子里全是秒杀双十一千万QPS这种大词然后就开始研究各种分布式中间件。但实际上面试官或者你的Leader问你系统能抗多少并发时他问的是三个具体数字QPS每秒请求数、RT平均响应时间、错误率。我在刚工作那会儿也犯过这个毛病Leader问我系统性能怎么样我张口就是挺快的。结果被反问一句怎么个快法就愣住了。后来才明白高并发的一切讨论都必须落到数字上。QPS和RT是相互制约的根据Littles Law一个稳定的系统里并发数 ≈ QPS × RT秒。举个例子假设你的接口平均响应时间是100ms想要支撑2000 QPS那系统里至少要同时存在200个请求在处理。如果RT因为某次慢查询涨到1秒同样的200个并发请求QPS直接掉到200。这个公式你一定要刻在脑子里因为它解释了高并发场景下所有问题的根源要么QPS上不去要么RT降不下来要么两者互相拖累。很多所谓的性能优化说到底就是在调整这三个变量之间的关系。1.2 串行代码为什么扛不住流量要理解高并发得先理解为什么正常的代码会扛不住。我给你拆一个最简单的场景假设有个接口要查用户信息、扣库存、发消息通知很多刚入门的朋友会这么写public OrderResult createOrder(OrderRequest request) { // 1. 查询用户信息数据库IO假设 50ms User user userMapper.selectById(request.getUserId()); // 2. 扣减库存数据库更新假设 50ms int rows stockMapper.deduct(request.getSkuId()); // 3. 发送消息通知调用外部接口假设 100ms smsClient.send(request.getPhone()); return OrderResult.success(); }这段代码逻辑没问题每一个步骤单独看也都很快加起来200ms。问题出在哪Tomcat默认的线程数是200也就是说你的服务器同一时间最多只能处理200个请求。按照并发数公式单机QPS上限就是 200 / 0.2s 1000。一旦流量超过1000 QPS多余的请求全部在队列里排队RT开始指数级上升达到几秒甚至几十秒后前端超时重试后端堆积更多请求最终整个服务雪崩。这就是高并发最核心的矛盾你的代码是串行执行的但流量是并行涌进来的。所以Java高并发的第一步不是换框架、上中间件而是学会怎么把串行的逻辑并行化同时保证数据不错乱。1.3 你的系统到底需要多高的并发聊高并发之前先泼一盆冷水不是所有系统都需要高并发。我给不少团队做过技术咨询很多业务场景QPS过百就烧高香了结果架构上整了一堆微服务、消息队列、分布式缓存运维成本比机器成本还高。这里有一个粗略的分级判断标准流量级别单机QPS主要瓶颈推荐方案低并发 100基本无压力单机 数据库索引优化中并发100 ~ 1000线程阻塞、锁竞争线程池优化 加缓存高并发1000 ~ 10000数据库连接、IO缓存 异步化 读写分离超高并发 10000系统整体架构分布式 消息队列 分库分表看到没1000 QPS以下基本用不上什么高大上的中间件把Java层面的线程、锁、连接池调好就够用了。真正需要上分布式架构的场景至少是单机资源耗尽之后的事。所以这篇文章聚焦在单机范围内把Java并发编程这套基本功打牢对你来说性价比最高。2. Java并发编程的四件核心武器2.1 线程池别再裸用new Thread()了我几乎在每次代码评审里都会看到new Thread(() - {...}).start()这种写法。不是说不能这么写而是这种写法在高并发下就是一颗定时炸弹。每来一个请求就开一个线程操作系统创建线程的代价很高而且线程多了之后CPU光忙着切换上下文了真正干活的资源反而变少。线程池的核心思想就一句话把线程的创建和销毁成本摊平让一组线程反复利用。Java里的ThreadPoolExecutor是这套机制的标准实现它的构造函数有七个参数把这七个参数搞明白线程池就通了public ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 非核心线程空闲存活时间 TimeUnit unit, // 时间单位 BlockingQueueRunnable workQueue, // 任务队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 )这里最容易被忽略的是任务提交流程网上很多文章讲得特别繁琐我提炼成四句口诀核心线程没满 → 直接开新线程执行任务核心线程满了 → 任务进队列排队队列也满了 → 开始创建非核心线程最多到maximumPoolSize非核心线程也满了 → 触发拒绝策略。注意这个顺序先排队再扩线程。很多人以为核心线程满了一定会先扩容再排队这是错的。也就是说如果你的队列设得很大实际上线程数永远上不去因为任务全在排队。这个特性直接决定了线程池参数的设置策略后面实操章节我会详细算。拒绝策略有四种默认的AbortPolicy是直接抛RejectedExecutionException这在高并发下很容易被忽略。我自己常用的组合是核心业务用CallerRunsPolicy让提交任务的线程自己执行起到天然限流作用非核心通知类任务用DiscardPolicy丢了也无所谓。2.2 锁的选择synchronized和ReentrantLock怎么选锁是高并发绕不开的话题也是面试重灾区。Java里最常用的两把锁就是synchronized和ReentrantLock很多人纠结到底选哪个。我的结论很直接能用synchronized就用synchronized。它语法简单出了异常自动释放锁而且JDK一直在优化它锁升级、锁粗化、自适应自旋在大多数单机场景下性能已经不输ReentrantLock了。需要尝试获取锁、超时等待、可中断、公平锁、多个条件变量时才上ReentrantLock。举个例子在并发扣库存这种场景我更喜欢ReentrantLock的非阻塞特性Lock lock new ReentrantLock(); boolean acquired false; try { // tryLock可以设置超时时间避免死等造成请求堆积 acquired lock.tryLock(500, TimeUnit.MILLISECONDS); if (!acquired) { return Result.fail(系统繁忙请稍后重试); } // 业务逻辑检查库存并扣减 int stock stockMapper.selectStock(skuId); if (stock 1) { return Result.fail(库存不足); } stockMapper.deduct(skuId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail(系统异常); } finally { if (acquired) { lock.unlock(); } }注意finally里那个if (acquired)判断。如果是lock.lock()拿锁那直接unlock就行但tryLock可能拿锁失败如果没拿到还去unlock会抛IllegalMonitorStateException。这个细节我在代码评审里至少指出过十次。还有一个很多人在高并发下容易忽略的问题锁的粒度。如果是粗粒度锁比如给整个方法加synchronized那把并发直接打成串行了。扣库存这个操作真正需要保护的不是整个方法而是查库存→判断→扣减这一小段复合操作。所以锁的粒度要尽量小锁住的代码越短并发度越高。2.3 并发容器与原子类线程安全的正确姿势很多人的第一反应是给所有共享变量都加锁这是最笨的办法。JDK并发包里其实提供了一整套线程安全的容器和原子类用对了既能保证安全又能保住性能。ConcurrentHashMap是我用得最多的并发容器。它锁的不是整个map而是每个桶JDK8实现所以并发度比Hashtable和Collections.synchronizedMap高得多。但要注意ConcurrentHashMap的单个操作是线程安全的复合操作不是。比如经典的先查后写// 错误示范虽然是ConcurrentHashMap但两步操作之间可能被其他线程插入 if (!cache.containsKey(key)) { cache.put(key, value); } // 正确做法用computeIfAbsent原子完成 cache.computeIfAbsent(key, k - loadValue(k));这个坑特别隐蔽别问我怎么知道的——线上缓存穿透事故就是这么来的。再来说说LongAdder和AtomicLong。如果你只是用原子变量做计数器比如统计PV高并发下优先用LongAdder。AtomicLong在竞争激烈的时候CAS失败率高会疯狂自旋消耗CPULongAdder把单一变量拆成多个cell不同的线程更新不同的cell最后sum起来吞吐量能提升好几倍。如果你的业务要求精确到每一次更新的时序性那还是用AtomicLong。2.4 让多个线程协作CountDownLatch、CyclicBarrier和CompletableFuture高并发不只是同时处理很多请求还包括一个请求内部并行处理多个任务。我给你还原一个真实场景订单详情页需要查用户信息50ms、查订单状态80ms、查物流信息200ms。如果串行执行接口RT就是330ms如果并行执行RT就是200ms。在这个场景里用CompletableFuture是我最推荐的方案CompletableFutureUser userFuture CompletableFuture.supplyAsync(() - userService.getUser(id), userThreadPool); CompletableFutureOrder orderFuture CompletableFuture.supplyAsync(() - orderService.getOrder(id), orderThreadPool); CompletableFutureLogistics logisticsFuture CompletableFuture.supplyAsync(() - logisticsService.getLogistics(id), logisticsThreadPool); // 三个任务并行执行全部完成后汇总 CompletableFuture.allOf(userFuture, orderFuture, logisticsFuture).join();这里有个非常关键的细节一定要传线程池参数。如果不传supplyAsync用的是ForkJoinPool的公共线程池默认线程数等于CPU核数减1。如果所有业务都用这个公共池遇到任何阻塞操作整个应用的其他并行任务都会跟着卡住。我自己公司就有过一次教训用公共池调第三方接口返佣堵了下单的并行查询全被牵连差点酿成事故。如果用的是CountDownLatch它的用法是等N个线程都干完后再继续。注意一个坑countDown()必须在finally里执行否则任何线程抛异常主线程就会永远阻塞。CyclicBarrier则适合多线程互相等待同时开始下一阶段的场景比如并发分批处理数据时做阶段同步。两者区别一句话概括CountDownLatch是等别人干完CyclicBarrier是等所有线程到齐再一起往下走。3. 实操把串行扣库存改造成高并发版本3.1 经典场景商品秒杀扣库存抛开理论我们用最常见的秒杀扣库存场景完整走一遍高并发改造流程。这个场景足够有代表性高并发、数据一致性要求高、超卖是绝对不能接受的。目标很简单接口RT小于200ms1000并发请求下库存正确扣减不超卖不漏卖系统不崩溃。先定义这个场景的基本数据商品库存100件模拟1000个用户同时抢购数据库用MySQL。第一版实现我见过很多初级工程师就是这样写的Transactional public boolean deductStock(Long skuId, Integer count) { Stock stock stockMapper.selectBySkuId(skuId); // 查询库存 if (stock.getStock() count) { return false; // 库存不足 } int rows stockMapper.updateStock(skuId, stock.getStock() - count); // 扣减 return rows 0; }用压测工具一打问题立刻暴露大量请求同时读到库存100都能通过判断然后一起扣减。结果就是并发场景下超卖。原因很简单查和改不是原子操作中间隔着时间窗其他线程可以插进来。3.2 第一版改造JVM锁让操作原子化最直接的思路是给扣库存操作加锁让查库存→判断→扣减这段逻辑在同一时刻只有一个线程在执行。加synchronized是最简单的方案但我上面也说了tryLock配合超时对本场景更好private final Lock lock new ReentrantLock(); public boolean deductStock(Long skuId, Integer count) { boolean acquired false; try { acquired lock.tryLock(200, TimeUnit.MILLISECONDS); if (!acquired) { // 拿不到锁直接返回避免请求排队堆积 return false; } Stock stock stockMapper.selectBySkuId(skuId); if (stock.getStock() count) { return false; } stockMapper.updateStock(skuId, stock.getStock() - count); return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (acquired) { lock.unlock(); } } }这一版能不能解决超卖能。但问题也很明显锁只有本机有效。如果服务部署了两台机器不同机器上的锁是两把独立的锁照样超卖。而且JVM锁在单一实例内把并发扣减完全串行化了QPS天花板就是锁的吞吐上限一般也就几百到一千左右。所以这个方案只适合单机、低并发的场景。但它很值得写出来因为理解了锁的边界你才知道为什么高并发最终要走向数据库层面去兜底。3.3 第二版改造数据库乐观锁兜底跨实例又不想引入分布式锁时最实用的方案是乐观锁也就是用数据库的条件更新把判断和修改合并成一条原子SQL// SQL层面做条件更新扣减前检查库存 购买数量 int rows stockMapper.deductStockByVersion(skuId, stock.getStock(), count); Update(UPDATE stock SET stock stock - #{count}, version version 1 WHERE sku_id #{skuId} AND stock #{count}) int deductStockByVersion(Param(skuId) Long skuId, Param(count) Integer count);返回的rows是受影响的行数。如果库存不够条件stock #{count}不成立更新影响0行说明扣减失败。这一步直接在数据库引擎层面保证了原子性不需要任何锁也不存在多实例问题。这就是乐观锁的魅力用一条SQL代替查判改三步让数据库帮我们做并发控制。但乐观锁在高竞争场景下有个副作用大量请求同时更新同一行数据数据库行锁竞争激烈会有大量请求更新失败。秒杀场景里这反而是可接受的——反正库存就那么多绝大部分人要失败。但对一些必须成功的高并发写操作这种方案的失败率会让你头皮发麻那就得靠削峰填谷队列去应对了。3.4 用线程池模拟压测眼见为实写完代码我习惯在本机先做个简单的并发压测确认改造效果。不搞复杂的工具就用线程池模拟并发请求最直观public static void main(String[] args) throws InterruptedException { int threadCount 1000; // 模拟1000个并发请求 int stock 100; // 库存100件 ExecutorService executor Executors.newFixedThreadPool(200); CountDownLatch readyLatch new CountDownLatch(threadCount); CountDownLatch startLatch new CountDownLatch(1); CountDownLatch endLatch new CountDownLatch(threadCount); AtomicInteger successCount new AtomicInteger(0); AtomicInteger failCount new AtomicInteger(0); for (int i 0; i threadCount; i) { executor.submit(() - { readyLatch.countDown(); try { startLatch.await(); // 所有线程就绪后统一放行 if (stockService.deductStock(1001L, 1)) { successCount.incrementAndGet(); } else { failCount.incrementAndGet(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endLatch.countDown(); } }); } readyLatch.await(); startLatch.countDown(); endLatch.await(); System.out.println(成功扣减 successCount.get()); System.out.println(扣减失败 failCount.get()); executor.shutdown(); }跑这个压测你会看到成功次数正好等于库存100失败900一条不多一条不少。这个CountDownLatch组合的写法值得收藏第一个Latch保证所有线程已经创建完毕第二个Latch同时放行模拟瞬间冲击第三个Latch让主线程等待所有结果出来再统计。这是我自己压测时最常用的手段比肉眼观察日志靠谱得多。3.5 接口层整体优化别让Tomcat成瓶颈把扣库存逻辑改完还要看整个链路。很多人只盯着业务代码忽略了一个事实Tomcat默认线程池只有200个线程每个线程同时只能处理一个请求。如果接口RT是100ms单机QPS上限撑死就是2000。想再往上扩三条路降RT把耗时的非核心操作发短信、写日志、推送通知异步化用消息队列或者线程池去处理把200ms的RT压到50ms提线程数调server.tomcat.max-threads比如调到400但线程不是越多越好太多反而增加上下文切换开销一般建议200~500之间具体要看CPU核数和任务类型加机器水平扩容但这时要解决前面说的JVM锁失效问题和Session共享问题。还有一个经常被忽视的优化Controller层直接返回业务异步执行。Spring MVC 3.2之后支持返回Callable或DeferredResultTomcat线程会立刻释放等业务线程处理完再异步写回响应。这个机制能有效提高Tomcat线程的利用率避免请求堆积。4. 常见问题与排查技巧实录4.1 线程池参数到底怎么定这个是我被问得最多的问题。网上有各种计算公式什么CPU密集型线程数 CPU核数 1、IO密集型 CPU核数 × 2我实战下来的感觉是这些公式只能当起点不能当结论。核心要分清楚任务的类型CPU密集型任务主要是计算线程数不宜超过CPU核数。机器学习推理、图像处理、加解密属于这类。线程设多了只会互相抢CPU性能反而下降。IO密集型任务里大量时间在等网络、等数据库、等外部接口CPU大部分时间闲置。线程数可以设多一些理论上限是CPU核数 / (1 - 阻塞系数)阻塞系数高就可以开很大。混合型既需要计算又有IO。这种任务最优策略是拆拆看能不能拆成纯CPU段和纯IO段不行的话按IO密集型偏保守设置。我的一个工程经验公式对于典型的IO密集型业务接口核心线程数 CPU核数 * 2 最大线程数 CPU核数 * 4 队列容量 最大线程数 * 数百到数千不等视可容忍排队延迟拿一台4核8线程的机器举例核心线程8最大线程16~32队列用有界队列比如1000。关键在于队列一定要有界。无界队列如LinkedBlockingQueue不指定容量看着省事但线程池永远不会触发最大线程数扩容任务全堆在内存里流量暴涨时直接OOM。还有一个小技巧线程池不建议用Executors.newFixedThreadPool()这类快捷方法。它们底层要么用无界队列、要么核心线程数最大线程数1生产环境各有各的坑。老老实实newThreadPoolExecutor参数自己传清清楚楚。4.2 线上出现死锁怎么排查死锁的经典场景就是两个线程各自持有一把锁又互相等对方手里的锁。Java里排查死锁有一套固定流程我把命令和思路整理成速查步骤第一步找到出问题的Java进程号jps -l第二步打出线程快照重点看Found one Java-level deadlock字样jstack pid thread_dump.txt第三步在线程快照里搜deadlock日志会直接告诉你哪两个线程互相持有什么锁、等什么锁。这是JDK自带的能力定位精确到行。死锁最容易出现的场景是我在代码评审里强调过无数次的那种多个方法嵌套加锁且加锁顺序不一致。比如A方法先锁lock1再锁lock2B方法先锁lock2再锁lock1两个线程同时执行到一半必死。规避死锁最简单的办法就是全局约定加锁顺序保证所有代码对同一组锁都是按相同顺序获取的。另外tryLock配合超时也能化解死锁——等不到锁就放弃不让线程无限期挂起。4.3 CPU飙到100%怎么办高并发系统最常见的线上故障就是CPU飙升。记住CPU高不一定代表坏事可能是正在全力处理请求但如果是代码bug导致死循环或锁自旋那就是事故。我的排查流程是这样的首先用top看进程和线程的CPU占用找到罪魁祸首线程的PIDtop -Hp java进程PID然后把这个线程PID转成十六进制printf %x 线程PID再用jstack导出线程快照搜索这个十六进制线程号就能定位到是哪个类的哪一行代码在疯狂刷CPU。整个过程五分钟以内是Java工程师的必修技能。常见的CPU飙升原因有这么几类死循环、正则表达式回溯、GC频繁导致CPU空转、序列化在高并发下开销过大。最后那一条我专门踩过系统流量上来之后CPU飙升查到最后竟然是JSON序列化占了一半多的CPU。换成更高效的序列化方案问题立刻缓解。4.4 数据一致性怎么保证高并发场景下数据一致性是比性能更棘手的问题。扣库存超卖是数据不一致下单成功但钱包没扣款也是数据不一致。Java层面能做的其实很有限核心思路是让数据库帮我们把关。单库场景下利用数据库事务是最可靠的。扣库存和创建订单放在同一个本地事务里要么都成功要么都回滚Transactional(rollbackFor Exception.class) public OrderResult createOrderAndDeductStock(OrderRequest request) { // 创建订单 orderMapper.insert(convertToOrder(request)); // 扣减库存乐观锁/条件更新 int rows stockMapper.deductStockByCondition(request.getSkuId(), request.getCount()); if (rows 0) { throw new BusinessException(库存不足); } return OrderResult.success(); }一旦拆成多个服务、多个数据库就得引入分布式事务或者更务实的最终一致性方案本地消息表、事务消息、对账补偿。这块内容展开又是一篇长文这里只说一个原则别轻易追求强一致性先想清楚业务能不能接受短暂的最终一致。秒杀、下单、支付这类场景绝大多数是可以接受异步对账兜底的。5. 给初学者的两句实在话文章写到这儿核心内容基本都覆盖了。最后说点个人感受。很多初学者学高并发特别喜欢一上来就啃源码、背八股什么AQS、CAS、CLH队列背得滚瓜烂熟但真让他写一个高并发的接口写出来的东西一压测就崩。我的建议是先跑起来再深挖原理。把这篇文章里说的线程池、锁、并发容器、CompletableFuture这四件套用到一个真实项目里把它跑起来、压一压、调一调遇到问题再用jstack、jstat去看。你在实战中踩过的每一个坑都比背诵十遍八股文更有价值。还有一件事高并发和所有编程能力一样是系统性工程。你的代码写得再漂亮数据库索引缺失、连接池配太小、缓存没用上一样扛不住流量。所以别只盯着并发工具用数据库调优、缓存设计、Linux性能排查这些基本功都是高并发体系的一部分。把这篇文章当成一个索引顺着每一个知识点往下挖这套能力迟早是你的。
返回列表