频繁加解锁如何优化合并?

访客 自然语言处理 1

本文目录导读:

  1. 合并操作:批量处理(最常见、最有效)
  2. 缩小临界区:只保护真正需要保护的部分
  3. 降低锁粒度:分拆锁(分段锁/哈希分片)
  4. 使用读写锁(ReadWriteLock):区分读与写
  5. 使用原子操作(无锁编程)
  6. 使用乐观锁(适合冲突少的场景)
  7. 使用无锁数据结构
  8. 优化决策树

“频繁加解锁”通常指的是在多线程或并发编程中,由于锁的粒度太细、加锁次数太多,导致大量的上下文切换和锁竞争,从而严重拖累性能。

要优化“频繁加解锁”,核心思路是:减少加锁次数、缩短持有锁的时间、降低锁的粒度(或使用无锁)。

以下是具体的优化策略,从简单到复杂,从“少锁”到“无锁”:

合并操作:批量处理(最常见、最有效)

如果每次操作都要加锁,尝试把多次操作合并成一次加锁的大操作。

  • 场景:比如有个计数器,每次增加 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 选择锁。
// 伪代码:分段锁
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),完全不需要锁。

  • 使用场景:计数器、标志位、单个变量的更新。
  • 工具AtomicIntegerAtomicLongAtomicReferencestd::atomic
  • 优点:非常轻量,没有线程切换开销。
// 使用 synchronized
synchronized(lock) { counter++; }
// 使用 AtomicInteger
atomicCounter.incrementAndGet(); // 无锁!

使用乐观锁(适合冲突少的场景)

如果不确定是否需要锁,可以先“乐观”地假设没人跟我抢,操作完再检查是否有冲突。

  • 场景:数据库、版本号机制。
  • 实现
    1. 读取共享数据及其版本号。
    2. 进行计算,修改数据。
    3. 写入时UPDATE table SET value = new_val, version = version + 1 WHERE version = old_version
    4. 如果影响行数为 0,说明中间被修改过(冲突),需要重试或报错。
  • 优点:如果冲突概率很低,性能极佳。

使用无锁数据结构

利用 CAS 和内存屏障,直接使用现成的无锁并发容器。

  • 常见实现
    • Java: ConcurrentLinkedQueueConcurrentHashMapLongAdder
    • C++: boost::lockfree::queuemoodycamel::ConcurrentQueue
    • Go: sync.Map
  • 注意:无锁数据结构在特定场景下极快,但调试复杂,且设计难度很高(ABA问题、内存回收问题),建议优先使用成熟的第三方库,而不是自己手写。

优化决策树

遇到“频繁加解锁”时,可以按以下顺序排查和优化:

  1. 先问:这个锁真的需要频繁加吗?
    • 能否 合并操作(本地缓存,批量提交)?→ 第1条
  2. 再看临界区:锁里有没有多余操作?
    • 计算、I/O、日志移出临界区。→ 第2条
  3. 再分析冲突模式
    • 读多写少?→ 使用 ReadWriteLock。→ 第4条
    • 单项操作(累加/比较替换)?→ 使用 Atomic*。→ 第5条
    • 多线程操作大集合的不同元素?→ 使用分段锁ConcurrentHashMap。→ 第3条
  4. 最后考虑:是否可以用无锁数据结构?→ 第7条

一个容易忽略的陷阱:不要“过度优化”,如果锁竞争并不激烈(比如锁的持有时间极短,且线程数不多),使用 Atomic 或复杂的分段锁反而可能性能更差(因为 CAS 在高冲突下会自旋浪费 CPU,而分段锁增加了额外寻址开销),先用 profiler(性能分析工具)定位瓶颈,再对症下药。

标签: 批处理

抱歉,评论功能暂时关闭!