前言
近日,阅读并发相关知识时,频繁的看到有关CAS、ABA等词汇的相关知识,就称此机会做个总结,把自己理解的CAS算法梳理一下,做个记录以便以后查阅。
一. 什么是CAS算法
1. CAS算法:全称 compare and swap,比较并交换。CAS是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。 该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。
2. CAS中的原子操作:原子即不可分割的不可中断最小单位,可以理解为CPU执行的最小单元。CAS游两个操作:对比和交换,它们将一同作为原子代码块执行,使得在一线程执行该代码块时,会顺利执行操作并不会受到其他线程的影响,从而满足CAS的原子性。这里的原子操作不是由软件或JVM来保证的,而是CPU实现。
3. CAS算法思路:按照获取原值,再进行比较并交换这一原子操作进行。
现在有一个变量A,需要更新A且保证A的并发同步性问题。那么CAS在进行数据更新时,会先从内存中读取A的值(注意,此时读取的操作并不属于CAS的原子性操作),然后再对比读取的值与内存中A的值,如果相同那么便更新A的值。
示例如下,我们可以使用Java中java.util.concurrent.atomic包中的类来实现CAS的原子操作。
public class CASAlgorithm {
AtomicInteger num = new AtomicInteger(0);
public int getNum() {
return num.get();
}
public static void main(String[] args) throws InterruptedException {
CASAlgorithm casAlgorithm = new CASAlgorithm();
for (int i = 1; i <= 1000; i++) {
new Thread(casAlgorithm::update).start();
}
Thread.sleep(2000);
System.out.println("最终结果: " + casAlgorithm.getNum());
}
void update() {
int oldValue = num.get();
// 此时可能其他线程以及修改了num的值
while (true) {
// 使用原子类AtomicInteger的cas方法来保证对比和赋值操作两个操作的原子性
if (num.compareAndSet(oldValue, oldValue + 1)) {
return;
}
oldValue = num.get();
System.out.println("自旋");
}
}
}
运行结果:
通过上例可以看到,我们每次更新num时,总是会先获取原值,然后再通过CAS判断是否赋值成功,当某个线程更新失败时,会进入自旋操作,直到成功后才返回。
二. CAS算法的弊端
1. ABA问题
ABA问题一般发生在多线程环境中,当某个线程两次读取的内存中变量且得到的值都相同时,CAS就会认为,第一次读取和第二次读取数据期间,变量的内存地址没有被改变过。此时,就有可能忽略一种情况,在前一个线程1、2次读取值期间,另一个线程对变量进行了修改,将A改为B,然后又改为A。这边是CAS的ABA问题。
ABA问题对于基本类型来说,是不造成影响的,因为新旧值并不产生任何关联。但当要操作引用类型的变量时,就有可能产生问题。
public class ABAQuestion {
Stack<Node> stack = new Stack<>();
ABAQuestion() {
// 单链表 1 -> 2 -> 3
Node node = new Node(1, null);
Node node1 = new Node(2, null);
node.succ = node1;
Node node2 = new Node(3, null);
node1.succ = node2;
stack.push(node);
stack.push(node1);
// 栈顶 3
stack.push(node2);
}
public static void main(String[] args) throws InterruptedException {
ABAQuestion abaQuestion = new ABAQuestion();
int size = abaQuestion.stack.size();
for (int i = size - 1; i >= 0; i--) {
Node pop = abaQuestion.stack.get(i);
System.out.println(pop.num + " -> " + (null == pop.succ ? "null" : pop.succ.num));
}
Node node = new Node(4, null);
Thread thread = new Thread(() -> {
System.out.println("线程1开始执行");
// 将4插入栈中 理想顺序是 1 -> 2 -> 3 -> 4
abaQuestion.update(node, true);
System.out.println("线程1执行完毕");
});
Thread thread1 = new Thread(() -> {
System.out.println("线程2开始执行");
// 删除节点2 顺序 1 -> 3
Node pop3 = abaQuestion.stack.pop();
Node pop2 = abaQuestion.stack.pop();
abaQuestion.stack.peek().succ = pop3;
abaQuestion.update(pop3, false);
System.out.println("线程2执行完毕");
});
thread.start();
thread1.start();
thread.join();
thread1.join();
System.out.println();
for (int i = size - 1; i >= 0; i--) {
Node pop = abaQuestion.stack.get(i);
System.out.println(pop.num + " -> " + (null == pop.succ ? "null" : pop.succ.num));
}
}
void update(Node newValue, boolean isFirst) {
Node oldValue = stack.peek();
if (isFirst) {
System.out.println("线程1阻塞");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1唤醒");
}
while (true) {
// 假设这里是原子操作 上面的线程休眠保证只有同一个线程执行
if (stack.peek() == oldValue) {
stack.push(newValue);
return;
}
oldValue = stack.peek();
System.out.println("自旋");
}
}
}
class Node {
Integer num;
Node succ;
Node(){}
Node(int num, Node succ) {
this.num = num;
this.succ = succ;
}
}
执行结果如下:
上述代码的执行步骤:
A. 建立一个由节点组成的栈,上述节点间的关系为:1 -> 2 -> 3,栈顶为节点3,其次2,栈底1。
B. 线程1在获取当前栈顶元素3成功后便休眠。
C. 线程2开始执行,并先后移除栈顶元素3、元素2,再将此时的栈顶元素1的后继设为元素3,最后将元素3重新插入栈中。此时可以看到,我们移除的元素3,和我们最后插入的元素3是同一个对象。线程2执行完毕
D. 线程1唤醒,开始执行,将栈顶元素与之前获取的元素3做对比,发现是同一个对象,因此判定没有人操作过该栈对象,随机插入元素4。线程1执行完毕。
E. 返回结果,此时,节点关系为:4, 1 -> 3。栈中元素从栈顶至栈底分别为:4,3,1。
结论:从结果中,我们可以看到典型的ABA错误,线程2可以任意修改栈中元素已经任意元素的值。这一点太致命了,意味着线程2,只要保证执行完毕后,栈顶是线程2执行前的元素,自己就可以从根本上破坏整个栈,而不被其他线程发觉。
解决方法:知道了ABA错误的发生场景,其实还是很容易解决的。以下是针对上述出现的问题提出的解决办法。
A. 增加版本号机制。既然单纯的值判断无法验证是否发生改变,那么可以增加其他参数加以验证。比如版本号、时间戳,甚至校验码都可以,这一点是解决CAS的ABA问题最常用方法。
B. 使用元素而不是使用节点赋值。上述问题可以直观看到,我们赋值时,是直接传进来的一个节点,那么外部该节点的来源可能都是同一个节点。如果我们直接将传参改为值,那么这个问题便不复存在了。
C. 外部来源均使用新对象。通过B方法很容易想到,既然外部来源不确定,那么可以统一口径,都创建一个新的节点即可。不过这一点使用要求较为苛刻。
2. 自旋的CPU开销问题
根据上面CAS原子操作的例子可以知道,某个线程CAS失败时,就会进入自旋状态,这种自旋仅仅是为了等待其他线程数据更新完成从而自己更新数据。如果并发量大,冲突激烈,那么有可能大部分线程都会一直循环运行,极大的消耗CPU资源却并没有实际作用。
解决方法:
1. 增加自旋的次数限制。当多个线程都竞争失败后,那么就不再做无意义的重复运行,可以选择直接返回运行结果或者直接阻塞随后等待CPU唤醒再重新运行。
等待CPU唤醒再重新运行的操作,相当于避免线程切换带来的开销,但是它占用了处理器的时间。但是后续这些沉睡的线程会由CPU逐一唤醒,会加重CPU的任务负担(这里有点类似于自旋锁,需要注意的是,由于这里不是锁,因此需要开发者自定义一个阀值,来决定何时沉睡何时唤醒)。
2. 针对这种冲突激烈的代码块,无锁性能反而会变差,因此可以考虑加锁。
3. 只能保证一个共享变量的原子操作
对单个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS就无法保证。
解决方法:Java1.5后提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
参考: