理解CAS
在日常开发中,难免会使用到并发,如对一个计数器自增。考虑如下场景:
public class DoAndRecord {
private int cot = 0;
public void doFunc () {
// Do something...
++cot;
}
}
如果是并发的场景,很容易造成两个线程操作结束后,最后值只自增了1,出现了线程安全问题
。因此,在实际开发中,对于这样的简单的操作,我们可能会用到如下的类,如AtomicInteger
。
public class DoAndRecord {
private AtomicInteger cot = new AtomicInteger(0);
public void doFunc () {
// Do something...
cot.getAndIncrement();
}
}
这是一种无锁的编程方式,这个类的底层就通过Unsafe类用到了CAS,即Compare And Swap
。实现的原理其实很简单:如需要自增一个变量v,在更新前有一个旧值e,期望更新成n。这个过程会经历读和写,在写之前,验证现在变量的值还是不是e,如果还是e则更新为n,不是则失败。
这里需要注意的是,无锁并不代表真的无锁,只是软件层面没有加任何的锁。只是,我们在编程的过程中没有使用到锁,但是考虑红色部分的描述,可能会考虑一个问题,用一个变量保护另一个变量,谁来保护保护变量的变量呢?在这里,比较与写之间,会不会有其他线程在这个空挡更新这个值?答案是不会,硬件工程师将CAS设计为一条原子的指令,只是,实现这种方式必须保证读和写之间是原子的,锁被加在了底层硬件的位置,至于如何加锁,锁住总线或是缓冲行,则依赖于具体CPU的实现,不同的处理器体系结构可能不同,软件工程师不需要考虑这一点。
诚然编程变得简单,在上面的demo中,自增操作已经无需使用synchronized去锁住临界区的代码,但是简单的虽然是优雅的,不过未必是完美的,CAS依然会带来问题。
ABA问题
场景
两个线程都希望将100变为50(或者考虑为两个用户有共同的愿望),那么考虑正常结束的情况下,应该有一个线程是修改失败结束:当一个线程修改成功了以后,已经是50了,而不是100,不应该修改。这是正常的情况,但是过程中,第三个线程仅希望将这个值加上50(而不追究原来是多少)。这个时候,有了第三个线程的参与,最终的值应该是100才对。但是,应该失败的线程阻塞住了,偏等一个“将100变成50”的线程,和“把值加50”的线程全部执行完毕后,才继续执行,于是,读到100,变成50,三个线程都成功退出了。
这显然与预期结果是不符的,我们期望有一个线程失败,最终结果为100,但是实际情况三个线程都成功结束,值却是50。如果涉及到对并发场景的数据一致性要求非常高的情况,这种数据的丢失会引发严重的线上问题!这个就是老生常谈的ABA问题,通过场景来理解这个问题带来的代价,则是我作这篇博客的重点。
解决的方式
依然是上面这个场景,造成这个问题的原因,是那个本应失败的线程根本不知道100与100还有区别,它比较的时候所看见的100,与一开始的100根本就不是同一个100,所以,解决的方式就是为100打上版本号,让这个线程可以区分两个100,问题就可以解决了。可以参考AtomicStampedReference
的实现,原理与上述相同。
自旋等待问题
这个要深入到源码中去发现问题,我们进入到Java的JUC包的基石——Unsafe类中去查看:
与普通的CAS不同,CAS如果在比较的阶段发现读到的值与预期不同,指令就会执行失败,这里所做的事是:如果失败了,就把新的预期值拿出来,再去比较,直到成功。可以预见,当并发量特别高的时候,这个方法会经常失败,代价是空转CPU。试想在高并发的情况下还如此浪费资源,情况是非常糟糕。解决方法可以有减少自旋的次数,如失败次数达到一定的阈值就放弃或阻塞,待唤醒之后再继续,提高成功率。CAS的目的是减少线程阻塞、唤醒的过程以加快执行速度,当情况非常糟糕,乐观锁的效率并非很高的时候,可以考虑将二者达到一个平衡。
AtomicReference
这个是JUC包提供的另一个类,CAS能做到的只是更新一个值,但是如果是一个结构(实例),可能会需要同时更新多个值。这里的实现方法也是CAS,只不过更新的是地址,使用的Unsafe
类中的compareAndSwapObject
方法。这是一个native方法,底层C++源码实现的时候,将原来的值的地址更新成新的值的地址,以实现更新多个值的目的。
class N {
Integer a;
Integer b;
Double d;
public N (Integer a, Integer b, Double d) {
this.a = a;
this.b = b;
this.d = d;
}
public N (Integer a, Double d) {
this.a = a;
this.d = d;
}
@Override
public String toString () {
return "N{" +
"a=" + a +
", b=" + b +
", d=" + d +
'}';
}
}
然后使用AtomicReference更新:
AtomicReference<N> reference = new AtomicReference<>(new N(1, 2, 2.5));
System.out.println(reference.get());
reference.getAndSet(new N(1, 1.5));
System.out.println(reference.get());
/*
N{a=1, b=2, d=2.5}
N{a=1, b=null, d=1.5}
*/
可以看见,更新时是整体的替换,可以印证刚刚的说法,通过将原来的引用指向新的地址以完成通过一次CAS更新全部的属性的方法。具体可以参考openjdk的C++实现compareAndSwapObject
的代码,博主目前无暇去搜寻具体的代码,下面的代码摘抄自其他博主——源码解析 Java 的 compareAndSwapObject 到底比较的是什么?。
// Unsafe.h
virtual jboolean compareAndSwapObject(::java::lang::Object *, jlong, ::java::lang::Object *, ::java::lang::Object *);
// natUnsafe.cc
static inline bool compareAndSwap (volatile jobject *addr, jobject old, jobject new_val)
{
jboolean result = false;
spinlock lock;
// 如果字段的地址与期望的地址相等则将字段的地址更新
if ((result = (*addr == old)))
*addr = new_val;
return result;
}
// natUnsafe.cc
jboolean sun::misc::Unsafe::compareAndSwapObject (jobject obj, jlong offset,jobject expect, jobject update) {
// 获取字段地址并转换为字符串
jobject *addr = (jobject*)((char *) obj + offset);
// 调用 compareAndSwap 方法进行比较
return compareAndSwap (addr, expect, update);
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具