副标题:覆盖字节、阿里、腾讯高频考点,从概念、原理、死锁到性能对比,逐条讲透
互斥锁是后端开发、操作系统、嵌入式岗位的必考题。面试官不会只问 "互斥锁是什么",而是会沿着 "概念→使用→底层原理→死锁→性能对比→工程实践" 的链条层层深入。下面整理了最常考的 15 道题,按难度分层给出完整答案。
一、基础概念篇
Q1:什么是竞态条件?什么是临界区?
答:
- 竞态条件(Race Condition):多个执行流同时访问共享资源,且最终结果依赖于线程调度的相对时序,导致结果不确定、出现数据错误的现象。
- 临界区(Critical Section):程序中访问共享资源的那段代码,这段代码必须保证 "同一时间只能有一个线程执行",否则就会触发竞态条件。
简单说:竞态条件是问题现象,临界区是需要保护的代码范围,互斥锁是解决手段。
Q2:互斥锁和自旋锁有什么区别?各自适用场景?
答:
表格
| 维度 | 互斥锁(Mutex) | 自旋锁(Spinlock) |
|---|---|---|
| 抢不到锁时 | 线程进入休眠,让出 CPU | 线程循环空转,一直占着 CPU |
| 上下文切换 | 有,休眠和唤醒各一次 | 无,不发生调度 |
| 开销来源 | 内核态切换开销 | CPU 空转浪费 |
| 适用场景 | 临界区较长、等待时间不确定 | 临界区极短、多核环境 |
| 单核场景 | 正常工作 | 纯浪费 CPU,且可能死锁 |
总结:锁持有时间短用自旋锁,持有时间长用互斥锁。Linux 内核中自旋锁广泛用于中断上下文等不能休眠的场景。
Q3:互斥锁和二元信号量有什么区别?
答:
- 二元信号量(初值为 1 的信号量)也能实现互斥,但它和互斥锁有本质区别:
- 所有权语义:互斥锁有 "持有者" 概念,谁加锁谁解锁;信号量没有所有权,任何线程都可以释放。
- 功能侧重:互斥锁专门用于互斥;信号量既可以做互斥,也可以做同步(线程间等待通知)。
- 安全性:互斥锁支持优先级继承等机制防止优先级反转;信号量一般不支持。
- 递归行为:互斥锁可以设置为递归类型;信号量重复 P 操作只会把自己卡死。
简单记:互斥锁是 "我占着别人不能用",信号量是 "剩几个名额,用完为止"。
Q4:什么是死锁?死锁产生的四个必要条件是什么?
答: 死锁是指两个或多个线程互相持有对方需要的资源,又都在等待对方释放,导致所有线程永久阻塞、无法继续执行的状态。
四个必要条件:
- 互斥条件:资源是独占的,同一时间只能一个线程持有。
- 持有并等待:线程已经持有至少一个资源,又去申请新的被他人持有的资源。
- 不可剥夺:资源只能由持有者主动释放,不能被其他线程强行抢走。
- 循环等待:线程之间形成环形的资源等待链。
打破死锁的思路 就是破坏其中任意一个条件。工程上最常用的是按固定顺序加锁 (破坏循环等待)和一次性申请全部资源(破坏持有并等待)。
Q5:如何排查和定位死锁?
答:
- 静态分析:代码走查,检查加锁顺序是否一致,是否存在嵌套加锁。
- 运行时工具 :
- Linux 下用
gdbattach 到进程,info threads查看所有线程,bt查看调用栈,检查是否都阻塞在 lock 调用上。 - 使用
pstack/jstack(Java)打印线程栈。 - Valgrind 的 Helgrind 工具可以检测潜在死锁。
- Linux 下用
- 工程手段 :给锁加超时时间(
pthread_mutex_timedlock),超时后打日志报警;在线上环境埋点监控锁持有时间。
二、底层原理篇
Q6:Linux 下 pthread_mutex 底层是怎么实现的?
答: Linux NPTL 线程库的互斥锁采用 用户态原子操作 + 内核态 futex 的两级结构,也就是 "快速路径 + 慢速路径":
- 快速路径(无竞争时):完全在用户态完成。通过 CPU 原子指令(如 CAS、xchg)修改锁状态,抢锁成功直接返回,不进入内核,开销极低。
- 慢速路径(有竞争时) :抢不到锁的线程调用
futex_wait系统调用陷入内核,内核将对应的 LWP 挂起,加入 futex 等待队列,让出 CPU。 - 解锁时 :先在用户态释放锁状态,如果发现有线程在等待,就调用
futex_wake唤醒等待队列中的一个线程。
核心设计思想:能在用户态解决的绝不进内核,真正需要等待时才陷入内核休眠,兼顾了无竞争的高性能和有竞争的低 CPU 消耗。
Q7:什么是 futex?为什么需要它?
答: futex(Fast Userspace Mutex,快速用户态互斥量)是 Linux 内核提供的一种同步机制。它本质上是一个用户态的整型变量 + 内核里对应的等待队列。
为什么需要它:
- 纯用户态自旋锁:不浪费内核资源,但空转浪费 CPU。
- 纯内核态锁:每次加锁解锁都要系统调用,无竞争时开销太大。
futex 取二者之长:
- 无竞争:直接在用户态读写变量,完全不进内核。
- 有竞争 :通过
futex_wait进入内核休眠,futex_wake唤醒。
futex 是 Linux 上互斥锁、条件变量、读写锁、信号量等几乎所有高级同步原语的共同基石。
Q8:为什么 i++ 不是原子操作?原子操作是怎么实现的?
答: i++ 在 CPU 指令层面被拆成 "读内存→寄存器计算→写回内存" 三步,中间可以被线程调度打断,因此不是原子的。
原子操作的实现依赖两方面:
- CPU 硬件保证 :处理器提供专用的原子指令(如 x86 的
lock指令前缀、ARM 的ldrex/strex独占指令),保证单条指令的 "读 - 改 - 写" 不可分割。 - 内存屏障:原子操作通常隐含内存屏障效果,保证指令重排不会越过原子操作边界,保证多核心间的可见性。
常见的原子操作原语:Test-and-Set、Fetch-and-Add、Compare-and-Swap(CAS)。
Q9:条件变量为什么必须和互斥锁一起使用?
答: 两个核心原因:
-
保护条件本身的线程安全。条件本身是共享状态(比如队列长度),检查和修改条件都必须在互斥锁保护下进行,否则条件本身会产生竞态。
-
防止 "丢失唤醒" 。
wait操作内部要做 "释放锁 + 进入等待队列" 两步。如果这两步不是原子的,就可能出现:刚释放完锁、还没睡下去,对方已经修改完条件并发送了 signal,等你睡下去就再也等不到这次唤醒了。和互斥锁绑定后,wait 可以保证 "释放锁 + 进入等待" 是原子操作,彻底避免丢失唤醒。
Q10:条件变量的 wait 为什么要用 while 而不是 if?
答: 为了应对虚假唤醒(Spurious Wakeup)。
即使没有线程调用 signal/broadcast,等待中的线程也可能被内核意外唤醒(比如信号中断、调度器优化、futex 实现细节)。如果用 if 判断,被虚假唤醒后就会直接往下执行,此时条件其实并不满足,会导致逻辑错误。
使用 while 循环后,线程被唤醒后会重新检查条件:条件真满足才继续,不满足就继续回去等待。这是 POSIX 标准推荐的标准写法。
三、性能与工程篇
Q11:什么是锁的粒度?怎么选择锁的粒度?
答: 锁的粒度指的是一把锁保护的资源范围大小。
- 粗粒度锁:一把锁保护大量资源。优点是加锁次数少、不易死锁;缺点是并发度低,容易变成串行。
- 细粒度锁:多把锁分别保护不同资源。优点是并发度高,不同资源的操作互不影响;缺点是加锁频繁、开销大,且容易出现嵌套加锁导致死锁。
选择原则:
- 优先只保护真正的共享数据,无关代码移出临界区。
- 锁竞争不激烈时,倾向粗粒度,简单不易错。
- 锁成为性能瓶颈时,再逐步拆分为细粒度,比如分桶锁、分段锁。
Q12:什么是优先级反转?怎么解决?
答: 优先级反转是指:高优先级线程被低优先级线程持有的锁阻塞,而中间优先级的线程又抢占了低优先级线程的 CPU,导致高优先级线程迟迟无法推进,相当于优先级被 "反转" 了。
两种经典解决方案:
- 优先级继承:低优先级线程持有高优先级线程等待的锁时,临时把自己的优先级提升到等待者的水平,防止被中间优先级线程抢占。解锁后恢复原优先级。
- 优先级天花板:每把锁预设一个 "天花板优先级"(所有可能持有这把锁的线程中的最高优先级),线程拿到锁就自动升到天花板优先级。
Linux 的 PTHREAD_PRIO_INHERIT 互斥锁属性就是开启优先级继承。
Q13:递归锁有什么用?有什么坑?
答: 递归锁(Recursive Mutex)允许同一个线程多次对同一把锁加锁,内部维护引用计数,加锁几次就要解锁几次。
适用场景:函数嵌套调用时,内层函数和外层函数都需要对同一个资源加锁。用递归锁可以避免自己把自己锁死。
潜在问题:
- 增加额外的计数开销。
- 容易掩盖设计问题:需要递归锁往往意味着模块边界不清晰、职责划分混乱。
- 代码重构时容易出问题:把内部加锁拆出去时容易遗漏配对的解锁。
一般建议:优先通过代码设计避免递归加锁,而非依赖递归锁。
Q14:读写锁和普通互斥锁有什么区别?适用场景?
答: 读写锁(rwlock)把访问者分为读者和写者:
- 读者之间共享锁,可以并发读,不需要互斥。
- 写者必须独占锁,写操作和任何操作都互斥。
适用场景:读多写少的场景,比如配置信息、缓存数据。能显著提升并发度。
潜在问题:
- 可能出现 "写者饥饿":读者源源不断,写者一直拿不到锁。
- 开销比普通互斥锁略大。读写比例差不多时,性能反而不如普通互斥锁。
Q15:无锁编程(Lock-Free)和互斥锁相比有什么优劣?
答: 无锁编程基于原子操作(主要是 CAS)实现数据结构的并发安全,全程不使用锁。
优点:
- 不存在死锁问题。
- 线程不会因为抢不到锁而阻塞,延迟更稳定。
- 临界区极短的时候性能可能更好,没有内核态切换开销。
缺点:
- 实现难度极高,容易出隐蔽的并发 bug,对内存模型、指令重排理解要求很深。
- 复杂数据结构(链表、树等)的无锁实现极其复杂,ABA 等问题难以处理。
- 高竞争下可能反复 CAS 失败,出现活锁,性能反而下降。
工程原则:优先用互斥锁,简单可靠;只有在锁明确成为性能瓶颈、且经过 profiling 验证后,才考虑无锁方案。
结语
互斥锁看似简单,实则串联起了 CPU 原子指令、内核调度、futex 机制、并发安全设计等一整条知识链。面试考察的从来不是 "会不会用 lock/unlock",而是你对同步机制本质的理解深度,以及在工程中权衡利弊、规避坑点的能力。
谢谢