从汇编底层全面解析 CAS 的来龙去脉

一、引言

对于 Java 开发者而言,关于 CAS ,我们一般当做黑盒来进行使用,不需要去打开这个黑盒。

但随着目前程序员行业的发展,我们有必要打开这个黑盒,去探索其中的奥妙。

本期 CAS 源码解析文章,将带你领略 CAS 源码的奥秘

本源码文章吸收了之前 SpringKakfaJUC源码文章的教训,将不再一行一行的带大家分析源码,我们将一些不重要的部分当做黑盒处理,以便我们更快、更有效的阅读源码。

虽然现在是互联网寒冬,但乾坤未定,你我皆是黑马!

废话不多说,发车!

二、使用

Java 中,CAS 操作是通过 JDK 提供的 java.util.concurrent.atomic 包下的 Atomic 系列类来实现的。

例如,AtomicInteger 类提供了原子性的加法、减法、比较和设置等操作,它们都是通过 CAS 操作来实现的。

Java 中的 CAS 操作通常使用 sun.misc.Unsafe 类来实现,因为 CAS 操作需要直接操作内存,而 Unsafe 类提供了直接操作内存的方法。

虽然 Unsafe 类是 Java 平台的内部实现细节,但是在一些高性能的并发编程库和框架中,仍然会使用 Unsafe 类来实现 CAS 操作。

public class Test {
    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger();
        for (int i = 0; i < 10; i++) {
            atomicInteger.getAndIncrement();
        }
        System.out.println(atomicInteger.get());
    }
}

三、原理

CAS(Compare and Swap)是一种并发编程中常用的原子操作,用于实现多线程环境下的同步和互斥。

CAS 操作包括三个参数:

  • 内存地址 V
  • 原始值 A
  • 新值 B

如果当前内存地址的值等于 原始值 A,则将内存地址的值修改为 新值 B,否则不进行任何操作。

CAS 操作是原子的,即在同一时刻只有一个线程能够成功执行该操作。 在这里插入图片描述

如上所示:

  • 第一步:CPU 获取内存地址上的数据 V
  • 第二步:CPU原始值数据 V 做对比
  • 第三步:
    • 如果相等,将 内存地址数据V 更换成 新值
    • 如果不相等,则不进行操作

四、源码

上面是 CAS 一些的基本使用和原理,老粉都知道,小黄主打的就是一个 源码硬核

我们继续分析其 HotSpot 中的实现

Java 代码中,我们追到下面这行代码就没办法继续往下追了

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

我们翻开 HotSpot 源码:

Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)

在不同的操作系统下面,实现不同。

1、Linux操作系统源码

linux x86 为例,它的 int 类型的 CAS 实现如下:

  • 第一个参数是 exchange_value(新值)
  • 第二个参数是 dest(目标地址)
  • 第三个参数是 compare_value(原值)
inline void* Atomic::cmpxchg_ptr(void* exchange_value, volatile void* dest, void* compare_value) {
  return (void*)cmpxchg((jint)exchange_value, (volatile jint*)dest, (jint)compare_value);
}

咱们继续往下追:

inline jint Atomic::cmpxchg(jint exchange_value, volatile jint* dest, jint compare_value) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}

Linux 环境下,最终调的就是这个方法

2、Window操作系统源码

但实际上来说,Linux 下的方法不太方便我们去阅读源码,我们来看看 Window 下的实现

// atomic_windows_x86.inline.hpp
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:
            
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
  // alternative for InterlockedCompareExchange
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

我们一行一行的去进行分析:

  • mov edx, dest:获取内存地址 dest 数据放至 edx 寄存器中
  • mov ecx, exchange_value:将 新值 放入到 ecx 寄存器中
  • mov eax, compare_value:将 原值放入到 eax 寄存器中
  • LOCK_IF_MP(mp):根据当前是否是多核进行加锁

当然,前面都不是我们的重点,我们的重点是下面这一行代码:

cmpxchg dword ptr [edx], ecx

首先我们先来看 dword ptr [edx] 这个是啥意思

dword :全称是 doubleword

ptr:全称是 pointer,与前面的 dword 连起来使用,表明访问的内存单元是一个双字单元

[edx]:表示一个内存单元,edx 是寄存器,dest 指针值存放在 edx 中。那么 [edx] 表示内存地址为 dest 的内存单元

所以,dword ptr [edx] 的意思:访问内存地址为 dest 的双字内存单元

