【多线程系列】CAS 常见的两个升级版本 CLH、MCS

  • Hello,大家好,我是Lorin 洛林,前一期我们聊了基于乐观锁实现的一种高效、线程安全的原子操作 CAS ,但简单的 CAS 也有一些不足,比如无法实现公平锁,依赖一个互斥标记,当线程竞争比较大时会导致大量的 CPU 高速缓存同步,这一期我们来聊聊 CAS 的升级版:CLH、MCS 如何解决这些问题。

导读

  • 普通自旋锁可能存在的一些问题:饥饿、如何实现公平、CPU 高速缓存频繁同步
  • CLH 锁 和 MCS 锁是什么?以及使用场景

环境及版本

  • 运行版本:JDK8

普通自旋锁存在的问题

  • 自旋锁是 Java 并发编程中的常见解决方案,当互斥资源被其它线程占用时,通过自旋的方式尝试获取锁,避免阻塞和唤醒线程带来的上下文切换开销,但普通的自旋锁存在以下几方面问题:

    1、非公平锁,可能导致饥饿
    2、依赖一个互斥标记,线程较多时竞技激烈,且多个CPU高速缓存同步频繁
    3、实现非公平锁需要额外的字段

CLH 锁 和 MCS 锁

  • 解决上述问题,我们可以用 CLH 锁 MCS 锁通过队列实现。

CLH 锁

  • CLH 是一种逻辑队列自旋锁,由 Craig、Landin 和 Hagersten 三位作者提出,具体内容在 《Building FIFO and Priority-Queuing Spin Locks from Atomic Swap》 论文中有详细介绍。
  • Java 中 AQS 就是基于变种 CLH 实现。

流程

  • 下面是 CLH 锁 加锁和解锁的大致流程:

加锁

  • 维护队列的尾节点,通过 CAS 操作将线程入队,并将前置节点置为上一个尾节点(逻辑连接),lock 状态置为 true (lock 状态为 true 表示正在获取锁或已经成功获取锁,需要结合前置节点 lock 状态判断)。
  • 入队后的节点,自旋轮询前一个尾节点(即当前节点的前置节点)lock 状态,当前置节点为空或 lock 为 false 时,当前节点成功获取锁。

解锁

  • 解锁时将当前节点的 lock 状态置为 false。

示例代码

Java 复制代码
interface Lock {
    void lock();

    void unlock() throws Exception;
}

class CLHLock implements Lock {

    /**
     * tailNode 尾节点原子操作保证线程安全
     */
    private final AtomicReference<Node> tailNode = new AtomicReference<>();

    private final ThreadLocal<Node> currentNodeLocal = new ThreadLocal<>();

    private static class Node {
        /**
         * 前驱节点
         */
        private Node preNode;
        /**
         * 当前节点状态
         * volatile 保证对后置线程的可见性
         */
        private volatile Boolean lockState;

        public Node(Boolean lockState) {
            this.lockState = lockState;
        }
    }

    @Override
    public void lock() {
        Node currentNode = currentNodeLocal.get();
        if (currentNode == null) {
            currentNodeLocal.set(new Node(true));
            currentNode = currentNodeLocal.get();
        }

        // 拿到当前节点的前置节点 形成逻辑连接 无实际连接
        Node preNode = tailNode.getAndSet(currentNode);
        // 检查前置节点 lock state
        while (currentNode.preNode != null && preNode.lockState) {
            System.out.println(Thread.currentThread().getName() + " 自旋等待获取锁");
        }
        System.out.println(Thread.currentThread().getName() + " 获取锁成功");
    }

    @Override
    public void unlock() throws Exception {
        Node currentNode = currentNodeLocal.get();
        if (!currentNode.lockState || (currentNode.preNode != null && currentNode.preNode.lockState)) {
            throw new Exception("current thread is not locked");
        }
        currentNode.lockState = false;
        // 清除线程 ThreadLocal 本次锁信息 避免拿到已经释放的锁信息
        currentNodeLocal.remove();
        System.out.println(Thread.currentThread().getName() + " 释放锁");
    }
}

CLH 的优点

  • 性能优异,获取和释放锁开销小。CLH 的锁状态不再是单一的原子变量,而是分散在每个节点的状态中,降低了自旋锁在竞争激烈时频繁同步的开销。释放锁的开销也因为不需要使用 CAS 指令而降低。
  • 公平锁。
  • 实现简单,可用于拓展,如 AQS 就是基于变种 CLH实现。

CLH 的不足

  • 对于锁长时间持有的场景会造成 CPU 自旋损耗。
  • 过于简单,实现复杂功能需要进行拓展。

