资讯动态

细粒度锁与并发优化:从锁竞争到无锁化实战

发布时间:2026/10/17 23:40:44 来源:尧图企业网站定制
并发性能遇到瓶颈时最先被怀疑的总是数据库但很多时候真正的卡点藏在代码里的锁竞争上。尤其是那种接口一压测就CPU飙升、吞吐上不去的系统把线程堆栈打出来一看全堵在同一把大锁上排队。细粒度锁与并发优化就是冲着这类问题去的。这篇文章会从锁竞争的本质讲起拆解缩小临界区、读写分离、分段锁、无锁化这几种主流的细粒度优化手法再以一个实际的重库存扣减服务为例完整走一遍从粗粒度锁演进到分段锁乐观锁的改造过程最后分享我在排查死锁、伪共享、性能不降反升这些坑时积累的实战经验。无论是用Java写业务接口的同学还是做中间件、基础框架开发的同路人这篇内容都值得花几分钟过一遍。1. 先聊清楚细粒度锁到底在解决什么问题1.1 锁竞争才是并发性能的真凶很多人对并发编程的认知停留在“多线程要加锁防数据错乱”这一层但真正把系统性能压垮的往往不是数据竞争本身而是锁竞争引发的线程阻塞、上下文切换和CPU缓存失效。我举个例子。假设你有100个线程同时在跑一个加了重量级锁的临界区临界区本身只要1毫秒但锁的竞争让大部分线程都在阻塞队列里等着。Java里synchronized和ReentrantLock在竞争激烈时都会发生线程挂起和唤醒一次挂起/唤醒的代价大约在几微秒到几十微秒不等加上操作系统调度和上下文切换的开销实际吞吐会断崖式下跌。我以前调过一套模拟的报表生成服务200个并发请求进来TPS只有三百多但数据库负载其实很低。用jstack一看超过一半的线程都阻塞在同一个ReportCache对象的锁上。问题的本质不是锁用错了而是一把锁保护了太多内容——缓存读、缓存写、统计计算、日志记录全塞在一个临界区里。这种场景下锁的粒度就是性能天花板。细粒度锁的思路说白了就是把“一把锁看全厂”变成“每人一把锁管自己的工位”。锁粒度越细不同线程同时进入临界区的概率就越高锁竞争自然下降。但这里有个前提粒度的细化不能破坏数据一致性否则就变成了用正确性去换性能迟早要还。1.2 细粒度与粗粒度的权衡之道这里有一个很关键的理念细粒度不是越细越好而是“够用就好”。锁的开销分两部分一部分是锁竞争时的阻塞/唤醒开销另一部分是加锁/解锁本身的固定开销原子操作、内存屏障等。当临界区极短时后者的占比会非常高这时候盲目拆锁反而会让性能更差。我常用的判断标准有三个维度临界区执行时间如果临界区只有几条赋值语句用原子变量或自旋锁可能比互斥锁更合适共享数据的访问频率读多写少走读写锁或CopyOnWrite写多读少则要慎重读锁太重反而拖慢写路径锁竞争的概率两个线程同时操作同一把锁的概率越低拆锁的收益越明显。还有一点容易被忽略锁粒度变细之后代码的圈复杂度会明显上升。一个类里面可能出现五六把锁锁的获取顺序稍有疏忽就会埋下死锁隐患。所以做细粒度优化之前先想清楚你手里这把锁的“责任边界”到底应该划到哪里不要为了秀操作把简单系统做成锁迷宫。2. 细粒度锁的四种主流实现手法2.1 缩小临界区最基础也最容易被忽视细粒度锁的第一层优化不是去换更高级的锁而是老老实实把临界区代码缩小。很多人在加锁时习惯把整个方法体包住比如public synchronized void updateOrder(Order order) { // 1. 校验订单状态 // 2. 查数据库 // 3. 执行复杂的金额计算 // 4. 更新缓存 // 5. 写审计日志 }这个写法的问题在于校验订单状态和写审计日志根本不涉及共享可变状态它们完全可以在锁外面执行。锁的作用是保护共享资源而不是保护整个业务流程。正确的做法是把锁的范围压缩到真正操作共享变量的那几步public void updateOrder(Order order) { // 1. 校验订单状态无共享可变状态不需要锁 // 2. 查数据库无共享可变状态不需要锁 // 3. 执行金额计算无共享可变状态不需要锁 synchronized (this) { // 4. 用共享的计数器更新缓存統計 stats.add(order.getAmount(), 1); } // 5. 写审计日志无共享可变状态不需要锁 }缩小临界区之后锁的持有时间大幅缩短线程在临界区外并行执行的部分增多系统吞吐量自然就上来了。这一步看似简单但需要你对自己代码的“共享区域”有非常明确的认知。我见过不少开发者把无共享的耗时代码塞进锁里比如在锁内调用远程接口或者做磁盘IO这种操作一旦碰上对方服务响应慢所有等待锁的线程都会被拖死。2.2 读写分离读多写少场景的利器缩小临界区只是减少了锁内的操作量但如果一把锁同时保护读和写读操作之间也会相互阻塞。读数据本身不会修改共享状态理论上完全可以让多个读线程并行执行只有写线程和读线程、写线程和写线程之间才需要互斥。Java里最常见的读写锁是ReentrantReadWriteLock用起来倒也简单private final ReadWriteLock lock new ReentrantReadWriteLock(); private final Lock readLock lock.readLock(); private final Lock writeLock lock.writeLock(); public Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } public void put(String key, Object value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } }但读写锁的坑也不少。首先是“写饥饿”问题如果读线程持续不断读锁一直被占用写锁可能长期拿不到写线程被饿死。ReentrantReadWriteLock默认是非公平模式在高并发读多写少场景下可以用new ReentrantReadWriteLock(true)开启公平模式来缓解写饥饿但公平模式下的吞吐量会有所下降需要平衡。另外读锁重入也有一些限制。读写锁不支持锁升级也就是你不能在持有读锁的情况下去获取写锁否则会死锁。锁降级是支持的从写锁降级到读锁是允许的但实际开发中不建议频繁使用降级操作容易把代码逻辑绕晕。读多写少还有一个更极端的优化方向CopyOnWriteArrayList和CopyOnWriteArraySet。它们把读操作完全去掉锁写入时复制新数组再整体替换读线程永远无锁。代价是写入成本较高适合读操作极多、写操作极少的场景比如配置信息的存储。2.3 分段锁与锁池按数据维度拆锁当共享数据本身是一个可以按维度划分的集合时分段锁就是更彻底、更有效的细粒度方案。它的核心思想是把一个大容器拆成多个小容器每个小容器单独配一把锁线程只锁自己操作的那个分段。Java的ConcurrentHashMap是分段锁的经典实现虽然1.8之后换成了CASsynchronized的桶级锁但分段思想仍然值得学习。手动实现分段锁也不复杂通常的做法是在类内部维护一个锁数组通过哈希取模决定使用哪把锁public class StripedLockMapK, V { private static final int STRIPES 16; private final Object[] locks new Object[STRIPES]; private final MapK, V map new HashMap(); public StripedLockMap() { for (int i 0; i STRIPES; i) { locks[i] new Object(); } } private Object stripeFor(K key) { return locks[Math.abs(key.hashCode() % STRIPES)]; } public void put(K key, V value) { synchronized (stripeFor(key)) { map.put(key, value); } } public V get(K key) { synchronized (stripeFor(key)) { return map.get(key); } } }分段数量的选择有讲究。分段太少锁竞争依旧严重分段太多锁数组本身的内存开销和哈希取模的冲突都会增加。一般来说分段数取16到64比较合适也可以根据CPU核数和数据规模动态调整。锁池则是另一种思路它不是按分段拆分而是为每个业务实体分配一把独立的锁。比如在库存扣减系统中针对每个商品ID维护一把锁通过一个ConcurrentHashMapString, Lock来管理。这种方式的粒度更细但要注意键集合的无限增长问题。我习惯在锁对象使用完后做一次移除或者对锁池做容量上限控制避免内存泄漏。2.4 无锁化替代原子操作与乐观锁细粒度锁优化到极致就是把锁彻底拿掉。这听起来激进但在特定场景下其实是更可靠的选择。原子操作是最基础的无锁化手段。通过CAS指令可以在硬件层面保证“比较—交换”的原子性避免线程切换的开销。Java里对应的就是AtomicInteger、AtomicLong、AtomicReference等。一个计数器如果只是做自增操作完全没有必要加synchronizedprivate final AtomicLong count new AtomicLong(0); public void incr() { count.incrementAndGet(); }CAS的问题在于ABA问题也就是变量的值从A变成B又变回ACAS无法感知到中间的改动。如果业务场景关心中间变化需要加版本号或时间戳标记来做比对这正是乐观锁的核心思想。乐观锁在数据库层最常见的就是版本号控制UPDATE stock SET quantity quantity - #{buyNum}, version version 1 WHERE product_id #{productId} AND quantity #{buyNum} AND version #{version}UPDATE返回的影响行数为0时说明版本冲突或库存不足由业务层重试或返回失败。这种方式完全没有锁阻塞在高并发写入冲突不严重的场景下表现极佳但如果冲突率非常高会导致大量的重试请求反而浪费数据库连接和CPU资源。无锁化的另一个方向是LongAdder它把单一计数拆成多个Cell每个线程只更新自己对应的Cell最终汇总时再求和。它的吞吐量比AtomicLong高很多代价是sum()时有轻微的不实时性。适合用于指标统计、流量计数等允许一定延迟的场景。3. 从粗到细一个库存扣减服务的完整锁优化实操3.1 初版实现一把大锁到底为了把前面的理论串起来我模拟了一个某电商平台的库存扣减服务作为案例。这个服务的初始版本非常简单粗暴直接在一个方法上加了synchronized把所有库存操作全部串行化public class StockService { private MapString, Integer stockMap new HashMap(); public synchronized boolean deductStock(String productId, int num) { Integer stock stockMap.get(productId); if (stock null || stock num) { return false; } // 模拟耗时操作查询商品信息、校验状态 simulateCost(); stockMap.put(productId, stock - num); return true; } public synchronized void addStock(String productId, int num) { Integer stock stockMap.getOrDefault(productId, 0); simulateCost(); stockMap.put(productId, stock num); } }初版逻辑在并发量低时完全够用但压测到500并发后TPS只有个位数线程堆栈显示几乎所有线程都阻塞在deductStock的锁上。问题很明显这把全局锁把所有商品、所有操作都串行化了。不同商品之间的库存扣减明明互不影响却被强制排队读操作本来不需要串行也被锁堵住simulateCost这种耗时操作本可以在锁外执行也被包裹在了临界区里。3.2 第一次演进按商品ID拆锁第一版优化思路是把“一把全局锁”替换为“按商品ID拆分锁”。这样不同商品的库存操作可以完全并行只有同一商品的操作才会竞争。public class StockService { private MapString, Integer stockMap new HashMap(); private final MapString, Object lockPool new ConcurrentHashMap(); private Object getLock(String productId) { return lockPool.computeIfAbsent(productId, k - new Object()); } public boolean deductStock(String productId, int num) { Object lock getLock(productId); synchronized (lock) { Integer stock stockMap.get(productId); if (stock null || stock num) { return false; } simulateCost(); stockMap.put(productId, stock - num); return true; } } public void addStock(String productId, int num) { Object lock getLock(productId); synchronized (lock) { Integer stock stockMap.getOrDefault(productId, 0); simulateCost(); stockMap.put(productId, stock num); } } }这版改造的收益非常明显。压测500并发下TPS直接从个位数涨到了好几千因为大部分商品之间的锁竞争彻底消失了。CPU利用率反而下降了不少线程不再疯狂上下文切换系统整体压力小了很多。但这个版本有个隐患lockPool里的锁对象只增不减且没有清理机制。商品数量如果无限增长锁池会占用大量内存。另一个问题是simulateCost这个耗时操作还是在锁内部执行的。3.3 第二次演进分段锁乐观锁组合针对第一版的不足我做了一次更细化的组合优化。思路是锁池改为固定分段锁用哈希取模分配同时引入数据库乐观锁将库存校验和扣减放到一条SQL里完成让锁保护的临界区缩短到极致。public class StockService { private static final int SEGMENT_COUNT 32; private final Object[] segments new Object[SEGMENT_COUNT]; private final MapString, Integer stockMap new ConcurrentHashMap(); public StockService() { for (int i 0; i SEGMENT_COUNT; i) { segments[i] new Object(); } } private Object segmentFor(String productId) { int hash productId.hashCode() Integer.MAX_VALUE; return segments[hash % SEGMENT_COUNT]; } /** * 扣减库存先无锁校验再走数据库乐观锁更新 * 这里模拟的是SQL原子操作的逻辑 */ public boolean deductStock(String productId, int num) { // 无锁快路径库存充足才继续缓存里的值仅供参考 Integer cached stockMap.get(productId); if (cached ! null cached num) { return false; } // 模拟数据库乐观锁扣减 Object segment segmentFor(productId); synchronized (segment) { while (true) { Integer current stockMap.get(productId); if (current null || current num) { return false; } // CAS扣减拿版本号做ABA判断 boolean success compareAndSwap(productId, current, current - num); if (success) { return true; } // CAS失败说明被别人改了重试 } } } private boolean compareAndSwap(String productId, int expect, int update) { synchronized (segmentFor(productId)) { Integer current stockMap.get(productId); if (current null || current.intValue() ! expect) { return false; } stockMap.put(productId, update); return true; } } }这里我把compareAndSwap方法内的锁替换的直接包一层结构来模拟CAS语义。实际生产环境中更推荐直接用ConcurrentHashMap自带的compute方法或者AtomicInteger来管理库存数值比如private final ConcurrentHashMapString, AtomicInteger stockMap new ConcurrentHashMap(); public boolean deductStock(String productId, int num) { AtomicInteger stock stockMap.computeIfAbsent(productId, k - new AtomicInteger(0)); while (true) { int current stock.get(); if (current num) { return false; } if (stock.compareAndSet(current, current - num)) { return true; } } }这个写法本质上是CAS自旋完全去掉了synchronized同一商品的并发操作通过AtomicInteger内置的CAS保证原子性。自旋重试会占用CPU但因为临界区只有一条compareAndSet指令重试的时间窗口极短收益远大于代价。3.4 压测对比与参数分析在实战优化过程中我习惯把每一次演进的压测数据记录下来做对比。下表是我曾经在某套模拟环境下的实测数据500并发、单商品热点扣减、同一压测脚本版本锁策略TPS(单商品热点)TPS(多商品随机)CPU占用v1全局synchronized约320约33086%v2按商品ID锁池约1280约980042%v3分段锁CAS约2560约1120035%v4AtomicIntegerCAS约3450约1190029%注意看一个有意思的现象按商品ID锁池版本在多商品随机场景下TPS反而比全局锁版本高得多这说明锁竞争的消除对吞吐量的提升是关键因素。但单商品热点场景下v2版本的性能提升有限因为同一个商品的并发操作仍然串行竞争。CPU占用从v1到v4一路下降说明上下文切换和线程阻塞的大幅减少才是性能改善的真正主因。这里也要提醒一点压测得出的数字受很多因素影响如果你的机器配置、JVM参数、临界区耗时和我的测试环境不同具体数值可能完全不一样。但“锁粒度变细后吞吐量提升、CPU占用下降”这个趋势是放之四海而皆准的。4. 常见问题与排查技巧实录4.1 死锁排查与预防细粒度锁最让人头疼的问题就是死锁。多把锁交错获取时线程A持有锁1等待锁2线程B持有锁2等待锁1两边谁都不让系统就卡死了。排查死锁的第一步是用jstack抓线程快照死锁信息会直接打印出来。Java的ThreadMXBean也能在运行期检测死锁可以在监控系统里做实时告警ThreadMXBean tmx ManagementFactory.getThreadMXBean(); long[] ids tmx.findDeadlockedThreads(); if (ids ! null) { ThreadInfo[] infos tmx.getThreadInfo(ids); // 记录日志并告警 }预防死锁的手段优先推荐“全局顺序加锁”。比如系统里有两把锁A和B所有线程都先锁A再锁B就不会出现交叉等待。如果用的是按商品ID分散的锁尽量避免在持有一把锁的情况下去获取另一把锁能用无锁原子操作解决的情况下不要引入第二把锁。还有一个很实用的技巧给锁加超时时间。ReentrantLock.tryLock(3, TimeUnit.SECONDS)可以避免线程无限期等待超时后重新尝试或释放已持锁资源能在一定程度上缓解活锁和死锁问题。4.2 粒度太细性能反而不升反降优化后性能没有提升甚至下降了这种情况我遇到过不止一次。最常见的根因是临界区太短锁的固定开销成了瓶颈。互斥锁的加锁/解锁操作本身需要两次原子操作加一次内存屏障这个成本大约是几十纳秒。如果临界区里只有一行赋值语句锁开销可能占到整个操作耗时的80%以上。这时候加锁不但没有提升并发度反而拖慢了正常执行速度。正确的应对方式不是继续拆锁而是考虑三种替代方案用volatile代替锁如果共享变量只是做单变量的读写volatile可以保证可见性和有序性开销远低于锁用原子变量代替锁自增、自减、CAS操作交给AtomicLong处理用无锁数据结构如ConcurrentLinkedQueue、ConcurrentHashMap等。判断锁是否已经成为瓶颈的方法很简单用perf或者jfr抓火焰图如果在加锁/解锁相关的栈帧上消耗过多CPU时间说明锁本身的开销已经超过了竞争的开销这时候需要换个思路而不是继续细化锁的粒度。4.3 伪共享一个隐蔽的并发性能黑洞伪共享False Sharing是并发优化中特别容易被忽视的问题。多个线程操作不同的变量但刚好这些变量落在同一个CPU缓存行64字节里某个线程修改其中一个变量会导致其他线程的缓存行失效强制重新从内存加载。Java中最典型的伪共享场景是数组中的多个计数器或者同一个类里相邻声明的多个长整型变量。比如class Counter { volatile long countA; volatile long countB; // countA和countB大概率在同一缓存行 }避免伪共享有几种做法。一种是给变量填充padding把缓存行占满class PaddedCounter { volatile long count; long p1, p2, p3, p4, p5, p6, p7; // 填充到64字节 }Java 8之后提供了Contended注解可以直接让JVM自动填充class Counter { Contended volatile long countA; Contended volatile long countB; }不过Contended默认只在JVM内部类中生效自定义类要加-XX:-RestrictContended参数才能开启。伪共享的排查比较麻烦可以通过perf c2c或者JFR的缓存线争用事件来分析但更高效的方式是在设计数据结构时尽量避免多个热点变量相邻声明。4.4 除锁之外并发优化的整体思路排查过很多系统之后我越来越觉得锁优化只是并发优化的其中一环它解决的是“线程之间竞争共享资源”的问题但并发瓶颈还可能出现在更前端的环节。第一是减少共享本身。能不共享的就不共享比如把可变控制在方法范围内传递用到线程封闭的思路能只读的就用不可变对象ImmutableMap用起来比每次加读写锁方便多了。数据无共享的时候锁自然也就不需要了。第二是做好系统分层让并发压力分散到不同层级。比如用户请求量很大时与其让所有压力都打到底层服务不如在接入层做限流和缓存流量打到数据库前先经过本地缓存分布式缓存兜底。每一层把热点消掉一部分到锁层的压力自然就低了。第三是学会用工具说话。前面多次提到的jstack、JFR、perf、火焰图、async-profiler都是在调优之前观察瓶颈的利器。不要一上来就猜什么地方慢先把线程堆栈和火焰图抓出来用数据定位锁竞争的真实位置再制定针对性的优化策略这样效率最高、方向也最不会跑偏。我在实际处理并发问题时最深刻的体会是细粒度锁的改造表面上动的是代码本质上动的是你对自己系统数据流向的理解。你得非常清楚每个共享变量被哪些线程读、被哪些线程写、写的频率高不高、临界区耗时有多长才能判断该拆锁、该换锁、还是该去锁。如果你刚开始接触并发优化可以从最简单的一步做起——把系统里synchronized修饰整个方法的地方全部审视一遍能缩小临界区的先缩小能无锁化的无锁化压测验证再决定要不要上分段锁。用一次完整的改造从头到尾过一遍之后你对并发的感知会和只看理论时完全不一样。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