什么是CAS机制?

CAS和Synchronized的区别是什么?适合什么样的场景?有什么样的优点和缺点?

示例程序:启动两个线程,每个线程中让静态变量count循环累加100次。

复制代码
public class ThreadTest {
	private static int count = 0;

	public static void main(String[] args) {
		for (int i = 0; i < 2; i++) {
		    //开启两个线程
			new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						Thread.sleep(10);
					} catch (Exception e) {
						e.printStackTrace();
					}
					//每个线程自增100
					for (int i = 0; i < 100; i++) {
						count++;
					}
				}
			}).start();
		}
		try {
			Thread.sleep(200);
		} catch (Exception e) {
			e.printStackTrace();
		}
		System.out.println("count="+count);
	}
}

最终输出的count结果是什么呢?一定会是200吗?

因为这段代码不是线程安全的,所以最终的自增结果很可能少于200!

加上Synchronized同步锁,看看结果:

复制代码
public class ThreadTest {
	private static int count = 0;

	public static void main(String[] args) {
		for (int i = 0; i < 2; i++) {
			//开启两个线程
			new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						Thread.sleep(10);
					} catch (Exception e) {
						e.printStackTrace();
					}
					//每个线程自增100
					for (int i = 0; i < 100; i++) {
						//加上同步锁
						synchronized (ThreadTest.class) {
							count++;
						}
					}
				}
			}).start();
		}
		try {
			Thread.sleep(200);
		} catch (Exception e) {
			e.printStackTrace();
		}
		System.out.println("count="+count);
	}
}

加了同步锁之后,count自增的操作变成了原子性操作,所以最终的输出一定是count=200,代码实现了线程安全。

Synchronized的确保证了线程安全,但是在某些情况下,确不是最优选择。

为什么这么说呢?关键在于性能问题。

Synchronized关键字会让没有得到锁资源的线程进入BLOCKED 状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。

尽管Java1.6为Synchronized做了优化,增加了从偏向锁轻量级锁 再到重量级锁的过度,但是在最终转变为重量级锁之后**,性能仍然较**低。

还有别的方法吗?

有没有听说过,java当中的原子操作类

所谓原子操作类,指的是java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。例如AtomicBoolean,AtomicInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子性操作。

现在我们尝试在代码中引入AtomicInteger类:

复制代码
public class ThreadTest {
//	private static int count = 0;
	private static AtomicInteger count = new AtomicInteger(0);

	public static void main(String[] args) {
		for (int i = 0; i < 2; i++) {
			//开启两个线程
			new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						Thread.sleep(10);
					} catch (Exception e) {
						e.printStackTrace();
					}
					//每个线程自增100
					for (int i = 0; i < 100; i++) {
						//加上同步锁
//						synchronized (ThreadTest.class) {
//							count++;
//						}
						count.incrementAndGet();
					}
				}
			}).start();
		}
		try {
			Thread.sleep(200);
		} catch (Exception e) {
			e.printStackTrace();
		}
		System.out.println("count="+count);
	}
}

使用AtomicInteger之后,最终的输出结果同样可以保证是200。并且在某些情况下,代码的性能会比Synchronized更好。

Atomic操作类的底层,正是用了CAS机制。

什么是CAS?

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。

CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B

更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

这样说或许有些抽象,我们来看一个例子:

  1. 在内存地址V当中,存储着值为10的变量。
  1. 此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10要修改的新值B=11
  1. 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。
  1. 线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败。
  1. 线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋
  1. 这一次比较幸运,没有其他线程改变地址V的值。线程1进行Compare,发现A和地址V的实际值是相等的。
  1. 线程1进行SWAP,把地址V的值替换为B,也就是12。

从思想上来说,Synchronized属于悲观锁 ,悲观地认为程序中的并发情况严重,所以严防死守。CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。

这两种机制没有绝对的好与坏,关键看使用场景。在并发量非常高的情况下,反而用同步锁更合适一些。

Java当中都有哪些地方应用到了CAS机制呢?
  • Atomic系统
  • Lock系列类的底层实现

甚至在java1.6以上版本,Synchronized转变为重量级锁之前,也会采用CAS机制。

CAS机制有哪些缺点?
  1. CPU开销较大

在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。

  1. 不能保证代码块的原子性

CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。

  1. ABA问题

两个问题如下需要解决:

  1. Java当中CAS的底层实现。
  2. CAS的ABA问题和解决方法。

1.CAS的底层究竟是怎么来实现的?比如AtomicInteger,是怎么做到原子性的比较和更新一个值?

我们来看一下AtomicInteger的源代码

首先看一看AtomicInteger当中常用的自增方法 incrementAndGet

复制代码
private volatile int value;

public final int get(){
    return value;
}

public final int incrementAndGet(){
    for(;;){
        int current = get();
        int next = current+1;
        if(compareAndSet(current,next)){
            return next;
        }
    }
}

这段代码是一个无限循环,也就是CAS的自旋。循环体当中做了三件事:

  1. 获取当前值。
  2. 当前值+1,计算出目标值。
  3. 进行CAS操作,如果成功则跳出循环,如果失败则重复上述步骤。

这里需要注意的重点是 get 方法,这个方法的作用是获取变量的当前值。

如何保证获得的当前值是内存中的最新值呢?很简单,用volatileˈvɒlətaɪl关键字来保证。有关volatile关键字的知识,我们之前有介绍过,这里就不详细阐述了。

compareAndSet是如何保证原子性操作的呢?

接下来看一看compareAndSet方法的实现,以及方法所依赖对象的来历:

