面试必看:排队自旋锁之MCS锁的实现原理与关键考点

电子说

1.4w人已加入

描述

 

 

在并发编程面试中,” 是绕不开的核心话题,而自旋锁作为轻量级锁的代表,其优化方案更是高频考点。其中,MCS (以发明者 John Mellor-Crummey 和 Michael Scott 命名)作为排队自旋锁的经典实现,完美解决了传统自旋锁 “CPU 资源浪费”“缓存风暴” 等痛点,成为面试官评估候选人并发底层能力的重要标尺。今天,我们就从面试视角拆解 MCS 锁的实现逻辑,帮你轻松应对相关提问。

 

 

一、先搞懂:为什么需要 MCS 锁?

 

在讲 MCS 锁之前,我们得先明确 传统自旋锁的问题”—— 这是面试中回答 “MCS 锁设计初衷” 的关键切入点。

 

 

传统自旋锁(如基于 CAS 的 Test-and-Set 锁)的核心问题的是 盲等:当多个线程竞争锁时,所有线程都会自旋等待同一个共享变量(如锁状态标记),哪怕锁已经被释放,也可能有大量线程无效自旋;更严重的是,多个线程频繁读取同一个变量会引发缓存一致性风暴(多个 CPU 核心缓存同步该变量,导致性能损耗)。

 

 

 MCS 锁的核心思想是 排队自旋:让竞争锁的线程按顺序排成一个单向链表,每个线程只自旋等待 前一个线程释放锁的信号,避免对同一个共享变量的集中竞争。这种设计既减少了无效自旋,又消除了缓存风暴,是高并发场景下的最优自旋锁方案之一。

 

 

二、MCS 锁的核心设计:数据结构 + 3 个关键操作

 

面试中,“MCS 锁的实现” 通常需要你讲清数据结构定义 **“加锁、解锁、自旋” 三个核心流程 **。我们以 Java 为例(C/C++ 实现逻辑类似,只是语法差异),一步步拆解。

 

 

1. 核心数据结构:线程节点(Node)与锁状态

 

MCS 锁的核心是 用链表记录等待线程,每个线程对应一个Node节点,节点中需包含两个关键信息:

 

 

isLocked:标记当前线程是否持有锁(或是否需要自旋);

 

 

next:指向链表中的下一个等待线程(形成排队顺序)。

 

 

同时,锁本身需要一个共享的尾指针tail,用于将新竞争锁的线程追加到链表尾部,这个尾指针通过 CAS 操作保证原子性。

 

 

Java 中的简化定义如下(面试时写出这个结构,基本就成功了一半):

 

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
// 1. 线程节点类:记录当前线程的锁状态和下一个等待线程class Node {  // 标记是否需要自旋:true=需要等待,false=可获取锁  volatile boolean isLocked = true;  // 指向链表中的下一个节点(下一个等待线程)  volatile Node next = null;}// 2. MCS锁类:核心是共享的尾指针tailpublic class MCSLock {  // 共享尾指针:指向链表最后一个节点,初始为null  private volatile Node tail = null;  // 每个线程独有的节点(避免线程安全问题,用ThreadLocal存储)  private ThreadLocal currentNode = ThreadLocal.withInitial(Node::new);}

这里有个面试高频细节:为什么用 ThreadLocal 存储当前线程的 Node

 

 

答:因为每个线程只能操作自己的 Node 节点(修改isLocked)和前一个线程的 Node 节点(设置next),用 ThreadLocal 可以避免多个线程竞争同一个 Node,同时保证每个线程对应唯一节点。

 

 

2. 加锁流程(lock ()):排队入队,确定等待对象

 

加锁的核心是将当前线程追加到链表尾部,并找到前一个线程,具体分 步(面试时建议结合步骤 代码 流程图讲解):

 

 

加锁流程流程图

 

cpu

步骤 1:获取当前线程的 Node 节点

 

通过ThreadLocal拿到当前线程独有的Node,确保线程安全。

 

 

步骤 2CAS 尝试将当前节点设为新的 tail

 

 CAS 操作(compareAndSet)将tail旧值” 更新为 当前节点

 

 

如果 CAS 成功:说明当前线程是第一个竞争锁的线程(链表为空),直接获取锁,无需自旋;

 

 

如果 CAS 失败:说明已有其他线程在排队,当前线程需要加入链表尾部。

 

 

步骤 3:找到前一个线程的 Nodeprev

 

