本文目录导读:
这是一个非常经典且核心的并发编程问题,粗粒度锁(比如给整个方法或整个数据结构加一把大锁)实现简单、不易出错,但会严重限制并发性能,将其优化细化为细粒度锁,核心思路是“锁尽可能少的数据,让不相关的操作并行执行”。
下面是一个系统性的优化拆分思路和具体实践方法,从简单到复杂。
核心原则
- 锁的粒度与数据关联性:锁保护的数据集合越小,锁的粒度越细。
- 锁的持有时间:锁持有时间越短,竞争越小,不要在持锁期间做I/O操作、复杂计算或调用外部不可控服务。
- 锁的争用程度:频繁被争用的锁是瓶颈,需要优先拆分。
具体优化拆分方法(按复杂度排序)
读写锁分离 (ReadWriteLock / StampedLock)
适用场景:读操作远多于写操作(典型如缓存、配置中心、读多写少的计数器)。
-
问题:粗粒度锁会阻塞所有读线程,即使它们之间并不冲突。
-
优化:允许多个读线程并发访问数据,写线程在写时独占。
-
代码示例 (Java):
// 粗粒度:使用 synchronized public synchronized Object get(String key) { ... } public synchronized void put(String key, Object val) { ... } // 细粒度优化:使用 ReentrantReadWriteLock private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); public Object get(String key) { readLock.lock(); try { // 读数据 } finally { readLock.unlock(); } } public void put(String key, Object val) { writeLock.lock(); try { // 写数据 } finally { writeLock.unlock(); } }
分段锁 (Lock Striping)
适用场景:大型、可被键值(Key)或索引(Index)拆分的数据结构,如 ConcurrentHashMap。
-
问题:给整个Map加锁,一次只能一个线程操作。
-
优化:将数据分成若干段(Segments或Buckets),每个段有自己的锁,线程操作某个Key时,只锁定该Key所在的段,不同的段可以完全并发。
-
实现方式:
- 数组 + 链表/红黑树:如 ConcurrentHashMap 的桶(Bucket)级别锁。
- 哈希取模:
int segmentIndex = key.hashCode() % NUM_OF_SEGMENTS,然后只锁定对应的segmentLocks[segmentIndex]。
-
代码示例 (简化版伪代码):
// 粗粒度:整个Map一把锁 class CoarseMap<K,V> { private final Object lock = new Object(); Map<K,V> map = new HashMap<>(); V put(K k, V v) { synchronized(lock) { return map.put(k, v); } } } // 细粒度优化:分段锁 class StripedMap<K,V> { private final Object[] locks; private final Map<K,V>[] segments; StripedMap(int concurrencyLevel) { locks = new Object[concurrencyLevel]; segments = new Map[concurrencyLevel]; for (int i = 0; i < concurrencyLevel; i++) { locks[i] = new Object(); segments[i] = new HashMap<>(); } } V put(K k, V v) { int hash = k.hashCode(); int segmentIndex = hash & (locks.length - 1); // 取模 synchronized (locks[segmentIndex]) { // 只锁定该段 return segments[segmentIndex].put(k, v); } } }
热点分离 (Hotspot Stripping / 剥离热点)
适用场景:某些特定数据(如一个全局计数器、一个热门商品的库存)被大量线程频繁修改。
-
问题:即使使用了读写锁或分段锁,但某个特定的“热点”数据仍然会被大量线程争用。
-
优化:
- 计数器拆分:将一个全局
AtomicLong counter拆分成AtomicLong[] counters,线程更新时选择一个随机或基于线程ID的计数器(如counters[threadId % N]),汇总时再累加。 - 库存拆分:将热门商品的1000件库存拆分成N个“分库存”槽位(库存分片),每个槽位有1000/N件库存和自己的锁,用户下单时随机选择一个槽位扣减,失败则重试另一个槽位。
- 计数器拆分:将一个全局
-
代码示例 (计数器拆分):
// 粗粒度:一个全局 synchronized 计数器 class CoarseCounter { long count = 0; synchronized long increment() { return ++count; } } // 细粒度优化:拆分成多个槽位,近似于 LongAdder 的设计 class StripedCounter { private final AtomicLong[] counters; StripedCounter(int concurrencyLevel) { counters = new AtomicLong[concurrencyLevel]; for (int i = 0; i < concurrencyLevel; i++) { counters[i] = new AtomicLong(0); } } long increment() { int index = ThreadLocalRandom.current().nextInt(counters.length); return counters[index].incrementAndGet(); // 无锁CAS,非常高效 } long sum() { long sum = 0; for (AtomicLong c : counters) { sum += c.get(); } return sum; } }
无锁数据结构 (Lock-Free / CAS)
适用场景:对单个变量或简单结构进行更新,且争用不极端的情况(极端争用仍需考虑拆分成多个槽位)。
- 优化:用
AtomicInteger,AtomicReference,LongAdder等原子变量替代synchronized,更高级的包括StampedLock(乐观读)、ConcurrentLinkedQueue等。 - 优势:无锁,不涉及线程上下文切换和阻塞,性能极高。
- 注意:CAS自旋在极高争用下可能浪费CPU,此时分段锁可能更优。
缩小锁范围 (减少锁持有时间)
适用场景:所有锁场景。
-
问题:在锁内执行了昂贵的非必要操作(如IO、类加载、对象创建)。
-
优化:只在锁内完成最核心的共享数据访问,将耗时操作移到锁外。
-
代码示例:
// 粗粒度:整个方法加锁 public synchronized String loadFromCache(String key) { String result = cache.get(key); if (result == null) { result = loadFromDatabase(key); // 非常慢,长时间持锁 cache.put(key, result); } return result; } // 细粒度优化:双检锁(Double-Checked Locking),只在真正需要访问缓存时加锁 public String loadFromCache(String key) { String result = cache.get(key); // 不加锁读 if (result == null) { // 此时加锁 synchronized (this) { // 再次检查,防止其他线程已经写入(双检) result = cache.get(key); if (result == null) { result = loadFromDatabase(key); // 加锁期间只做数据库加载 cache.put(key, result); } } } return result; }更优的实践是使用
ConcurrentHashMap.computeIfAbsent()直接内置了高效的单例逻辑。
警惕拆分陷阱
- 死锁风险:当需要同时获取多个细粒度锁时,必须保证所有线程获取锁的顺序一致(全局顺序锁),否则极易死锁。
- 例:操作A需要锁对象1和2,操作B需要锁对象2和1,如果A拿到1,B拿到2,就死锁了,解决方法:按对象ID排序后加锁。
- 并发度 < 预期:分段数太少,仍会竞争;分段数太多,锁占用内存且增加哈希计算开销,通常分段数设为
16 * N(N为CPU核心数)或根据实际争用情况调整。 - 复合操作原子性破坏:拆分锁后,原本一个原子操作(如“先检查再更新”、“转账-扣减A余额&增加B余额”)无法在单个锁内完成,需要额外的机制来保证原子性(如
compareAndSet循环、全局时序锁或版本号)。 - 数据一致性复杂度:分段锁或热点分离后,一致性模型从“强一致性”退化为“最终一致性”或“基于快照的一致性”,例如库存分片可能存在短暂的“超卖”风险,需要业务上容忍或用更复杂的协议(如两阶段提交)。
总结与推荐策略
| 场景 | 粗粒度锁 | 推荐细化方案 |
|---|---|---|
| 读多写少 | 读写都加 synchronized |
读写锁 (ReadWriteLock) 或乐观锁 (StampedLock) |
| 大型哈希表 | 整个 HashMap 一把锁 |
分段锁 (模仿 ConcurrentHashMap) |
| 全局计数器 | synchronized 递增 |
拆分多个槽位 (LongAdder 或 StripedCounter) |
| 缓存加载 | 整个方法加锁,包括DB调用 | 双检锁 + 缩小锁范围,或 ConcurrentHashMap.computeIfAbsent() |
| 多个对象操作 | 一个全局大锁保护所有对象 | 对象级锁,但注意获取多个锁时必须保证锁顺序(如按ID排序) |
| 极度高频争用 | 所有操作被一把锁阻塞 | 考虑 Lock-Free 数据结构 (CAS)或 最终一致性(业务容忍)+ 队列 |
最终建议: 不要一开始就追求极致的细粒度。正确的姿势是:先用粗粒度锁保证正确性,然后通过性能分析工具(Profiler)锁定真正的锁竞争热点,最后只针对热点进行上述的针对性细化拆分。 过度设计细粒度锁可能引入死锁和复杂性,而收益却寥寥。
标签: 锁拆分