CAS 即Compare And Swap 或Compare And Set , 比较并替换。是乐观锁的一种具体实现方式,通过自旋的方式来实现共享变量的同步机制。
了解JMM的话应该知道,当线程在对某个共享变量进行操作时,包括下面几个步骤:
首先将主内存的变量值读到 当前线程的工作内存中。(读)
然后在工作内存中对变量值进行修改。(改)
最后将修改后的值再写入主内存。(写)
如下图所示:
到这里不难发现,在上面所说的3个步骤(读-> 改 -> 写), 并不是原子性的操作。所以,在多线程或者并发情况下,有可能会出现线程安全问题。
最典型的就是多线程下的i++问题。
多线程下的i++问题
假设有两个线程同时对共享变量i进行++操作, 那么有可能会出现以下情况,这里假设i的初始值为0:
线程A 将主内存中i的值0 读到线程A工作内存中
线程A 在工作内存中将值修改为1, 但是还没有写回主内存,此时主内存中的 i = 0
线程B 将主内存中i的值0 读到线程B的工作内存中
线程B 在工作内存中将值修改为1, 但是还没有写回主内存,此时主内存中仍然为i = 0
线程A 将工作内存中的值 1 写回 主内存, 此时主内存值 i = 1
线程B 将工作内存中的值 1 写回 主内存, 此时主内存值 i = 1
在这种情况下,两个线程对i 进行++操作,但是 i 最终的值却为1 , 这就产生了线程安全问题。
我们使用一段程序来验证一下:
public class Test {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 使用闭锁 控制主线程在10万个线程执行完毕后再继续运行
CountDownLatch latch = new CountDownLatch(100000);
// 创建10万个线程,对count进行++操作
for (int i = 0; i < 100000; i++) {
new Thread(() -> {
count++;
latch.countDown();
}).start();
}
//主线程阻塞,直到10万个线程运行执行完毕,再继续向下执行。
latch.await();
// 执行完毕后,输出最终的count值
System.out.println(count);
}
}
该段代码在我本地测试3次的结果分别为: 99997, 99995, 99999。 说明多线程下的i++操作确实存在线程安全的问题。
那么该怎么解决这个问题呢?
这就不得不提到java.util.concurrent包,在这个包下,JDK为我们提供了一系列应用于并发场景的API,包括原子引用。我们只需要将要操作的变量类型改为对应的原子应用就可以避免这个问题。
public class Test {
//static int count = 0; 改为对应的原子类型AtomicInteger
static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
// 使用闭锁 控制主线程在10万个线程执行完毕后再继续运行
CountDownLatch latch = new CountDownLatch(100000);
// 创建10万个线程,对count进行++操作
for (int i = 0; i < 100000; i++) {
new Thread(() -> {
//count++; 改为使用原子类型中的getAndIncrement()进行++操作
count.getAndIncrement();
latch.countDown();
}).start();
}
//主线程阻塞,直到10万个线程运行执行完毕,再继续向下执行。
latch.await();
// 执行完毕后,输出最终的count值
System.out.println(count.get());
}
}
该段代码在我本地测试很多次的结果都是: 100000。 说明使用AtomicInteger的方式确实可以避免线程安全问题。
那么为什么使用AtomicInteger就可以避免这个问题呢?
原因就是因AtomicInteger使用了valotile 修饰变量,保证变量的可见性,并且在使用getAndIncrement() 进行自增操作的时候,使用了CAS算法。
CAS原理:
CAS的原理就是:在线程将数据写回主内存的时候,再读取一次主内存的值,与第一次读取的值进行比较, 如果两次读到的值不同,说明中间有其他线程对主内存的数据进行了修改,那么当前线程就不执行写入操作,可以放弃写入操作或者继续循环直到成功,也就是自旋。这个判断和写入的步骤是由系统底层实现的,是一个原子操作,中间不会被别的线程打断,所以避免了多线程下的安全问题。
线程A 将主内存 i = 0 读到工作内存。(expectedValue)
线程A 在工作内存中将值 修改为1。 (newValue)
线程A 在写入值主内存前,再次读取主内存数据 (value),与expectedValue进行对比,若value == expectedValue, 则执行写入,否则无限循环(自旋)或者放弃。
我们看一下AtomicInteger的源码,只看类中的变量和getAndIncrement()方法。
public class AtomicInteger extends Number implements java.io.Serializable {
private static final Unsafe unsafe = Unsafe.getUnsafe();
private volatile int value;
// ....省略其他变量和方法
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
}
通过源码可以看到,AtomicInteger中使用volatile 对 变量 进行了修饰,在getAndIncrement()时,使用Unsafe类的getAndAddInt()来执行自增操作。
Unsafe是Java中的一个底层类,提供了硬件级别的原子操作,该类在JUC包中有重要应用。一些三方框架都使用Unsafe类来保证并发安全。感兴趣的话可以去了解下,这里不再多说。
我们看到在AtomicInteger的getAndIncrement()中调用了Unsafe类的getAndAddInt(), 我们去看一下。
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
我们看到在Unsafe类中的这个方法里,使用while循环调用了this.compareAndSwapInt(),顾名思义,这也就是具体的CAS操作了。再看下这个方法:
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
我们可以看到这个方法时final native 修饰的本地方法,也就是说方法的具体实现不是由Java来实现。而是由系统底层进行cpu级别的实现,所以才可以保证CAS在比较替换操作时的原子性
到这里我们可以看出CAS在JUC系列组件中的重要性,其实不止是像AtomicXXX这样的原子类操作,还包括AQS同步组件等,底层也都使用了CAS算法来保证并发下的线程安全。
CAS算法的优缺点
优点:CAS算法的有优点就不用多说了,CAS可以使线程不需要进行阻塞状态,避免了多线程之间来回切换的开销。
缺点:CAS并不是真正意义上的锁,他并不会阻塞线程,在自旋的过程中,仍然消耗CPU,所以当线程数量过多时并且执行时间过长时,可能会导致大量的CAS自旋操作,从而使性能降低。
所以, CAS并不适合所有的并发场景,他只适合线程数量少,执行时间较短的场景。
CAS存在的ABA问题及解放方案:
CAS在修改主内存值之前,需要检查主内存的值有没有被改变,如果没有改变才进行更新。但是仍然会存在一种情况如下:
线程1读取主内存值A,然后修改, 但是还没有写回主内存
线程2 将主内存的A 改为 B
线程3 将主内存的B 改为 A
线程1 再次读取主内存值A, 判断值没有改变,执行写入
这种情况产生的问题就是,主内存中的值看似没有被改变,但是其实已经被改变,然后又再次被别的线程改了回来,但是其实已经不是原来的那个A了。就像你与前女友分手后,她又经历了3个男朋友,然后再与你复合,表面上还是那个人,但实质上已经不是了(此处自行脑补。。。)
如果变量的值是基本数据类型的话,其实倒没有什么影响,但是如果变量是引用类型,就会出现问题。
解决方案:
使用AtomicStampedReference的版本号机制,在每次写入操作时,同时修改版本号,然后每次比较时,不但比较值是否改变,还比较版本号是否一致。如果都一致,才进行修改。