MCS 锁

  • MCS 由 John M. Mellor-Crummey 和 Michael L. Scott 提出,具体内容可以在 《Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors》 论文中查看。
  • MCS 锁和 CLH 锁十分相似,都是逻辑队列自旋锁,但 CLH 锁轮询的是前置节点的 lock 域,而 MCS 锁轮询的自己当前节点的 lock 域,前置节点释放锁时会更新队列后置节点 lock 状态,即可以根据当前节点的 lock 状态来判断是否可以获取锁,主要是为了解决 NUMA(Non-Uniform Memory Access) 架构下读取远端内存速度较慢的问题。

和 CLH 锁区别

  • CLH 锁逻辑队列之间连接无物理连接,MCS 锁存在物理连接。
  • 核心:CLH 锁通过自旋轮询前置节点 lcok 域状态判断是否获取锁,MCS 锁判断当前节点 lock 状态。

流程

  • 下面是 MCS 锁加锁、解锁大致流程:

加锁

  • 维护队列的尾节点,通过 CAS 操作将线程入队,若前置节点为空,直接获取锁,若前置节点不为空,将前置节点的 next 节点指向当前节点,轮询当前节点 lock 状态。(初始值为 false 未获取锁,true 为获取到锁)

解锁

  • 当前节点释放锁,唤醒队列中的后置节点,即将后置节点的 lock 置为 true。

示例代码

Java 复制代码
interface Lock {
    void lock();

    void unlock() throws Exception;
}

class MSCLock implements Lock {

    /**
     * tailNode 尾节点原子操作保证线程安全
     */
    final AtomicReference<Node> tailNode = new AtomicReference<>();

    private final ThreadLocal<Node> currentNodeLocal = new ThreadLocal<>();

    private static class Node {
        /**
         * 后驱节点
         * volatile 保证 nextNode 引用的可见性
         */
        private volatile Node nextNode;
        /**
         * 当前节点状态
         * volatile 保证对后置线程的可见性
         */
        private volatile Boolean lockState;

        public Node(Boolean lockState) {
            this.lockState = lockState;
        }
    }

    @Override
    public void lock() {
        Node currentNode = new Node(true);
        currentNodeLocal.set(currentNode);
        Node preNode = tailNode.getAndSet(currentNode);
        // 首节点直接获取锁
        if (preNode == null) {
            currentNode.lockState = true;
        } else {
            preNode.nextNode = currentNode;
            // 自旋检测当前节点状态
            while (!currentNode.lockState) {
                System.out.println(Thread.currentThread().getName() + " 自旋等待获取锁");
            }
        }
        System.out.println(Thread.currentThread().getName() + " 获取锁成功");
    }

    @Override
    public void unlock() {
        Node currentNode = currentNodeLocal.get();
        Node nextNode = currentNode.nextNode;

        // 若无等待线程 尝试将tailNode置为 null
        if (nextNode == null) {
            if (tailNode.compareAndSet(currentNode, null)) {
                System.out.println(Thread.currentThread().getName() + " 锁释放成功");
                return;
            } else {
                nextNode = currentNode.nextNode;
            }
        }

        // 清除线程 ThreadLocal 本次锁信息 避免拿到已经释放的锁信息
        currentNodeLocal.remove();
        // 唤醒下一个等待线程
        nextNode.lockState = true;
    }
}

下期预告

  • 下一期我们将聊聊 Java 多线程编程中最重要的 抽象队列同步器 AQS ,这是 JUC 中除了 CAS 外另一个 底层工具,我们使用的大多数锁都是基于这两个底层工具实现。

参考

相关推荐
RainbowSea1 分钟前
6. RabbitMQ 死信队列的详细操作编写
java·消息队列·rabbitmq
RainbowSea9 分钟前
5. RabbitMQ 消息队列中 Exchanges(交换机) 的详细说明
java·消息队列·rabbitmq
李少兄2 小时前
Unirest:优雅的Java HTTP客户端库
java·开发语言·http
此木|西贝2 小时前
【设计模式】原型模式
java·设计模式·原型模式
可乐加.糖2 小时前
一篇关于Netty相关的梳理总结
java·后端·网络协议·netty·信息与通信
s9123601012 小时前
rust 同时处理多个异步任务
java·数据库·rust
9号达人2 小时前
java9新特性详解与实践
java·后端·面试
cg50172 小时前
Spring Boot 的配置文件
java·linux·spring boot
啊喜拔牙2 小时前
1. hadoop 集群的常用命令
java·大数据·开发语言·python·scala
anlogic3 小时前
Java基础 4.3
java·开发语言