复制代码
private static final Unsafe unsafe = Unsafe.getUnsafe();

private staitc final long valueOffset;

static{
    try{
        valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclareField("value"));
    }catch(Exception ex){
        throw new Exception(ex);
    }
}

public final boolean compareAndSet(int expect,int update){
    return unsafe.compareAndSwapInt(this,valueOffset,expect,update);
}

compareAndSet方法的实现很简单,只有一行代码。这里涉及到两个重要的对象,一个是unsafe ,一个是valueOffset

什么是unsafe呢?Java语言不像C,C++那样可以直接访问底层操作系统,但是JVM为我们提供了一个后门,这个后门就是unsafe。unsafe为我们提供了硬件级别的原子操作

至于valueOffset对象,是通过unsafe.objectFieldOffset方法得到,所代表的是AtomicInteger对象value成员变量在内存中的偏移量。我们可以简单地把valueOffset理解为value变量的内存地址。

我们在上一期说过,CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B

而unsafe的compareAndSwapInt方法参数包括了这三个基本元素:

  • valueOffset参数代表了V
  • expect参数代表了A
  • update参数代表了B

正是unsafe的compareAndSwapInt方法保证了Compare和Swap操作之间的原子性操作。

2.ABA问题呢?

所谓ABA问题,就是一个变量的值从A改成B,又从B改成了A。

什么是ABA呢?假设内存中有一个值为A的变量,存储在地址V当中。

此时有三个线程想使用CAS的方式更新这个变量值,每个线程的执行时间有略微的偏差。线程1和线程2已经获得当前值,线程3还未获得当前值。

接下来,线程1先一步执行成功,把当前值成功从A更新为B;同时线程2因为某种原因被阻塞住,没有做更新操作;线程3在线程1更新之后,获得了当前值B。

再之后,线程2仍然处于阻塞状态,线程3继续执行,成功把当前值从B更新成了A。

最后,线程2终于恢复了运行状态,由于阻塞之前已经获得了"当前值"A,并且经过compare检测,内存地址V中的实际值也是A,所以成功把变量值A更新成了B。

这个过程中,线程2获取到的变量值A是一个旧值,尽管和当前的实际值相同,但内存地址V中的变量已经经历了A->B->A的改变。

表面看起来没毛病,本来就是要把A变成B,但如果我们结合实际应用场景,就可以看出它的问题所在。

当我们举一个提款机的例子。假设有一个遵循CAS原理的提款机,小灰有100元存款,要用这个提款机来提款50元。

由于提款机硬件出了点小问题,小灰的提款操作被同时提交两次,开启了两个线程,两个线程都是获取当前值100元,要更新成50元。

理想情况下,应该一个线程更新成功,另一个线程更新失败,小灰的存款只被扣一次。

线程1首先执行成功,把余额从100改成50。线程2因为某种原因阻塞了。这时候,小灰的妈妈刚好给小灰汇款50元。

线程2仍然是阻塞状态,线程3执行成功,把余额从50改成100。

线程2恢复运行,由于阻塞之前已经获得了"当前值"100,并且经过compare检测,此时存款实际值也是100,所以成功把变量值100更新成了50。

这个举例改编自《java特种兵》当中的一段例子。原本线程2应当提交失败,小灰的正确余额应该保持为100元,结果由于ABA问题提交成功了。

那么ABA问题如何解决呢?

解决方法很简单,加个版本号就行。

什么意思呢?真正要做到严谨的CAS机制,我们在Compare阶段不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致。

我们仍然以最初的例子来说明一下,假设地址V中存储着变量值A,当前版本号是01。线程1获得了当前值A和版本号01,想要更新为B,但是被阻塞了。

这时候,内存地址V中的变量发生了多次改变,版本号提升为03,但是变量值仍然是A。

随后线程1恢复运行,进行Compare操作。经过比较,线程1所获得的值和地址V的实际值都是A,但是版本号不相等,所以这一次更新失败。

在Java当中,AtomicStampedReference类就实现了用版本号做比较的CAS机制。

3.总结

3.1 Java语言CAS底层如何实现?

利用unsafe提供了原子性操作方法。

3.2 什么是ABA问题?怎么解决?

当一个值从A更新成B,又更新会A,普通CAS机制会误判通过检测。

利用版本号比较可以有效解决ABA问题。

相关推荐
蝎子莱莱爱打怪5 天前
XZLL-IM干货系列 04|Netty 长连接实战:Pipeline 怎么排、心跳怎么跳、连接怎么管
后端·微服务·面试
SamDeepThinking6 天前
Java微服务练习方式
java·后端·微服务
米丘9 天前
微前端之 Web Components 完全指南
微服务·html
霸道流氓气质11 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
霸道流氓气质12 天前
Spring Boot 微服务性能优化完全指南
spring boot·微服务·性能优化
地瓜伯伯12 天前
从MESI缓存一致性协议讲透synchronized的底层
java·spring boot·spring·spring cloud·微服务·springcloud
Devin~Y12 天前
大厂 Java 面试实录:从音视频内容社区到 AI RAG 的全链路技术设计
java·spring boot·redis·spring cloud·微服务·kafka·音视频
递归尽头是星辰12 天前
AI 访问数据仓库:从直连到微服务化
数据仓库·人工智能·微服务·dataagent·ai数据治理
就改了12 天前
Windows 环境 SkyWalking 完整实操教程
windows·微服务·skywalking
至乐活着13 天前
Docker Compose多服务编排实战:从零搭建Node.js+MySQL+Redis全栈应用
docker·微服务·devops·容器编排·compose