有关volatile和CAS在JUC中的理解

有关volatile和CAS在JUC中的理解

@[JUC|并发]


[TOC]


并发编程一直是Java语言一个非常重要的特性,J.U.C作为Java多线程的工具包,它封装了许多并发操作的实现细节,例如如何使用线程池,提供了各种锁的实现,线程安全的容器等等。J.U.C提供的这些功能,其基础依靠的是volatile关键字和CAS操作实现的。本文将分析volatile和CAS具体实现原理和它们在J.U.C中实际的使用场景,来探讨它们是如何作为J.U.C基础组件的。

从jdk1.5开始,Doug Lea在jdk中增加了J.U.C这个并发工具包,它的全称是java.util.concurrent,在这个包下,提供了许多并发工具:

  • locks锁工具:例如ReentrantLock

  • atomic原子类:例如AtomicInteger

  • executor执行器:线程池执行器ThreadPoolExecutor

  • collection线程安全容器:ConcurrentHashMap

  • sync同步器框架:AQS

这些同步工具和框架,它们大部分通过无锁的方式实现线程安全,从而大大提高了多线程并发执行的效率,无锁方式实现线程安全的原理便是通过volatile关键字和CAS操作,J.U.C的整体架构:

有关并发问题的理解

什么是线程安全:在多线程环境下,针对某一共享对象或属性的并发操作,总是能得到我们想要的结果。
在多线程并发过程中,我们通过三个特性来保证并发安全,它们分别是原子性、可见行和有序性

原子性

所谓原子性是指针对某一个操作必须是不可分割的、原子的,例如针对i++这个操作,当多个线程并发执行这个动作,对内存中的同一个属性值执行+1操作,这个操作不是一个原子操作,具体的执行过程可能存在多种情况,从而导致最终结果与我们预想的结果不一致。
对不可分割的理解:对于某一个操作,当前线程要么全部执行完毕,要不全部状态回滚,同时在执行过程中不能让出执行权

原子性问题.png
i++被分割成了三个操作,读取、加1和写入操作,在加1阶段,可能发生其它的去读、加1和写入操作,而在写入操作是也可能发生其它的读取、加1和写入操作,因此两个线程执行完i++最后i的结果确实1;

可见性

可见行:当一个线程修改了某一共享变量后,能够对其它线程可见,其它线程可以获取到这个变量对更新值
在Java的内存模型中,每个线程有自己的工作内存,当要读取和修改某一个共享变量的时候,需要从共享的主内存中复制一份变量到工作内存,然后进行修改,如果这时其它线程修改了这个贡献变量,当前线程是无感知的,这也就会导致不可见。

有序性

有序性:程序的执行顺序按照代码的先后顺序执行
在单线程内,程序的执行是串行的,也就是代码顺序执行,但是多线程环境下,不同线程可能存在切换,执行顺序不一定是按照代码的顺序执行

volatile关键字的原理

在前面关于可见性问题上我们认为线程之间是不存在可见行的,我们可以编写一段代码进行验证,首先定义一个共享变量stop

1
public static boolean stop;

执行一下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void unVisibility() throws InterruptedException {
Thread t1 = new Thread(() -> {
System.out.printf("Thread %s is running...\n", Thread.currentThread().getName());
while (!stop) {

}
System.out.printf("Thread %s is end...\n", Thread.currentThread().getName());
});
t1.start();
Thread.sleep(1000);

Thread t2 = new Thread(() -> {
System.out.printf("Thread %s is running...\n", Thread.currentThread().getName());
stop = true;
System.out.printf("Thread %s is end...\n", Thread.currentThread().getName());
});
t2.start();
}

执行结果:

1
2
3
Thread Thread-0 is running...
Thread Thread-1 is running...
Thread Thread-1 is end...

当Thread-1执行完毕,线程Thread-0一直没有退出,也就是一直在死循环。

我们在定义一个由volatile修饰的变量vStop

1
public static volatile boolean vStop;

执行以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void visibility() throws InterruptedException {
Thread t1 = new Thread(() -> {
System.out.printf("Thread %s is running...\n", Thread.currentThread().getName());
while (!vStop) {

}
System.out.printf("Thread %s is end...\n", Thread.currentThread().getName());
});
t1.start();
Thread.sleep(1000);

Thread t2 = new Thread(() -> {
System.out.printf("Thread %s is running...\n", Thread.currentThread().getName());
vStop = true;
System.out.printf("Thread %s is end...\n", Thread.currentThread().getName());
});
t2.start();
}

执行结果:

1
2
3
4
Thread Thread-0 is running...
Thread Thread-1 is running...
Thread Thread-1 is end...
Thread Thread-0 is end...

可以看到两个线程都正常退出了,因此通过volatile可以保证线程的可见性

volatile可见行原理

volatile关键字的语义:被volatile修饰的共享变量在多个线程之间是可见的,当一个线程修改volatile修饰的属性时,它会将当前线程在CPU缓存区中该属性的值刷新回主内存,而在读取共享属性时,会将读取线程的工作内存中缓存的共享变量置为无效,同时重新加载主内存的值。
实现原理:在执行写操作时,jvm会在写操作的汇编指令上加上lock指令

  1. 该指令将使当前处理器中的缓存行的所有数据会写进主内存

  2. 在写入主内存后将其它处理器中该共享变量的缓存失效

在写入的过程中,lock指令会对共享变量的缓存区域和主内存区域加锁,避免多个写入操作,而在写入完主内存后根据缓存一致性原理,将使其它处理器中的缓存失效。

CAS操作的原理

CAS全称是Compare and Set,即比较并赋值,虽然这是两个操作,但是在底层操作系统中,这是一个原子不可分割的操作,这也就体现了我们并发时候的原子性。在进行CAS操作的时候需要传入三个参数,分别是目标对象在内存中的地址,目标对象的期望值(期望值不一定是实际内存值,因此CAS操作可能失败),需要更新的值。在进行CAS操作的时候,当前线程首先根据内存地址获取到目标对象的值,同时与期望值进行比较,如果一致,则对其进行更新操作,更新为我们需要更新的值;如果不一致,说明在获取目标对象值之后有其它线程修改了目标对象,那么不需要进行任何操作。

CAS流程图:
CAS操作.png

在比较失败的时候,有些场景可能出现自旋,当前线程并不是立刻结束退出,而是自己循环一段时间,每次循环重新从内存中更新期望值,然后再次比较。

volatile和CAS是如何作为JUC的基础


Question:

  1. 关于CAS,如果CAS是原子的,为什么还会出现比较期望值时出现不一致的情况?
文章作者: xiexipeng
文章链接: http://xiexipeng.github.io/2019/07/08/有关volatile和CAS在JUC中的理解/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 xiexipeng
打赏
  • 微信
  • 支付寶