尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

多线程锁策略全解析:从悲观锁到自旋锁,避开并发陷阱

多线程锁策略全解析:从悲观锁到自旋锁,避开并发陷阱 去年冬天做压测我把一个库存扣减接口并发数从 200 提到 500结果库存扣成了负数服务端日志里出现大量 BLOCKED 线程CPU 使用率却低得离谱。排查到最后根因不是数据库慢而是锁策略选错了所有线程挤在同一把全局锁上读写互斥、频繁切换事务也在等待中大量超时。从那次之后我把多线程编程里最常见的锁策略从头到尾梳理了一遍发现很多人在面试时能把 synchronized 和 volatile 背得滚瓜烂熟真到项目里选锁却全靠猜。这篇文章就把我对锁策略的理解、落地经验和踩坑记录都写下来覆盖 Java、C、Python 和 Linux/嵌入式场景也把多线程面试里的高频考点一起带出来适合正在做并发编程、被线程安全问题折磨过、或者准备多线程面试的开发者。1. 锁到底在管什么并发三大问题的来源1.1 一个简单的 count 为什么会翻车先看一段再常见不过的代码public class Counter { private int count 0; public void increment() { count; } }如果你开 100 个线程每个线程循环执行 10000 次 increment()最后 count 的值大概率不是 1000000而是几千到九十几万之间。原因很简单count 在 CPU 层面并不是一步操作它至少分成三步——把 count 的值从内存读到寄存器、寄存器加 1、把新值写回内存。两个线程可能同时读到同一个旧值各自加 1 后写回结果相当于只加了一次。这就是最典型的竞态条件。它不只在教科书里出现真实业务里我见过太多类似事故余额少算、库存超卖、统计数字对不上。很多新手觉得“加个 synchronized 就好了”但 synchronized 只是锁策略里最基础的一种远远不够。1.2 原子性、可见性与有序性多线程并发往下拆绕不开三个问题原子性一段操作要么全部执行完要么完全不执行不能被其他线程打断。可见性一个线程修改了共享变量另一个线程能不能立刻看到最新值。有序性编译器或 CPU 可能为了优化对指令重排重排后的顺序在某些线程下会导致逻辑错误。锁能同时解决这三件事。互斥锁保证同一时刻只有一个线程进入临界区这是原子性锁的释放和获取会触发内存屏障把线程本地缓存刷到主内存也能让读线程看到写线程的结果这是可见性而内存屏障本身会限制重排序保证有序性。但锁是有代价的。每次加锁、解锁都可能涉及用户态与内核态的切换、线程的阻塞与唤醒、CPU 缓存失效。你可以把锁想象成排队过安检虽然保证了每个人都能安全通过但排队本身消耗了时间。全局锁更是如此——所有线程挤在一个入口哪怕有 16 个核也只有一个线程能干活。1.3 选锁前先问自己四个问题这么多锁策略怎么选我一般先问四个问题是读多还是写多读写比例直接决定要不要上读写锁或乐观锁。临界区是长是短临界区短自旋锁可能比阻塞锁更好临界区长阻塞锁更省 CPU。线程冲突的概率高不高冲突概率低乐观锁收益大冲突概率高悲观锁可能更稳。能不能容忍偶尔的重试或失败乐观锁需要重试机制如果业务不允许失败要谨慎。后面所有锁策略的取舍本质上都是在围绕这四个问题做权衡。2. 悲观锁与乐观锁两种世界观的取舍2.1 悲观锁默认有人跟你抢悲观锁的思路很直白我修改数据的时候默认一定有人也在改所以先把锁拿到手其他人必须等我用完。Java 里的 synchronized、ReentrantLock数据库里的SELECT ... FOR UPDATE都属于悲观锁。public synchronized void safeIncrement() { count; }或者显式加锁private final Lock lock new ReentrantLock(); public void safeIncrement() { lock.lock(); try { count; } finally { lock.unlock(); } }悲观锁的好处是控制简单线程阻塞等待不会反复消耗 CPU写冲突严重时锁队列可以保证公平调度不会出现大量无效重试。坏处也很明显加锁、阻塞、唤醒都有开销尤其是临界区长、持锁时间久时吞吐量会断崖式下降。我在实际项目里一般这样判断如果写操作非常频繁并且一个共享数据被多个服务同时修改的概率很高悲观锁往往更可靠。比如交易系统里的账户余额扣减宁可多等一会也不能让脏数据落库。2.2 乐观锁默认没人跟你抢乐观锁正好相反。它认为冲突是偶发事件所以不加锁而是靠版本号或者 CASCompare And Swap来检测并解决冲突。CAS 的核心逻辑是比较当前内存值和期望值如果相等就更新为新值否则说明被别人改过了操作失败由调用方决定是否重试。Java 里的 AtomicInteger、AtomicLong 底层就是 CAS。AtomicInteger count new AtomicInteger(0); count.incrementAndGet();数据库里最常见的乐观锁是版本号控制UPDATE stock SET count count - 1, version version 1 WHERE id #{id} AND version #{version};如果影响行数为 0说明版本号已变化需要重新查询再试一次。这种方式没有锁等待吞吐量高很适合读多写少、冲突概率低的场景。2.3 两种锁的代价模型什么时候切换很多人喜欢背口诀“读多写少用乐观锁写多用悲观锁。”这句话方向对但不够精确。真正决定选择的是冲突概率和重试代价。假设临界区执行耗时是 t冲突概率是 p。乐观锁每次失败都要重试平均重试次数约等于 p / (1 - p)。当 p 很小时重试开销可忽略当 p 超过 50% 后重试次数急剧上升大量线程都在空转 CASCPU 被打满吞吐量却不涨。这时候悲观锁虽然会让线程阻塞反而能把 CPU 让给真正干活的线程。一个更具体的判断方式先压测拿到冲突概率如果低于 10%优先选乐观锁超过 30%老老实实考虑悲观锁位于中间区间就结合临界区耗时一起看。临界区非常短时乐观锁仍然可能占优因为即使冲突率高CAS 的重试代价也比线程挂起唤醒小得多。3. 读写锁与锁粒度别让读线程为写线程买单3.1 读写锁的基本规则与锁降级陷阱很多业务场景是读多写少比如配置表、商品详情。如果用一把互斥锁保护所有读写读线程之间也会互相阻塞白白浪费并发能力。读写锁的目的就是把读和写分开读读之间可以共享读写互斥写写互斥。Java 里的 ReentrantReadWriteLock 提供 readLock() 和 writeLock() 两把锁。基础用法不复杂但有一个特别容易踩的坑锁升级会导致死锁。ReadWriteLock rwLock new ReentrantReadWriteLock(); Lock readLock rwLock.readLock(); Lock writeLock rwLock.writeLock(); readLock.lock(); try { // 如果在这里再尝试获取 writeLock可能会死锁 } finally { readLock.unlock(); }为什么升级会死锁因为读锁可能同时被多个线程持有当一个线程在持有读锁的情况下去等写锁而其他读锁持有者也在排队就可能形成循环等待。反过来锁降级是安全的持有写锁时再拿读锁然后释放写锁这样能保证写操作完成后自己还能继续以读模式访问数据同时阻止其他写线程插队。3.2 分段锁从 ConcurrentHashMap 看锁粒度拆解锁粒度越细并发能力越强但管理复杂度也越高。一个经典的折中方案是分段锁把要保护的数据分成若干段每把锁只管其中一段。Java 7 的 ConcurrentHashMap 就是分段锁的典型实现内部用一个 Segment 数组每个 Segment 是一把锁数据根据哈希值落到某个 Segment 上写操作只需要锁住对应的 Segment其他 Segment 的读写完全不受影响。到了 Java 8实现改成了 CAS synchronized 锁单个桶锁粒度进一步缩小到链表或红黑树的头节点。这背后的思路对整个并发编程都有启发当你发现一把全局锁成为瓶颈先别急着上分布式锁或者无锁编程想一想能不能把数据按 key 分区让不同的线程操作不同的分区从根上消除竞争。比如用户维度的数据按 userId 取模分片订单维度按 orderId 分片每个分片内部再考虑细粒度锁。3.3 锁粒度控制在业务里的实际应用业务代码里我见过常见的反面教材一个方法里为了方便把整个 List 或 Map 用 synchronized 锁住然后循环处理所有元素。这样无论循环多少次同时只有一个线程在跑。正确的做法是尽量缩小锁的范围。比如批量更新一批订单状态可以按订单号哈希拆成多个子任务不同子任务之间天然隔离再让多个线程并发执行每个线程只锁自己负责的那部分数据。还有“多线程执行 SQL等 SQL 执行完再执行下一条”这种需求很多人会用一把全局锁把 SQL 执行串行化其实应该用任务编排工具让每个 SQL 任务并行跑在所有任务结果就绪后再汇总锁只保护共享的数据库连接或结果集。记住一个原则能通过数据分区解决竞争就不要去优化锁本身。锁少一把是一把。4. 公平锁、非公平锁、可重入锁与自旋锁细节里藏着大坑4.1 非公平锁凭什么性能更好ReentrantLock 默认是非公平锁也可以传入 true 创建公平锁。两者的区别在于公平锁严格按照线程申请锁的顺序分配非公平锁允许新线程在锁被释放的那一刻“插队”。很多初学者不理解为什么非公平锁性能更好。原因在于公平锁的公平意味着当锁被释放时系统必须唤醒等待队列里最早的那个线程这个唤醒过程涉及上下文切换往往需要几十到上百微秒。而在这段时间里新来的线程如果直接尝试抢锁很可能抢到后立刻执行完再释放整个过程反而更快。非公平锁的代价是可能造成线程饥饿极端情况下后到的线程不断插队先到的线程一直等不到锁。Java 的 synchronized 本质就是非公平的ReentrantLock 默认也是非公平的。只有对等待时间有明确要求或者不想让任何线程等太久时才建议用公平锁。具体到实践如果某个线程需要在固定时间内完成任务比如定时任务或实时性要求较高的场景可以考虑公平锁如果追求吞吐量非公平锁是更稳妥的选择。4.2 可重入锁为什么递归不会把自己锁死可重入锁的意思是同一个线程可以重复获取同一把锁多次每次进入计数器加 1每次退出减 1直到计数器归零才真正释放锁。synchronized 和 ReentrantLock 都是可重入的。如果不支持可重入下面这段代码会直接死锁public synchronized void outer() { // 做一些前置逻辑 inner(); } public synchronized void inner() { // 真正干活 }因为 outer() 获取了锁进入 inner() 时又需要同一把锁。可重入锁通过线程身份和计数器让同一个线程可以顺利通过避免这种无意识的嵌套死锁。这个特性在真实项目里很重要。比如一个方法里调了另一个加锁的方法或者加了缓存层、代理层方法的调用链变深如果锁不可重入系统会莫名其妙卡死。4.3 自旋锁短临界区的双刃剑自旋锁的思路是不释放 CPU在一个循环里反复尝试获取锁直到成功。CAS 本身就是一种自旋机制。用 Java 模拟一个简单的自旋锁AtomicBoolean locked new AtomicBoolean(false); public void lock() { while (!locked.compareAndSet(false, true)) { // 自旋等待 } }自旋锁的优势是省掉了线程阻塞和唤醒的开销适合临界区极短、锁竞争不激烈的情况。如果临界区执行只需要几百纳秒每次阻塞唤醒却要几微秒自旋显然划算。但自旋锁的坑也在这如果持锁线程被操作系统调度出去自旋线程就会白白转圈消耗 CPU。所以锁竞争激烈、临界区较长时不要用自旋。JVM 里的 synchronized 已经做了优化会尝试轻量级锁和自适应自旋如果一直不成功再膨胀为重量级锁这也是为什么现代 Java 里很多场景不需要你手动去写自旋锁。另外JVM 还会做锁粗化和锁消除。锁消除发生在逃逸分析确认对象不会被其他线程访问时锁会被直接去掉锁粗化则是把相邻多次加锁解锁合并成一次减少重复获取锁的开销。理解这些底层优化有助于你明白很多“锁策略选择”其实是 JVM 帮你在兜底但你的代码结构不能依赖它。5. 多语言场景下的锁策略实战Java、C、Python 与 Linux/嵌入式5.1 JavaCompletableFuture 等待任务结果和 for 循环内多线程Java 多线程应用里除了锁本身还有一个高频需求在 for 循环里提交一批任务等所有任务都执行完再继续往下走。很多新人会这样写for (Item item : items) { new Thread(() - process(item)).start(); }然后立刻去读取结果发现数据还没处理完。正确做法是使用线程池加上 CompletableFuture 编排任务ExecutorService executor Executors.newFixedThreadPool(8); ListCompletableFutureVoid futures new ArrayList(); for (Item item : items) { CompletableFutureVoid future CompletableFuture.runAsync(() - process(item), executor); futures.add(future); } CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();allOf 返回一个在所有任务完成后才完成的 CompletableFuturejoin() 会阻塞等待最终结果。这个过程不需要你手动加锁因为每个任务内部只处理自己的 item但如果多个任务需要往同一个 List 或 Map 里写结果就必须加锁或者用 ConcurrentHashMap 这类线程安全容器。还有一个数据库场景多个线程同时去执行 SQL等所有 SQL 执行完再继续。如果连接池允许每条 SQL 独立拿连接依然可以用 allOf 编排真正要加锁保护的是连接池里复用的单个连接可以通过让每个线程拿独立连接来避免锁竞争而不是把 SQL 执行串行化。5.2 C11条件变量与 mutex 的组合才是完整的锁策略C11 多线程编程里最经典的组合是 std::mutex 加 std::condition_variable。互斥锁负责保护共享数据条件变量负责线程间唤醒。一个标准的生产者消费者模型#include condition_variable #include mutex #include queue std::mutex mtx; std::condition_variable cv; std::queueint dataQueue; // 生产者 void producer() { { std::lock_guardstd::mutex lock(mtx); dataQueue.push(42); } cv.notify_one(); } // 消费者 void consumer() { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [] { return !dataQueue.empty(); }); int value dataQueue.front(); dataQueue.pop(); }注意这里必须用 std::unique_lock而不能用 lock_guard。原因是 wait() 在等待期间要释放锁让生产者能进入临界区写入数据被唤醒后又会重新获取锁。std::lock_guard 不支持这种操作。很多人第一次写这段代码时在这里编译不过就是这个原理没理解透。另外wait 条件建议写成 lambda 形式并且内部要配合 while 循环。这是因为存在“伪唤醒”的可能系统通知可能不止一次如果只判断一次就往下走很可能数据还没就绪。用条件变量时谁负责唤醒、什么时候唤醒、唤醒后要做什么都是一套显式的锁策略比 Java 的 synchronized 更容易出错但控制力也更强。5.3 PythonGIL 之下还需要自己加锁吗Python 的 GIL全局解释器锁保证同一时刻只允许一个线程执行 Python 字节码所以很多人说“Python 多线程是假的”。但这句话只说对了一半。纯 CPU 密集型任务比如无限循环做加减乘除GIL 会让多个线程轮流获得执行权线程切换反而降低效率这时候应该用 multiprocessing 或者直接写成单线程。但 IO 密集型任务比如网络请求、文件读写、数据库操作线程在等待 IO 时会把 GIL 释放掉多线程依然能显著提升并发度。GIL 并不等于你可以不用锁。举一个例子import threading counter 0 lock threading.Lock() def increment(): global counter for _ in range(100000): with lock: counter 1如果不加 lock即使有 GILcounter 1 也可能在两个线程切换之间产生丢失更新。GIL 保证的是字节码级原子性不是代码逻辑级原子性。所以 Python 里写多线程Lock、RLock 依然是日常必备。RLock 是可重入锁同一个线程可以多次 acquire对应 Java 里的 ReentrantLock。5.4 Linux / Qt / STM32从 pthread 到 RTOS 的锁差异Linux 多线程编程最常见的是 pthread 接口pthread_mutex_init、pthread_mutex_lock、pthread_mutex_unlock。组合条件变量时一样遵循先加锁、再 wait 释放锁、被唤醒后重新加锁的流程。C std::thread 底层也是封装了 pthread但在锁策略上并没有差异。Qt 多线程有自己的体系QMutex、QReadWriteLock、QMutexLockerRAII 类以及 QtConcurrent::run 做异步任务。还有一个容易被忽视的点信号槽跨线程连接时排队连接本身就带有事件队列的互斥控制很多场景不需要你额外加锁直接通过信号槽传递数据比多个线程共享可变状态安全得多。理解“消息传递替代共享内存”这个思路在 Qt 里会省很多锁。STM32 这类嵌入式 MCU 上的“多线程”往往是 RTOS 任务比如 FreeRTOS 的 task。这里的关键区别是没有虚拟内存和复杂缓存锁策略更简单但对实时性要求更高。常用的不是通用互斥锁而是互斥量mutex和信号量semaphore。互斥量有优先级继承机制解决优先级反转二值信号量则纯粹做同步或互斥不解决优先级问题。在嵌入式里要遵守一个原则临界区尽量短能关中断保护的就不要用信号量否则会影响中断响应时间。还有个很多人容易混淆的地方Linux 多线程里的条件变量在嵌入式 RTOS 里并不总是有对应物很多时候靠队列和信号量实现线程唤醒这也是 RTOS 开发者在“多线程控制”上要重新建立认知的原因。6. 死锁、锁竞争与面试追问锁策略的“暗坑”全解析6.1 死锁的四个必要条件与典型代码死锁是锁策略里最容易出问题、也最常被面试官问起的话题。它需要同时满足四个条件互斥资源同一时刻只能被一个线程持有。持有并等待线程持有一个锁又在等待另一个锁。不可剥夺已经持有的锁不能被其他线程强行抢走。循环等待多个线程形成环路彼此等待对方持有的锁。典型代码长这样Object lockA new Object(); Object lockB new Object(); // 线程1 synchronized (lockA) { Thread.sleep(100); synchronized (lockB) { } } // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { } }避免死锁最常用的手段是打破循环等待全局约定加锁顺序所有线程都先锁 lockA 再锁 lockB就不会形成环。另一个办法是使用 ReentrantLock 的 tryLock 带超时if (lockA.tryLock(2, TimeUnit.SECONDS)) { try { if (lockB.tryLock(2, TimeUnit.SECONDS)) { try { // 干活 } finally { lockB.unlock(); } } } finally { lockA.unlock(); } }如果尝试不到锁就释放已经持有的锁避免了无限等待。但这种“尝试获取多把锁”的写法本身有很强的复杂度能避免就尽量避免优先用数据分区分掉多锁场景。6.2 如何用工具定位锁引发的问题排查锁问题工具比人眼更靠谱。Java 环境里jstack 是首选jstack pid thread_dump.txt然后去 thread_dump.txt 里搜 “BLOCKED” 和 “WAITING”。一个线程如果长时间处于 BLOCKED 状态通常就是被另一个线程持有锁挡住了。再看 “Found one Java-level deadlock” 字样可以快速判断是否死锁。Linux 下排查 C 或 pthread 程序可以用 gdbgdb -p pid thread apply all bt这会打印所有线程的调用栈找到各自卡在哪个 mutex 或条件变量上。pstack 命令也是一个轻量选择。如果是数据库层面的锁等待可以查 information_schema.innodb_trx看哪些事务在等待哪些事务的锁。还有一个容易被忽略的点锁竞争导致的性能问题不一定表现为死锁更多时候是 CPU 忽高忽低请求超时。这时候要用火焰图采样出热点函数看看是不是大量线程都消耗在 lock/unlock 的竞争路径上。6.3 面试里锁策略高频问题怎么回答多线程面试题基本绕不开锁。我梳理几个最常被问的synchronized 和 ReentrantLock 有什么区别乐观锁和悲观锁怎么选CAS 有什么缺点什么是锁粗化、锁消除、锁膨胀什么是死锁如何避免公平锁和非公平锁的区别与取舍回答这类问题我推荐一个框架先一句话讲清原理再给一个自己写过的例子接着落到适用场景最后补一个坑或优化点。比如被问到 CAS 的缺点不能只说“会有 ABA 问题”还要说明 ABA 具体是什么、怎么解决用 AtomicStampedReference 或版本号、以及 CAS 在同一时刻只能保证一个共享变量的更新安全多个变量更新时要考虑锁或者其他机制。面试官真正想看的是你有没有踩过锁的坑而不只是背概念。准备的时候最好能结合一个真实项目遇到了什么并发问题当时怎么分析最后选了哪种锁策略为什么放弃另一种效果如何。这样的回答比任何标准答案都有说服力。7. 锁策略选择清单与我的实测经验7.1 不同场景下的锁策略速查表把上面所有内容压缩成一张表直接拿去做选型参考场景特征推荐锁策略核心原因读多写少冲突概率低乐观锁 / 读写锁减少无竞争时的加锁开销写多临界区极短悲观锁 / 自旋锁避免线程频繁挂起唤醒写多临界区长悲观锁 阻塞自旋会白白消耗 CPU对线程等待时间有严格要求公平锁避免线程饿死或不公平等待单一热点数据高并发更新分段锁 / CAS拆细粒度分散竞争批量任务并行等待全部完成CompletableFuture / CountDownLatch用任务编排替代全局锁C 生产者消费者模型mutex condition_variable条件变量负责阻塞与唤醒Python IO 密集型并发Lock / RLock 线程池线程等待 IO 时释放 GILLinux / 嵌入式 RTOS 控制策略互斥量 信号量兼顾互斥与同步注意优先级反转表格只能给一个大方向真实场景一定要结合临界区长度和冲突概率调优。7.2 两次真实优化里的锁策略收益我在实际项目里做过两次比较典型的锁策略改造数据可以给大家一个直观体感。第一次是库存扣减接口。最初所有商品共享一把全局 synchronized 锁压测 500 并发时吞吐量大概 220 TPS大量线程 BLOCKED。改造后把库存按商品 ID 分片存储使用 ConcurrentHashMap AtomicLong 做原子扣减只在库存不足需要回写流水时加细粒度锁。压测结果提升到了 900 TPS 左右翻了四倍多。这里的关键不是锁实现多高级而是锁粒度从全局变成了单个商品维度。第二次是批量 SQL 导入。原来的代码用一个全局锁保证“一次只执行一条 SQL”导致导入一批一万条数据要跑十几分钟。我用 CompletableFuture 把数据按业务键分片每个分片一个小线程池执行 SQL最后 allOf().join() 等所有分片完成再统计结果。同样一批数据耗时降到原来的 40% 左右。你会发现很多“锁策略”问题的本质都是“要不要共享这个资源能不能不共享”。7.3 最后一条经验先写对再优化锁写锁策略最忌讳一上来就追求极致的无锁编程、细粒度锁、读写锁各种花活。锁这东西用错了不只是性能问题更是正确性问题。我的做法是第一版先用最简单、最不容易出错的锁把业务写对比如 synchronized 或者单把 ReentrantLock压测确认瓶颈确实在锁上之后再去优化粒度或换策略。优化锁之前先问自己能不能通过数据分区减少共享能不能用不可变对象避免加锁能不能用消息传递替代共享状态这些手段往往比任何锁策略都有效。如果一定要用锁优先考虑按业务 key 加锁也就是 Striped Lock 的思路让不同 key 的线程各走各的临界区既保留并发度又不至于全局串行。我个人的体会是锁策略的学习曲线并不在于记住多少种锁而在于真正理解和测量自己的应用场景。多写压测脚本、多看线程转储、多复盘线上事故比背十篇锁原理文章都管用。下次再有人问我“多线程用哪把锁”我会先问他一句你的并发模型本身能不能少一点锁
返回列表