ARTICLE DETAIL

资讯详情

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

高并发Java多线程优化实战:线程池、锁与并发容器调优

高并发Java多线程优化实战:线程池、锁与并发容器调优 做过几年Java后端大大小小的系统优化也折腾了不少。前年接手一个订单履约服务大促高峰期单个接口的响应时间从80ms一路飙到3s数据库CPU直接跑满加了8台机器也没啥改善。后来排查发现瓶颈根本不在机器数量上而在应用层那层线程处理逻辑——线程池参数拍脑袋配的、锁的粒度粗得吓人、并发容器还在用Hashtable。那段时间我把Java多线程机制整个重新撸了一遍从线程池、锁、并发容器到具体的高并发场景逐步调优才把核心接口的吞吐量提了上来也踩了不少坑。这篇文章就把这段经历整理出来聊聊高并发应用优化到底该怎么下手。我不会只讲API和理论重点放在线程池到底怎么配、锁该怎么选、并发容器怎么避坑以及一个完整的库存扣减场景从单机锁到分布式预扣的演进过程。无论你在做高并发项目还是准备Java多线程相关的面试或者刚开始学Java并发编程这篇文章都应该能给你一些能直接落地的参考。1. 先搞清楚问题边界高并发瓶颈到底在哪1.1 并发和并行不是一回事很多刚接触多线程的同事一想到高并发优化第一反应就是多开几个线程。这个方向不能说错但非常容易跑偏。并发Concurrency和并行Parallelism是两个层面的东西并发强调的是多个任务在同一时间段内交错执行哪怕只有一个CPU核心也能做到并行指的是多个任务在同一时刻同时执行必须依赖多核CPU。高并发应用要解决的问题通常不是有没有线程在同时跑而是单位时间内能不能处理完这么多请求。如果一台机器只有一个核心开1000个线程和开10个线程最终完成的任务总量差别不大反而可能因为线程太多导致上下文切换开销暴增吞吐量直线下降。所以我在做优化前一定会先问三个问题当前系统的瓶颈在CPU、内存、数据库还是锁竞争每个请求的计算耗时和等待耗时大概是多少目前线程池的真实使用率是多少这三个问题不搞清楚后面所有优化都是盲人摸象。1.2 从线程生命周期看资源消耗Java线程从创建到销毁要经历NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED这几种状态。很多人背得滚瓜烂熟但真到了线上排查能把状态和资源消耗对应起来的人就不多了。线程在TIMED_WAITING和WAITING状态时是让出CPU的不占用计算资源但线程本身仍然占用内存。Java线程栈默认大小一般是512KB到1MB一个普通的JVM进程开200个线程光线程栈就可能占掉200MB内存。这还不算线程内核对象资源以及线程调度带来的上下文切换成本。上下文切换是隐形的性能杀手。每次切换操作系统都要保存当前线程的寄存器状态、程序计数器、内存映射信息然后加载下一个线程的状态。这个过程涉及用户态到内核态切换代价不低。我压测过一台8核机器线程池核心线程数从8调到32QPS确实涨了继续调到128QPS反而跌了20%CPU大量消耗在切换线程上而不是执行业务逻辑。用vmstat看cscontext switch列数字高得吓人。所以Java多线程优化高并发应用的第一步不是学一堆并发工具类而是建立线程是资源不是玩具的意识。合理控制线程数量比盲目创建线程更有价值。2. 线程池高并发应用的稳定基石2.1 核心参数到底怎么配线程池的实际工作中最常用的就是ThreadPoolExecutor。它的构造参数有六个corePoolSize、maximumPoolSize、keepAliveTime、workQueue、threadFactory和RejectedExecutionHandler。网上讲每个参数含义的文章很多但能把为什么这样配讲清楚的少。我习惯把线程池想象成一家银行柜台。corePoolSize是正式柜员数量平时固定这么多workQueue是等待区柜员忙不过来时客户去排队maximumPoolSize是旺季临时加开的窗口数量keepAliveTime是临时窗口空闲多久后关闭RejectedExecutionHandler是排队区都满了之后新来的客户怎么办。理解了这个比喻再看任务提交流程就顺了核心线程数没满直接开新线程执行任务。核心线程满了任务先进队列排队。队列满了继续开新线程直到达到最大线程数。最大线程数和队列都满了走拒绝策略。这里有三个关键点。第一核心线程并非一定先创建满新任务进来才创建第二最大线程数和队列是既能填队列又能加线程的关系不是先队列后线程第三线程池的核心逻辑就这一套流程参数怎么定才是核心难题。2.2 队列和拒绝策略的选型逻辑workQueue选什么直接影响线程池在高并发下的表现。常用的三种LinkedBlockingQueue默认无界队列大小可以设很大。优点是几乎不会触发拒绝策略但隐患很大——任务积压多了会占用大量内存而且线程数永远到不了maximumPoolSize因为队列永远没满。很多线上OOM就是这么来的。SynchronousQueue不存任务生产者直接把任务交给线程。这种情况下线程池必须保证maximumPoolSize足够大否则新任务马上被拒绝。适合任务量波动大、希望快速处理的场景。ArrayBlockingQueue有界队列可以控制缓存上限。这是我最常用的选择因为它能同时限制排队长度和线程数给系统留一个明确的安全边界。拒绝策略默认是AbortPolicy直接抛RejectedExecutionException。很多团队配了线程池之后不管这块结果流量一冲直接抛异常。我推荐在关键业务上用CallerRunsPolicy它的意思是队列满了任务不丢掉回到提交任务的线程一般是请求线程去执行。代价是提交线程被阻塞但保证了任务一定被执行在流量过载时反而是一种天然的背压机制能把压力回传到上游。2.3 线程数量计算与动态调整线程池的线程数配置网上流传两个经验公式CPU密集型任务配N1个线程IO密集型任务配2N个线程N是CPU核心数。这个公式简单但过于粗糙因为IO等待的时间比例不同结果差异巨大。更科学的方法是Brian Goetz在《Java并发编程实战》里提的思路线程数 N * (1 期望的CPU利用率 * (等待时间 / 计算时间))举个例子一个接口调第三方服务计算耗时10msIO等待100ms机器是8核CPU利用率目标80%线程数 8 * (1 0.8 * (100 / 10)) 8 * (1 8) 72比2N16大得多。因为IO等待比例高CPU有大量空闲需要更多线程来填补。反过来如果纯计算任务等待时间接近0线程数就是N19这样。我遇到过最稳妥的做法是先按公式算一个初始值然后做压测观察CPU利用率、QPS、RT的曲线再调整。线上流量是动态的如果只有固定线程池流量高峰时响应变慢、队列堆积流量低谷时线程空闲浪费。美团的做法是在配置中心放corePoolSize和maximumPoolSize通过监控队列长度和线程活跃度动态修改参数。我后来也用类似思路给线程池加了一个简单的监控钩子定期记录活跃线程数和队列深度配合告警效果比静态配置好太多。3. 锁的选型与优化从synchronized到AQS3.1 synchronized锁升级别被重字吓到很多面试者一说synchronized张口就是重量级锁性能差。但在JDK 6之后synchronized已经被JVM优化了很多它有锁升级机制无锁 - 偏向锁 - 轻量级锁 - 重量级锁。偏向锁解决的是一个锁被同一个线程反复获取的情况第一次拿到锁后后续获取只用CAS记录一下线程ID省掉大量重复的加锁开销。轻量级锁解决的是多个线程交替持有锁的情况用CAS在对象头上做标记不需要操作系统介入。只有发生真正的多线程竞争多个线程同时抢同一个锁才会升级成重量级锁此时才涉及操作系统mutex性能断崖式下降。这个机制解释了为什么单线程场景下synchronized的开销几乎可以忽略也解释了为什么高并发高竞争场景下synchronized会变成瓶颈。所以面试时反复考锁升级不是考八股而是考你知不知道什么时候用synchronized不会被拖垮。3.2 ReentrantLock、ReadWriteLock和StampedLock怎么选ReentrantLock相比synchronized多出几个能力可中断获取锁、超时获取锁、公平锁、多个Condition条件变量。如果你的场景需要等锁超时后返回失败或者读写入队列分别唤醒就需要ReentrantLock。读多写少的业务场景比如商品详情、配置信息查询用ReentrantReadWriteLock可以把读锁和写锁分开多个读线程共享读锁写线程独占写锁。但要注意读写锁有个坑如果一个线程先拿到读锁又去写会死锁。这是使用上的大忌。JDK 8还加了StampedLock它支持乐观读。乐观读不真正加锁而是在读之前记录一个stamp读完之后再验证stamp是否变化如果变化说明有写入再升级成悲观读。Guava的缓存就是这么用的。适合读占比极高且要求低延迟的场景但不支持重入使用难度比ReentrantReadWriteLock高我一般不建议新手直接上。一个简单的对比锁类型应用场景优点缺点synchronized通用互斥代码简洁自动释放锁JVM优化多竞争激烈时仍是重量级无法中断ReentrantLock需要超时、可中断、公平锁灵活性强条件队列好用手动解锁容易漏ReentrantReadWriteLock读多写少读写分离读并发高读锁转写锁会死锁StampedLock读占比极高乐观读性能极佳不支持重入API复杂3.3 锁粒度控制和死锁排查高并发场景下锁的性能问题大多不是锁本身慢而是锁粒度太大。我调过一个库存服务一个方法里锁的范围从查库存到扣减再到写流水全程串行吞吐量上不去。后来按业务拆分扣减库存用互斥锁写流水放到另一个异步渠道吞吐量直接翻了4倍。锁粒度拆分的两个方向一是锁粗化把连续多次加解锁合并成一次减少重复开销二是锁细化把一把大锁拆成多把小锁让不同数据并行处理。ConcurrentHashMap的桶粒度锁和LongAdder的Cell数组都是锁细化的经典例子。死锁的四个必要条件——互斥、持有并等待、不可抢占、循环等待。实际操作中我遇到死锁后第一件事是跑jstack在dump文件里搜索deadlock或者waiting to lock一般很快就能看到两个线程互相持有对方想要的锁。解决思路就两条加锁顺序全局一致或者用tryLock超时替代直接lock。线上排查死锁jstack永远是你的第一工具。4. 并发容器的正确选择从Hashtable到ConcurrentHashMap4.1 并发容器演进背后的设计思想Java老牌的线程安全容器是Hashtable和Vector它们的实现思路很简单所有方法都加synchronized全表锁。这是对的但效率极低高并发下所有操作串行化读和读之间都无法并发。Collections.synchronizedMap是对同步方法的封装本质还是全局锁。真正解决问题的是JDK 5引入的ConcurrentHashMap初始设计采用分段锁——把哈希表分成多个Segment每个Segment独立加锁读写各自锁自己的Segment互不干扰。JDK 8改成了更细粒度的桶锁加CAS写入时用CAS保证头节点更新的原子性发生哈希碰撞时才对单个桶加synchronized锁粒度更小。这个演进过程的核心思想就是保证并发安全的前提下尽量让不同线程操作不同的数据结构区域。理解了这个面试问ConcurrentHashMap和Hashtable区别就变成了聊设计哲学而不是背对比列表。4.2 ConcurrentHashMap的实战细节和容易踩的坑JDK 8的ConcurrentHashMap底层是Node数组加链表加红黑树单个桶内节点数超过8个时链表转红黑树降低查询时间。这些细节面试经常考真正在工作中容易翻车的是下面几个问题。第一putValue等单个操作线程安全但复合操作不是。比如先检查再插入如果直接get后put中间可能被其他线程插一脚。解决办法是使用compute、merge等原子方法把检查逻辑写进函数里由ConcurrentHashMap保证整个操作原子性。第二size()方法在JDK 8不是实时精确值。它内部维护baseCount和CounterCell数组size()返回的是累加估算值在高并发写入时可能不够精确。如果业务需要精确计数例如库存在内存里的剩余量我建议单独用AtomicLong配合操作。第三如果你用ConcurrentHashMap做缓存并存放过期时间还需要自己处理过期清理ConcurrentHashMap没有自动过期机制。需要带TTL的缓存直接用Caffeine更省事。另外提一句并发场景下不要用HashMap做任何写入操作。JDK 7的HashMap在并发扩容时可能形成循环链表导致CPU 100%的严重事故这个历史问题导致很多人现在看到HashMap仍心有余悸。用并发容器直接上ConcurrentHashMap没有纠结的必要。4.3 其他值得用的并发工具除了ConcurrentHashMap高并发场景下还有几个工具类很多人面试答得出名字实际项目里反而没用起来。CountDownLatch适合一个线程等N个线程完成前置任务的场景。比如批量初始化缓存主线程等10个加载线程全部完成后再开始对外提供服务。CyclicBarrier适合N个线程互相等待的场景和CountDownLatch的区别在于它可以重置复用。Semaphore是信号量限流比如数据库连接池最多只放10个连接超过就等待非常适合控制并发访问外部资源的数量。LongAdder是JDK 8引入的原子计数器内部维护一个Cell数组多个线程分别累加到不同Cell最终汇总。竞争激烈时LongAdder性能远优于AtomicLong因为AtomicLong是CAS自旋争抢多的时候CPU空转严重。统计类计数场景例如请求量、QPS统计我会优先选择LongAdder。5. 一个完整案例秒杀场景下的库存扣减优化5.1 场景描述和原始方案业务场景每个商品库存有限大量用户同时下单要保证不超卖同时要尽可能提高下单成功率。这是典型的高并发写场景。最原始的方案是这样写的public synchronized boolean reduceStock(Long skuId, int count) { Stock stock stockMapper.selectBySkuId(skuId); if (stock.getStock() count) { return false; } int rows stockMapper.reduceStock(skuId, count); return rows 0; }方法级synchronized所有库存扣减串行化逻辑上绝对正确没有超卖风险但性能极差。我压测过一个商品5000并发同时扣库存这套方案每秒只能处理大概300个请求其余全部阻塞排队用户侧表现就是页面一直转圈最后失败。5.2 从乐观锁到预扣减的演进第二步改成乐观锁利用数据库行锁的特性update stock set stock stock - #{count} where sku_id #{skuId} and stock #{count}SQL自带条件判断更新成功后影响行数为1失败为0。这里依靠数据库底层的行级锁保证原子性不用在Java代码里加锁并发能力比synchronized高了一个量级。压测数据大约是每秒2000扣减成功。代价是并发特别高时大量请求会在stock count这一步失败用户直接看到已抢光体验不够好。第三步是引入Redis预扣减。用Redis的原子操作decr或Lua脚本先扣减Redis里的库存扣减成功再发MQ消息异步更新数据库。用户看到的是下单成功数据库扣减异步慢慢执行。Lua脚本保证整个判断和执行在同一时刻内完成原子性有保障if tonumber(redis.call(get, KEYS[1])) tonumber(ARGV[1]) then redis.call(decrby, KEYS[1], ARGV[1]) return 1 else return 0 end这个方案压测数据提升非常明显单机达到每秒1万以上的扣减操作。但它引入了新的复杂度Redis和数据库的数据一致性。解决办法是对账任务每隔一段时间扫描Redis库存和数据库库存发现差异就修正。这条链路不是一次性能做到完美的需要在业务容忍范围内做取舍。5.3 压测数据和调优记录我当时在8核16G的机器上用JMeter做了本地压测同一商品库存5000并发线程数分别设为100、500、1000数据如下方案并发100并发500并发1000最终一致性风险synchronized方案约300 TPS约300 TPS约300 TPS无数据库乐观锁约1800 TPS约2000 TPS约1900 TPS无Redis预扣减约9000 TPS约10000 TPS约10000 TPS需要对账一个值得注意的点synchronized方案的TPS不随并发数增加而上升因为它本质是串行。乐观锁方案在并发超过一定量后TPS会小幅下降因为太多请求在数据库层面失败重试。Redis预扣减方案表现最稳定说明把热点扣减从数据库移到缓存层效果立竿见影。但踩过的坑也得说Redis预扣减最大的隐患是Redis宕机或者网络抖动会导致整个下单链路不可用另外MQ消息重复消费会导致数据库扣减重复执行必须做幂等处理。生产环境上线前这两个点一定要设计和测试到位。6. 多线程调优的常见坑和排查工具6.1 上下文切换与伪共享两个隐形杀手线程数量配置不合理最直接的后果就是上下文切换频繁上升。排查上下文切换Linux下用vmstat看cs列如果cs数值长期高于CPU核数的几十倍说明线程切换太频繁需要调低线程数或者重新设计任务分配粒度。伪共享False Sharing是另一个容易被忽视的问题。CPU缓存加载数据是按缓存行通常64字节为单位的两个线程如果频繁修改同一个缓存行里的不同变量会互相拖累表现为看似并行却互相抢缓存。一个典型场景是多个线程维护多个计数器数组相邻元素被不同线程频繁更新就可能触发伪共享。JDK 8提供了Contended注解来避免伪共享但使用前需要通过JVM参数开启。这个知识点面试偶尔会问实际优化时用到的概率不高但遇到性能诡异下降排查不到原因时值得往这个方向怀疑一下。6.2 ThreadLocal和线程池组合内存泄漏隐患线程池的核心线程是复用的生命周期很长而ThreadLocal和线程池结合使用时有个经典陷阱。ThreadLocalMap中的key是弱引用value是强引用线程池里线程长期存活如果业务代码没有显式removevalue会一直卡在线程里时间一长内存就涨上去了表现是Old区持续增长最终触发Full GC甚至OOM。我的习惯很简单凡是往线程池里提交任务任务入口处如果用了ThreadLocal就必须在finally块里remove。这一步没有例外。6.3 排查工具jstack、jstat和Arthas线上遇到线程相关的问题我最常用的排查套路jstack先看线程状态分布。如果大量线程处于BLOCKED状态说明锁竞争严重再dump两次对比锁定热点锁。jstat观察GC情况排除垃圾回收导致的长尾延迟。Arthas可以直接在线排查不需要重启应用它的thread -n 3命令能直接列出CPU占用最高的前三个线程配合thread -b找阻塞锁效率很高。jstack输出里线程名和堆栈信息所对应的业务代码一般是确定它到底卡在哪的第一步不要先急着看数据库或网络很多时候问题就出在共享锁和无界队列上。7. 最后再分享一点实际优化时的个人体会多线程优化高并发应用我最大的感受是先测量再动手。不要一上来就上各种并发工具先把线程池当前的活跃线程数、队列长度、接口RT和TPS测出来找到瓶颈图形的拐点。这个拐点通常是资源配置和业务特性的交叉点光靠理论推不出来必须靠实测。另一个体会是并发优化不是越复杂越好。synchronized打底、必要时ReentrantLock、再用ConcurrentHashMap和线程池做基本盘能解决绝大多数问题。Redis预扣减、分布式锁这种方案只有在确实产生性能瓶颈后才值得引入因为复杂度上去之后排查问题的成本会成倍增加线上出故障时人会非常痛苦。还有一个实用的小技巧线程池的线程名一定要设置成方便识别的业务名比如order-pool-thread-1而不是默认的pool-1-thread-1。排查线上问题的时候光靠线程名你就能快速定位是哪个业务流程在消耗资源这个小习惯省了我很多时间。最后每次调完并发参数记得回填压测记录不然过两个星期连自己都不记得当初为什么这么配了。
返回列表