本文目录导读:
- 合并操作:批量处理(最常见、最有效)
- 缩小临界区:只保护真正需要保护的部分
- 降低锁粒度:分拆锁(分段锁/哈希分片)
- 使用读写锁(ReadWriteLock):区分读与写
- 使用原子操作(无锁编程)
- 使用乐观锁(适合冲突少的场景)
- 使用无锁数据结构
- 优化决策树
“频繁加解锁”通常指的是在多线程或并发编程中,由于锁的粒度太细、加锁次数太多,导致大量的上下文切换和锁竞争,从而严重拖累性能。
要优化“频繁加解锁”,核心思路是:减少加锁次数、缩短持有锁的时间、降低锁的粒度(或使用无锁)。
以下是具体的优化策略,从简单到复杂,从“少锁”到“无锁”:
合并操作:批量处理(最常见、最有效)
如果每次操作都要加锁,尝试把多次操作合并成一次加锁的大操作。
- 场景:比如有个计数器,每次增加 1 都要加锁。
- 优化:使用一个本地缓存(如
ThreadLocal),每个线程先本地累加 100 次,只有到 100 次时才去申请一次全局锁更新。 - 类比:就像去银行存零钱,每次都存 1 块钱(每次加锁)很慢,不如攒够 100 块一次性去存(合并加锁)。
// 优化前:每次加锁
for (int i = 0; i < 10000; i++) {
lock(lockObj) {
sharedCounter++;
}
}
// 优化后:批量合并
int localCount = 0;
for (int i = 0; i < 10000; i++) {
localCount++;
if (localCount >= 100) {
lock(lockObj) {
sharedCounter += localCount;
}
localCount = 0;
}
}
// 最后别忘了把余数加上
if (localCount > 0) {
lock(lockObj) {
sharedCounter += localCount;
}
}
缩小临界区:只保护真正需要保护的部分
如果锁中包含了无关的操作(如日志、I/O、计算),将它们移出锁外。
- 原则:锁只保护共享数据的读写,不保护数据准备和数据消费。
- 优化前:
synchronized(lock) { // 读取共享数据 Data d = sharedList.get(index); // 大量计算(不需要保护) String result = complexCompute(d); // 写日志(不需要保护) log.write(result); // 写回共享数据 sharedResultMap.put(key, result); } - 优化后:
Data d = null; synchronized(lock) { d = sharedList.get(index); // 只保护读取 } String result = complexCompute(d); // 无锁 log.write(result); // 无锁 synchronized(lock) { sharedResultMap.put(key, result); // 只保护写入 }
降低锁粒度:分拆锁(分段锁/哈希分片)
如果只有一个大锁(全局锁)保护整个大数据结构,那么修改不同部分也会相互阻塞,可以将数据结构拆成多个小锁。
- 场景:一个全局
Map被频繁读写。 - 优化:Sharding(分片),根据 Key 的哈希值决定使用哪个子锁。
- Java 中的
ConcurrentHashMap早期版本使用“分段锁”(Segment),内部有 16 个锁,不同段互不影响。 - 你也可以实现自己的分段锁:创建 N 个锁对象,根据 hash(key) % N 选择锁。
- Java 中的
// 伪代码:分段锁
Lock[] locks = new Lock[16];
Map<K,V>[] shards = new Map[16];
void put(K key, V value) {
int index = key.hashCode() & 15; // 取低4位
Lock lock = locks[index];
lock.lock();
try {
shards[index].put(key, value);
} finally {
lock.unlock();
}
}
使用读写锁(ReadWriteLock):区分读与写
如果操作多是“读”操作,少数是“写”操作,使用普通的互斥锁会阻塞所有读操作。
- 优化:使用
ReadWriteLock(如 Java 的ReentrantReadWriteLock、C# 的ReaderWriterLockSlim)。- 读锁:多个线程可以同时获得读锁(不互斥)。
- 写锁:写锁必须独占(与所有读锁、写锁互斥)。
- 注意:如果读写比例极高(99% 读,1% 写),效果极佳,但如果写操作也很频繁,读锁和写锁的争抢反而可能更慢。
使用原子操作(无锁编程)
对于简单的“读-改-写”操作(如计数、累加、状态切换),使用硬件级别的原子指令(CAS),完全不需要锁。
- 使用场景:计数器、标志位、单个变量的更新。
- 工具:
AtomicInteger、AtomicLong、AtomicReference、std::atomic。 - 优点:非常轻量,没有线程切换开销。
// 使用 synchronized
synchronized(lock) { counter++; }
// 使用 AtomicInteger
atomicCounter.incrementAndGet(); // 无锁!
使用乐观锁(适合冲突少的场景)
如果不确定是否需要锁,可以先“乐观”地假设没人跟我抢,操作完再检查是否有冲突。
- 场景:数据库、版本号机制。
- 实现:
- 读取共享数据及其版本号。
- 进行计算,修改数据。
- 写入时:
UPDATE table SET value = new_val, version = version + 1 WHERE version = old_version。 - 如果影响行数为 0,说明中间被修改过(冲突),需要重试或报错。
- 优点:如果冲突概率很低,性能极佳。
使用无锁数据结构
利用 CAS 和内存屏障,直接使用现成的无锁并发容器。
- 常见实现:
- Java:
ConcurrentLinkedQueue、ConcurrentHashMap、LongAdder。 - C++:
boost::lockfree::queue、moodycamel::ConcurrentQueue。 - Go:
sync.Map。
- Java:
- 注意:无锁数据结构在特定场景下极快,但调试复杂,且设计难度很高(ABA问题、内存回收问题),建议优先使用成熟的第三方库,而不是自己手写。
优化决策树
遇到“频繁加解锁”时,可以按以下顺序排查和优化:
- 先问:这个锁真的需要频繁加吗?
- 能否 合并操作(本地缓存,批量提交)?→ 第1条
- 再看临界区:锁里有没有多余操作?
- 把计算、I/O、日志移出临界区。→ 第2条
- 再分析冲突模式:
- 读多写少?→ 使用
ReadWriteLock。→ 第4条 - 单项操作(累加/比较替换)?→ 使用
Atomic*。→ 第5条 - 多线程操作大集合的不同元素?→ 使用分段锁或
ConcurrentHashMap。→ 第3条
- 读多写少?→ 使用
- 最后考虑:是否可以用无锁数据结构?→ 第7条
一个容易忽略的陷阱:不要“过度优化”,如果锁竞争并不激烈(比如锁的持有时间极短,且线程数不多),使用 Atomic 或复杂的分段锁反而可能性能更差(因为 CAS 在高冲突下会自旋浪费 CPU,而分段锁增加了额外寻址开销),先用 profiler(性能分析工具)定位瓶颈,再对症下药。
标签: 批处理