有人可能会疑惑,这里也没有我们上面说的 eax 里面的寄存器数据呀

不要着急,奥秘就在 cmpxchg 这个里面

我们看一下官方对于 cmpxchg 指令的定义:

Compares the value in the AL, AX, EAX, or RAX register with the first operand (destination operand). If the two values are equal, the second operand (source operand) is loaded into the destination operand. Otherwise, the destination operand is loaded into the AL, AX, EAX or RAX register. RAX register is available only in 64-bit mode.

This instruction can be used with a LOCK prefix to allow the instruction to be executed atomically. To simplify the interface to the processor’s bus, the destination operand receives a write cycle without regard to the result of the comparison. The destination operand is written back if the comparison fails; otherwise, the source operand is written into the destination. (The processor never produces a locked read without also producing a locked write.)
    
    
// 翻译
将 AL、AX、EAX 或 RAX 寄存器中的值与第一个操作数(目标操作数)进行比较。如果两个值相等,则将第二个操作数(源操作数)加载到目标操作数中。否则,目标操作数被加载到 AL、AX、EAX 或 RAX 寄存器中。RAX 寄存器仅在 64 位模式下可用。

该指令可以与 LOCK 前缀一起使用,以允许指令以原子方式执行。为了简化与处理器总线的接口,目标操作数接收一个写周期而不考虑比较的结果。如果比较失败则写回目标操作数;否则,源操作数被写入目标。(处理器永远不会在不产生锁定写入的情况下产生锁定读取。)

所以,我们在这里看到了 EAX 寄存器的出现,将 AL、AX、EAX 或 RAX 寄存器中的值与第一个操作数(目标操作数)进行比较。如果两个值相等,则将第二个操作数(源操作数)加载到目标操作数中。 这一句的描述,也符合我们 CAS 的定义。

现在最关键的问题是,这里有 4 个寄存器,我们怎么才能知道走的是 EAX 寄存器呢?

Accumulator = AL, AX, EAX, or RAX depending on whether a byte, word, doubleword, or quadword comparison is being performed

// 翻译
累加器 = AL、AX、EAX 或 RAX,具体取决于执行的是字节、字、双字还是四字比较 

这里我们看到了,访问不同模式的内存单元,走的寄存器是不同的:

  • byte:AL
  • word:AX
  • doubleword:EAX
  • quadword:RAX

所有,由于上面我们使用的是 doubleword,所以这一段代码:cmpxchg dword ptr [edx], ecx ,可以描述为:

EAX 寄存器中的值与 内存地址为 dest 的双字内存单元 进行比较。如果两个值相等,则将 ecx寄存器中的值 加载到目标操作数中。

这里,也就正式完成了 CAS 的操作。

五、总结

鲁迅先生曾说:独行难,众行易,和志同道合的人一起进步。彼此毫无保留的分享经验,才是对抗互联网寒冬的最佳选择。

其实很多时候,并不是我们不够努力,很可能就是自己努力的方向不对,如果有一个人能稍微指点你一下,你真的可能会少走几年弯路。

我是爱敲代码的小黄,独角兽企业的Java开发工程师,CSDN博客专家,喜欢后端架构和中间件源码。

我们下期再见。

我从清晨走过,也拥抱夜晚的星辰,人生没有捷径,你我皆平凡,你好,陌生人,一起共勉。

#实习##面试##Java#
全部评论
牛的大佬,今天面字节问到了,我只能疯狂挠头
点赞 回复 分享
发布于 2024-05-13 21:54 湖北

相关推荐

