【关键词】CAS,CPU,底层,缓存锁,MESI缓存一致性协议,总线锁,指令重排,volatile
前阵子看到一个面试题:
CAS底层原理是什么?
略作思考( ̄. ̄)
不就是相当于乐观锁嘛?
乐观锁与悲观锁?
例如:张三和李四去买东西的时候,但是东西只有一个了,怎么买?
店家这时候拍拍脑袋( ̄︶ ̄)↗,想到一个办法:
我就一个人,谁来我这里我就先卖给谁。就很棒,换在计算机里,这属于叫悲观锁,在卖东西时,也就是请求资源的时候,资源只有唯一一个人才能获取,其他人都被阻塞起来不让获取
悲观锁:简单讲就是用阻塞,加锁的方式,在一定时间内,某些东西只让一个人访问或只让一个人操作,其他线程操作将被堵住,不允许操作
过了阵子,店家生意越来越好,在村里开始开分店了,但是仓库还是共用一个的,这个时候,张三李四又来买东西了,老板和分店老板跑到仓库里去看商品,商品还在\( ̄︶ ̄)/
老板眼疾手快把商品拿走了,留下分店老板一人在风中凌乱,,
分店老板看没有商品,只好回去,然后每隔几个小时就会跑来仓库看看是否有他需要的商品,有就拿走,这也就是乐观锁了
乐观锁(CAS):例如线程1和线程2同时在修改一个值,线程1和线程2去修改的时候,发现这个文件是1号的版本,这时线程1抢先一步,将文件替换成2号的版本了,线程2想要替换的时候发现,该文件已经是2号版本了,中途已经被别人替换过了,这时线程2如果想替换就得重新来获取该文件的状态,只有当版本号对应上的时候,才允许替换成功
乐观锁也被成为CAS (compare and swap),在java里面是怎么实现的呢?
常用到的一些地方:
ReentrantLock 类:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
ReentrantLock在底层对某个内容尝试加锁的时候,就会调用Sync类,而这个类继承于抽象类:AbstractQueuedSynchronizer
AQS在对对象加锁的时候,首先会进行尝试去获取锁,而获取锁这个方法,采用的就是CAS,尝试获取锁,如果获取得到,则进行加锁,获取不到,则继续获取,进行自旋操作
继续点开,我们可以发现,这个CAS实际是使用的更底层的Unsafe类去完成的CAS操作(当然不仅仅是AQS,Atomic系列的CAS操作使用的也是这个)
再点开Unsafe类,我们可以看到:
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
满满一排的native方法,我这里就列出我们看的这几种CAS方法,而Unsafe底层采用的native方法是直接由cpu硬件支持的原子操作,这使得java程序通过CAS来运行的效率会非常高。
再往底层去查看就是C语言代码了:
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
// 根据偏移量valueOffset,计算 value 的地址
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
// 调用 Atomic 中的函数 cmpxchg来进行比较交换
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
上面的代码大概含义:
首先使用上面的jint
计算了value
的地址,然后根据这个地址,使用了Atomic
的cmpxchg
方法进行比较交换。现在问题又抛给了这个cmpxchg
,真实实现的是这个函数,接着往下:
assert(sizeof(unsigned int) == sizeof(jint), "more work to do");
/*
* 根据操作系统类型调用不同平台下的重载函数,
这个在预编译期间编译器会决定调用哪个平台下的重载函数
*/
return (unsigned int)Atomic::cmpxchg((jint)exchange_value,
(volatile jint*)dest, (jint)compare_value);
继续往下:
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
}
这块就开始涉及到汇编语言的问题了, 首先三个move指令
表示的是将后面的值移动到前面的寄存器上。然后调用了LOCK_IF_MP
和下面cmpxchg
汇编指令进行了比较交换。现在我们不知道这个LOCK_IF_MP
和cmpxchg
是如何交换的,没关系我们最后再深入一下:
//1、 判断是否是多核 CPU
int mp = os::is_MP();
__asm {
//2、 将参数值放入寄存器中
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
//3、LOCK_IF_MP指令
cmp mp, 0
//4、 如果 mp = 0,表明线程运行在单核CPU环境下。此时 je 会跳转到 L0 标记处,直接执行 cmpxchg 指令
je L0
_emit 0xF0
//5、这里真正实现了比较交换
L0:
/*
* 比较并交换。简单解释一下下面这条指令,熟悉汇编的朋友可以略过下面的解释:
* cmpxchg: 即“比较并交换”指令
* dword: 全称是 double word 表示两个字,一共四个字节
* ptr: 全称是 pointer,与前面的 dword 连起来使用,表明访问的内存单元是一个双字单元
* 这一条指令的意思就是:
将 eax 寄存器中的值(compare_value)与 [edx] 双字内存单元中的值进行对比,
如果相同,则将 ecx 寄存器中的值(exchange_value)存入 [edx] 内存单元中。
*/
cmpxchg dword ptr [edx], ecx
}
到这里,就可以很明显的看出来,CAS在C处就是由操作系统的汇编指令完成的,但是CPU里是如何实现CAS操作的原子性等等呢?
在之前,关于CAS指令最著名的传闻是CAS需要锁总线,因此CAS指令不但慢而且会严重影响系统并发度,即使没有冲突是也一样。不过在较新的CPU中(对于Intel CPU来说是486之后),已经不存在说因为CAS指令特别慢而影响系统并发度了,在这里需要提一下操作系统的的总线锁和缓存一致性。
总线锁、缓存锁
对于使用CAS,在汇编语言里使用cmpxchg指令,先判断cpu是否为单核cpu,如果是单核cpu,则利用总线锁, 保证操作的原子性,下面列一个图来讲一下cpu在内存和缓存的操作结构:
名词解释下:L1d 数据缓存 L1i指令缓存
总线锁:就是在cpu操作主存的时候,当一个cpu核想要去执行一个任务的时候,它会向总线发送一个LOCK信号,总线收到LOCK信号之后,如果此时其他线程想要去请求总线,就会被阻塞住,老版本CPU使用的这种策略,但是由于这种类似“悲观锁的”的机制,会导致其他线程被阻塞,效率大大降低,后续的CPU也没有采用这种策略
缓存锁:如果内存区域某个数据,已经被两个或以上的处理器核缓存了,缓存锁就会通过缓存一致性,阻止对其修改,来保证操作的原子性,当其他处理器核对该内存进行修改后,其他核的缓存中该数据将被无效化。
所以很显然,总线锁的性能没有缓存锁性能好,而缓存锁采用的缓存一致性,就是现在大家听得比较多的MESI协议:
M(Modify):修改,表示该内存空间已被修改过
E(Exclusion):独占,表示该内存空间,仅此核拥有这个内存空间的缓存,就比如,内存空间A,现在只有核1缓存起来了,其他核都没有缓存该内存空间
S(Shared):共享,表示该内存空间目前有多个核均有该内存空间的缓存
I(Invalid):无效,表示该内存空间在缓存里的储存是无效的
缓存行:在CPU缓存里,由于缓存使用时,缓存认为你使用数据时,大概率会使用地址相邻的数据,故一次性将一小块连续空间的内容全部读取出来了,在cpu里,这一块空间,常见大小为64字节,也就是说,缓存读取和操作的最小单元是64字节,其中也会出现缓存行伪共享(CPU缓存行 - 简书)、Java使用时也有缓存行对齐等问题,这里就不一一详细展开讲了
上述流程大概是这样的,当核1去拿数据A的时候,从L1查询数据A,查了发现L1中没有缓存A,这时便跑到L2里去查询,L2也没有,接着又跑到L3里去查询,这时L3也没有
(缓存读取效率:L1:1ns左右 L2:3ns左右 L3:15ns左右 主存:60-80ns左右)
实在没办法,核1只能去主存里拿,它去主存拿完了之后,又不厌其烦的在回来时,先把数据A放到L3,再放到L2,再放到L1中,都放完了之后,cpu会把这个内存空间标记为Exclusion,这个内存空间是被他独享的
叮叮叮
系统这时候发了一个通知,核2也拿了数据A,核1没有办法,只能把数据A的状态更新为Shared,表示该内存已经被多个核所共享
这时候,核2在拿了数据A后,对数据A做了修改,然后将修改后的内容放入主存中,主存被修改后,系统便发送了个通知,核1在接收到通知后,便将自己缓存的数据A标识位Invalid,在需要重新使用该数据的时候,再重新去主存拿。
【延伸】volatile
而Java中的volatile在使用的时候,是有2个用处的:
1.保证变量可见性
说到缓存一致性,就要谈到volatile,在java中,一旦变量使用volatile,就会给这个变量发送一条LOCK前缀的指令,这个指令会将这个变量所在缓存行的数据写回到系统内存
而其他核在看到这个LOCK指令的时候,就不会使用缓存内的数据,而是每次使用该数据都会去主存内重新读取,这样一来,尽管效率变低,但是变量的可见性却保证了
2.禁止指令重排
首先第一个问题,什么是指令重排?
指令重排:cpu在执行指令的时候,并不一定会按照程序的顺序去执行
比如你程序有10行,逻辑1:计算 逻辑2:IO读写,这个时候,如果两者是毫无关联的,那么逻辑1与逻辑2是可以打乱顺序执行的,好处在于,有时候乱序执行 ,会提升系统性能,比如此时先派一个程序去执行逻辑2,在IO读写的延时中,可以利用其它核去执行逻辑1里的计算功能,这样,可以减少停顿时浪费的时间,从而提升效率
那么,cpu是怎么做到,让代码可以打乱顺序执行,但是又不影响逻辑性呢?
cpu可以利用依赖分析,对每行代码所依赖的其他行进行分析,给定范围内的指令构成多根树,从根开始排,就类似于JVM里的GC回收算法,GC在回收之前是有一个可达性分析的,类似于这种,分析哪些代码是相互关联,相互依赖的,将代码分成几块,而每一块之间不相互依赖,这样就可以将每一块的执行顺序进行打乱了
(不过当你的机器指令窗口足够大的时候,编译器可以不排)
那么回到正题
这里的禁止指令重排,其实就是让含有volatile的变量不会被重排序,是按照顺序去执行的,可以理解为把volatile变量相关的代码形成了一个指令关联树,从而达到禁止指令重排的目的
内容是工作1年时发布的,有什么不对的地方还望各位大佬给指出🤝🤝🤝🤝🤝