CAS 失败后,旧的tail就是前一个线程的节点(prev,需要将prevnext指向当前节点(把当前线程接入链表)。

 

 

步骤 4:自旋等待前一个线程释放锁

 

当前线程自旋等待prev.isLocked变为false(前一个线程释放锁的信号),直到条件满足后,才能获取锁。

 

 

加锁的 Java 实现代码如下(面试时写出关键逻辑即可):

 

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
public void lock() {  // 步骤1:获取当前线程的Node节点  Node currNode = currentNode.get();   // 步骤2:CAS尝试将当前节点设为新tail(入队)  Node prevNode = CASUpdateTail(null, currNode) ? null : tail;   // 步骤3:如果有前一个线程(prevNode不为null),则将当前节点接入链表  if (prevNode != null) {      // 将前一个节点的next指向当前节点(当前线程排队到prev后面)      prevNode.next = currNode;      // 步骤4:自旋等待前一个线程释放锁(直到prev.isLocked为false)      while (currNode.isLocked) {          // 自旋:可加Thread.yield()减少CPU占用(面试可提优化点)          Thread.yield();      }  }  // 如果prevNode为null(CAS成功),直接获取锁,无需自旋}// 辅助方法:CAS更新tail指针(简化版,实际需用Unsafe类或AtomicReference)private boolean CASUpdateTail(Node expect, Node update) {  if (tail == expect) {      tail = update;      return true;  }  return false;}

3. 解锁流程(unlock ()):唤醒下一个线程,出队

 

解锁的核心是找到下一个等待线程,通知它可以获取锁,避免链表中后续线程一直自旋,具体分 步(面试时建议结合步骤 代码 流程图讲解):

 

 

解锁流程流程图

 

cpu

步骤 1:获取当前线程的 Node 节点

 

同样通过ThreadLocal获取,当前节点持有锁,解锁时需操作它。

 

 

步骤 2:检查是否有下一个等待线程(next

 

如果next == null:说明当前线程是链表最后一个节点,需要尝试将tail设为null(避免后续线程入队时找不到 prev);

 

 

如果next != null:说明有线程在排队,需要将next.isLocked设为false(通知下一个线程可以获取锁)。

 

 

步骤 3:清理当前线程的 Node(可选)

 

避免 ThreadLocal 内存泄漏,可在解锁后移除当前节点。

 

 

解锁的 Java 实现代码如下:

 

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
public void unlock() {  // 步骤1:获取当前线程的Node节点  Node currNode = currentNode.get();   // 步骤2:检查是否有下一个等待线程  if (currNode.next == null) {      // 情况1:没有下一个线程,尝试将tail设为null      if (CASUpdateTail(currNode, null)) {          // CAS成功:直接返回(链表已空)          return;      }      // CAS失败:说明有新线程正在入队,需要等待它设置next      while (currNode.next == null) {          Thread.yield();      }  }   // 情况2:有下一个线程,通知它可以获取锁(设置next.isLocked为false)  currNode.next.isLocked = false;  // 步骤3:清理当前节点(避免ThreadLocal内存泄漏)  currNode.next = null;  currentNode.remove();}

这里有个面试易错点:为什么 CAS 更新 tail 失败后要循环等待 next

 

 

答:因为当当前线程(最后一个节点)解锁时,可能有新线程正在执行lock()的步骤 3(设置prev.next = currNode),此时currNode.next还未被赋值,直接解锁会导致新线程永远自旋。因此需要循环等待,直到新线程的next设置完成,再通知它获取锁。

 

 

三、面试高频问题:MCS 锁的优势与考点

 

掌握了实现逻辑后,还需要能回答“MCS 锁的核心优势”“与其他锁的区别” 等问题,这些是面试中的加分项。

 

 

1. MCS 锁的核心优势(必答)

 

无缓存风暴:每个线程只自旋等待前一个节点的 isLocked”,而不是同一个共享变量,减少 CPU 缓存同步开销;

 

 

公平性:线程按入队顺序获取锁,避免饥饿(传统自旋锁可能导致线程一直抢不到锁);

 

 

低无效自旋:只有前一个线程释放锁时,下一个线程才会停止自旋,减少无效 CPU 占用。

 

 

2. MCS 锁与 CLH 锁的区别(高频对比题)

 

CLH 锁(另一种排队自旋锁)与 MCS 锁原理类似,但有一个关键差异:

 

 

自旋对象不同MCS 锁自旋 当前节点的 isLocked”CLH 锁自旋 前一个节点的 isLocked”

 

 

适用场景不同MCS 锁更适合 非缓存友好” 的环境(如分布式锁),CLH 锁在共享内存环境(如单机器多线程)中缓存效率更高,但 MCS 锁的实现更直观,面试中更常考。

 

 

3. 实际应用:JDK 中有 MCS 锁吗?

 

答:JDK 1.6 之后,ReentrantLock的非公平锁实现中,底层的 AQSAbstractQueuedSynchronizer)队列其实借鉴了 MCS 锁的 排队思想”——AQS Node节点、tail指针、CAS 入队等逻辑,本质上是 MCS 锁的变种(但 AQS 是阻塞锁,不是自旋锁)。面试时提到这一点,能体现你对 JDK 源码的理解。

 

 

四、总结:MCS 锁面试答题框架

 

最后,给大家整理一个“MCS 锁面试答题框架,按这个逻辑说,既清晰又全面:

 

 

1.定义MCS 锁是排队自旋锁的实现,通过链表记录等待线程,每个线程只自旋前一个线程的释放信号;

 

 

2.设计初衷:解决传统自旋锁的盲等” 和 缓存风暴” 问题;

 

 

3.核心结构Node节点(isLockednext共享 tail 指针 + ThreadLocal 存储当前节点;

 

 

4.流程

 

 

加锁:获取节点→CAS 入队接入链表自旋等待前节点;

 

 

解锁:获取节点检查 next→通知 next 解锁清理节点;

 

 

1.优势:无缓存风暴、公平、低无效自旋;

 

 

2.延伸:与 CLH 锁的区别、JDK 中 AQS 的借鉴。

 

 

掌握这个框架,再结合代码示例和流程图,MCS 锁相关的面试题就能轻松应对了。

 

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分