线程池:线程池的目的是为了避免线程重复创建和销毁,线程池可以设置最大线程池和阻塞队列任务数。同时当任务进来阻塞队列时候,看那个核心线程有空,去拿出来任务。当线程都在工作,任务会放在队列内排队。若队列也满了,这时候新来任务,会创建临时线程。临时线程等于最大线程数-核心线程。所以说可能出现后来先执行的情况。当临时线程也满了此时源码写着false然后是拒绝创建。线程存活时间:超出的临时线程的空闲存活时间。线程过期要看,线程一定时间没有从工作队列中获取新任务,线程会被标记且回收,此时可用线程数加一。拒绝策略:任务无法被线程池执行的处理策略线程淘汰策略:先进先出(FIFO,&nbsp;First&nbsp;In&nbsp;First&nbsp;Out)描述:最先创建的线程最先被淘汰。优点:实现简单。缺点:不考虑线程的使用频率或重要性,可能导致频繁使用的线程被淘汰。 #牛客AI配图神器#最近最少使用(LRU,&nbsp;Least&nbsp;Recently&nbsp;Used)描述:淘汰最近最少被使用的线程。优点:相对公平,能够保留最近使用频繁的线程。缺点:需要维护每个线程的使用时间戳,增加了开销。 最不经常使用(LFU,&nbsp;Least&nbsp;Frequently&nbsp;Used)描述:淘汰使用频率最低的线程。优点:适合处理长期不使用的线程。缺点:可能会保留一些短期内频繁使用但长期不再使用的线程。随机淘汰(Random&nbsp;Eviction)描述:随机选择一个线程进行淘汰。优点:实现简单,开销低。缺点:无法保证淘汰的合理性,可能会淘汰重要线程。原子性就是一次执行全部执行,一次拒绝全部拒绝,无法在分割了。可见性:线程在修改过程中,其他线程能看见。jmm内存模型:主内存和线程的工作内存,线程基本上都是修改工作内存,如果工作内存和主内存没有同步就会失去可见性,如果加锁了,这会工作内存就能看得见了。CAS:compaer&nbsp;and&nbsp;swap,先看是不是目标值,不是就替换。内存位置,预期值,新值,内存值与预期值匹配,则内存位置的值更新为新值,否则操作失败。同时旧值存到寄存器中,一般是内存值和寄存器互换值。自旋锁:线程在获取锁时候,如果背其他获取了,不断判断能不能获取,知道锁被获取才退出循环。ABA问题:就在上述问题中间,看似没变化,实际上发生了修改。锁升级问题:这里太底层了,想去大厂的兄弟们可以看下,比较难以理解其实,JVM中的锁升级是一个逐步优化的过程,具体步骤如下:无锁状态当一个对象刚被创建时,它处于无锁状态。&nbsp;此时没有任何线程持有该对象的锁。偏向锁(Biased&nbsp;Locking)只有一个线程访问对象时。JVM会将线程ID记录在对象头中,后续该线程可以直接获取锁,无需同步操作。减少了锁的开销,适合单线程重复访问的场景。当另一个线程尝试获取锁时,偏向锁会被撤销,升级为轻量级锁。轻量级锁(Lightweight&nbsp;Locking)多个线程交替访问对象,但没有激烈的竞争。JVM会在当前线程的栈帧中创建一个锁记录(Lock&nbsp;Record),然后通过CAS操作将对象头中的锁指针指向该锁记录。避免了线程阻塞,减少了上下文切换的开销。当CAS操作失败(即锁竞争激烈)时,轻量级锁会升级为重量级锁。重量级锁(Heavyweight&nbsp;Locking)多个线程激烈竞争锁。JVM会将锁交给操作系统管理,线程会进入阻塞状态,等待锁的释放。适合高并发场景,确保线程安全。增加了上下文切换和线程调度的开销。Reentrantlock公平锁与非公平锁:公平锁(Fair&nbsp;Lock)多个线程按照申请锁的顺序依次获取锁,即先到先得。避免了线程饥饿问题,所有线程都有机会获取锁。性能较低,因为需要维护一个队列来记录线程的申请顺序。非公平锁(Non-Fair&nbsp;Lock)多个线程获取锁的顺序不一定是先到先得,允许插队。性能较高,因为减少了线程切换的开销。可能导致线程饥饿,某些线程可能长时间无法获取锁。排他锁与非排他锁(互斥锁和非互斥锁):排他锁和非排他锁的区别在于锁是否可以被多个线程共享排他锁(Exclusive&nbsp;Lock)也称为互斥锁(Mutex&nbsp;Lock),同一时刻只能被一个线程持有。写操作通常需要排他锁,以确保数据的一致性。其他线程必须等待锁释放后才能获取锁。&nbsp;非排他锁(Non-Exclusive&nbsp;Lock)也称为共享锁(Shared&nbsp;Lock),同一时刻可以被多个线程持有。读操作通常使用非排他锁,以提高并发性能。多个线程可以同时获取锁,但写操作仍然需要排他锁。AQS是什么:可用构建锁和其他同步器,AQS用一个FIFO队列管理等待锁的线程,同时提供独占模式和共享模式的同步机制。
点赞 评论 收藏
分享
评论
6
8
分享

创作者周榜

更多
牛客网
牛客企业服务