当前位置: 首页 > 科技观察

浅谈CAS(CompareAndSwap)的实现原理

时间:2023-03-19 01:58:44 科技观察

前言CAS,全称CompareAndSwap,即比较和交换,是乐观锁的一种实现。悲观锁和乐观锁悲观锁总是假设最坏的情况。线程A每次获取或更新数据时,都会感觉到其他线程也在修改数据。为了避免丢失自己的更新操作,线程A会尝试获取这条数据的锁,只有线程A获取到之后,才能对这条数据进行一些更新操作。在此期间,其他线程不能更新,只有线程a释放锁后才能更新。之所以叫悲观锁,是因为它是一种对数据修改持悲观态度的并发控制方式。我们一般认为数据被并发修改的概率比较高,所以在修改前需要加锁。悲观并发控制实际上是一种“先获取锁再访问”的保守策略。同步是悲观锁的一种实现。乐观锁乐观锁假设数据一般不会造成冲突,所以在取数据的时候不会加锁,但是更新的时候会判断这段时间其他线程是否修改了数据。CAS机制是乐观锁的一种实现。乐观锁实现——CASCAS操作一般包含3个参数,期望值,内存值,新值。如果期望值等于存储值,则用新值更新存储值。如果不相等,可以重新比较,直到成功。CAS是一种非阻塞算法。当一个线程更新失败时,不需要挂起,从而节省了大量的线程上下文切换开销。Java使用Unsafe类来支持CAS操作。不了解Unsafe类的同学可以参考我的另一篇JUC基石——Unsafe类。我们用java代码简单模拟一下CAS的过程:/***@paramexpectexpectedvalue*@paramupdatenewvalue*@return*/publicintcas(intexpect,intupdate){//如果update失败,busyloop会继续while(true){//get方法从内存中获取最新值intmemory=get();if(memory==expect){//set方法将内存中的值设置为新值set(update);returnupdate;}}}当然这只是一个模拟,实际的cas操作会用到底层的系统指令,这些指令会保证整个cas操作的原子性,关于这些指令,可能会有另外的章节来解释。悲观锁的实现——synchronizedsynchronized是悲观锁的典型实现。它的用法可以参考我的文章Synchronized。早期的synchronized非常繁琐。好在1.6之后做了很多优化,锁性能提升了不少,关于synchronized的优化,可以参考我的文章Synchronizedoptimization。CAS的缺陷——ABA问题假设有这样一种情况,x的内存值先是A,线程1读取A,然后忙于其他事情,后面值被线程2改成B,然后它被线程3改成了A,此时线程1进行了CAS操作,发现内存值还是A,于是进行了更新操作。但是这个A已经不是原来的A了,也不是之前版本的A了。要解决这个缺陷,可以使用带有版本号或者时间戳的CAS。每次更新A值,版本号加1,或者更新时间戳。此时内存值等于期望值,但不是线程期望的版本号。此时A→B→A变为A(version=1)→B(version=2)→A(version=3)。使用带有版本号的CAS时,可以避免ABA问题。CAS和synchronized的线程冲突比较小。CAS进行自旋操作,synchronized升级为轻量级锁,也是自旋。两者的效率差不多。当线程冲突严重时,CAS的大部分自旋操作都会浪费大量的CPU时间片。这时候synchronized升级为重量级锁,但是在这种情况下,synchronized的效率要比CAS高很多。(因为当线程冲突严重时,synchronized已经意识到轻量级锁的自旋操作效率低下,主动升级为重量级锁,所以这里的busyloop的开销远大于线程切换的开销)。JAVA中的CAS操作,AtomicInteger,实现了CAS,可以原子地更新一个int类型的数据。其实底层也调用了Unsafe类。但是如果你想一次原子地更新多个变量,你可以使用AtomicReference。当然,这就有上面提到的ABA问题。这时候可以使用带有版本号机制的CAS实现类——AtomicStampedReference,它使用一个stamp字段来表示版本号,代码如下图所示:数据库中的CAS运行乐观锁机制数据库不使用表锁、行锁等,以库存修改为例,乐观锁实现如下:updategoodssetquantity=99whereid=1andquantity=100;这个场景比较简单,暂时不考虑ABA问题。其实上面的SQL还是有一定问题的,就是一旦并发很高,只能有一个线程修改成功,会出现大量的失败。因此需要降低乐观锁的粒度。有更好的建议,可以降低乐观锁的强度,最大化吞吐率,提高并发能力!如下:updategoodssetquantity=quantity-1whereid=1andquantity-1>0将quantity=100转化为quantity-1>0,大大降低了乐观锁的强度,效率大大提高。JVM中的CAS操作调用Java中的newobject()创建一个对象,分配到JVM的堆中。那么这个对象是如何保存在堆中的呢?首先,在执行newobject()的时候,这个对象需要多少空间其实已经确定了,因为java中的各种数据类型占用了多少空间。它是固定的。如何确定对象的大小,可以参考我的文章对象的内存布局,如何确定对象的大小。那么接下来的工作就是在堆中找一块空间来存放这个对象。在单线程的情况下,一般有两种分配策略:指针碰撞:一般适用于内存绝对规律的情况(内存是否规律取决于内存回收策略)。已用内存放在一侧,空闲内存放在另一侧。它们之间有一个边界指针。分配空间的工作只是将边界指针向空闲内存一侧移动对象大小的距离。freelist:这个适用于内存不规律的情况。在这种情况下,JVM会维护一个内存列表,记录哪些内存区域是空闲的,大小是多少。在给对象分配空间的时候,先到空闲链表中找到合适的区域再分配。但是,对象的创建非常频繁。为了保证效率,JVM可以为对象并发分配内存空间。由于分配内存不是原子操作,至少需要以下步骤:查找空闲链表、分配内存、修改空闲链表等,不安全。解决并发时的安全问题也有两种策略:CAS:实际上是虚拟机使用CAS配合失败重试来保证更新操作的原子性。原理同上。TLAB:如果使用CAS,其实会对性能有影响,所以JVM提出了更高级的优化策略:每个线程在Java堆中预分配一小块内存,称为本地线程分配缓冲区(TLAB),当线程需要分配内存时,可以直接在TLAB上分配,避免了线程冲突。只有当缓冲区的内存耗尽,需要重新分配内存时,才会进行CAS操作,分配更大的内存空间。虚拟机是否使用TLAB可以通过-XX:+/-UseTLAB参数进行配置(jdk5及以后版本默认启用TLAB)。