当前位置:网站首页>Volatile和CAS
Volatile和CAS
2022-08-11 00:04:00 【wyplj_sir】
文章目录
在代码规范中,有一条规范是“ static 和 synchronized不应双重检查锁”
错误的双重检查锁
先回顾一下单例模式,单例模式是指:一个类有且仅有一个实例,并且自行实例化向整个系统提供。单例模式通常分为饿汉式和懒汉式。
饿汉式:
饿汉式在类创建的同时就已经创建好一个静态的对象供系统使用,以后不再改变,所以天生是线程安全的。
//饿汉式
public class Singleton {
//构造函数为private
private Singleton() {
}
//有一个 private static final的变量,在类初始化时实例化
private static final Singleton instance = new Singleton();
//通过public static 的方法获得变量引用
public static Singleton getInstance() {
return instance;
}
}
懒汉式:
懒汉式在第一次调用时实例化。
//懒汉式(非线程安全)
class Singleton2{
//构造函数为private
private Singleton2() {
}
private static Singleton2 instance;
public static Singleton2 getInstance() {
if (null == instance) {
instance = new Singleton2();
}
return instance;
}
}
不考虑多线程的情况,上述代码是ok的,但如果存在多线程的情况,上述代码就可能会生成多个实例。例如:
在这个例子中,instance会被重复初始化,生成两个实例。
加锁
在这种情况下,一般可以选择加锁的方式来解决,如下:
class Singleton3{
//构造函数为private
private Singleton3() {
}
private static Singleton3 instance;
public synchronized static Singleton3 getInstance() {
if (null == instance) {
instance = new Singleton3();
}
return instance;
}
}
加了synchronized之后,重复初始化的问题被解决了,但也带来了性能开销的问题。在JDK1.5及之前的版本中,是不推荐使用synchronized来实现线程同步的。因为synchronized是一种重量级锁,底层是通过监视器对象来实现的,依赖于操作系统的互斥锁,操作系统需要在用户态和内核态之间进行切换,影响效率。
双重检查锁
为了优化加锁带来的性能开销,可以使用双重检查锁,如下:
class Singleton4{
//构造函数为private
private Singleton4() {
}
private static Singleton4 instance;
public static Singleton4 getInstance (){
if (null == instance) {
synchronized (Singleton4.class) {
if (null == instance) {
// 实例化对象
instance = new Singleton4();
}
}
}
return instance;
}
}
这种方法是在获取锁之前,检查对象是否为空,为空再去获取锁,获取成功之后,再次检查对象是否为空,不为空的话进行初始化操作。
在双重检查的情况下,可以避免每次都进行获取锁和释放锁带来的额外的性能开销,也可以避免重复初始化的问题。例如:
但这种方法存在着一个问题,实例化对象的操作并不是一个原子操作,会被编译器编译为三条指令:
- 为对象分配内存空间
- 初始化对象
- 将对象指向分配的内存空间
通常,编译器和处理器为了提高运行效率会进行指令重排序,都遵循as-if-serial语义。as-if-serial语义是指,不论编译器和处理器对指令怎么进行重排序,程序的执行结果都不能被改变。这里的执行结果不变是指对单线程程序而言。如果是多线程的话,可能出现意想不到的结果。
对于实例化对象的操作,可能会被重排序为:
- 为对象分配内存空间
- 将对象指向分配的内存空间(此时对象不为空)
- 初始化对象
此时可能会出现一下情况:
此时为什么static 和 synchronized不应双重检查锁的困惑已经解开。
volatile
这个问题的关键在于指令重排序,禁止指令重排序即可解决,因此可以使用关键字volatile。
class Singleton5{
//构造函数为private
private Singleton5() {
}
//使用volatile
private volatile static Singleton5 instance;
public static Singleton5 getInstance (){
if (null == instance) {
synchronized (Singleton5.class) {
if (null == instance) {
// 实例化对象
instance = new Singleton5();
}
}
}
return instance;
}
}
volatile
什么是重排序
在执行程序时,为了提高性能,编译器和处理器通常会对指令进行重排序。这些重排序一般遵循as-if-serial语义和happens-before规则。
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。
数据依赖性:
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。(写后读、写后写、读后写)
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
happens-before规则仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。(并不意味着前一个操作必须要在后一个操作之前执行)
与程序员密切相关的happens-before规则如下:
1.程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3.volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
4.传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
Java内存模型
要理解volatile,首先要了解JMM,如下图:
主内存:被所有的线程所共享,对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。直接操作主内存速度太慢,因此使用了性能较高的工作内存。
工作内存:每一个线程拥有自己的工作内存(逻辑概念,非物理概念),对于一个共享变量来说,工作内存当中存储了它的“副本”。线程对共享变量的所有操作都必须在工作内存进行,不能直接读写主内存中的变量。不同线程之间也无法访问彼此的工作内存,变量值的传递只能通过主内存来进行。
对于变量 static int a = 0
,如果线程A对其进行 a = 3
的操作,流程如下:
假设线程B要读取a的值且在线程A之后执行,那么它读到的是0还是3呢?
都有可能。如果线程B在线程A的第三步之前读取,读到的是0,如果在线程A的第三步之后读取,读到的是3。
如果变量a使用volatile修饰, volatile static int a = 0 ,线程B读到的一定是3。因为volatile会要求线程B在线程A的写完成之后才能读取。
volatile禁止重排序
针对volatile变量,Java内存模型(JMM)制定了相关的重排序规则,简单总结为三条:
- 当第二个操作是volatile写时,无论第一个操作是什么,都不能重排序,保证volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,无论第二个操作是什么,都不能重排序,保证volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个是volatile读时,不能重排序。
上面的例子命中了第三条规则。
volatile写时
当发生volatile写的时候,JMM会做两件事:
- 将该线程对应的本地内存中的共享变量的值刷新到主内存中。(volatile变量所在的缓存行的所有共享变量,不止是volatile变量)
- 将其他线程本地内存中对应的缓存置为无效,下次访问相同内存地址时,将强制执行缓存行填充。
对于第一条规则,当第二个操作是volatile写时,如果进行了重排序操作,会导致其他线程本地内存中的缓存行无效,无法保证volatile写之前的共享变量数据的一致。举个例子:
volatile int a = 0;
int b = 1;
public void A (){
b = 2; // 1 普通写
a = 1; // 2 volatile写
}
public void B() {
int c = 0;
if (a == 1) // 3 volatile读
c = b; // 4 普通写
}
假如方法A先执行,若方法B能读到a=1,则c应该为2;若方法B读不到a=1,则c应该为0。
在这段代码中,存在以下happens-before关系:
根据程序顺序原则,代码1的执行结果对代码2可见,代码3的执行结果对代码4可见;根据volatile语义,代码2的执行结果对代码3可见;根据happens-before的传递性,代码1的执行结果应该对代码4可见。因此,我们期望得到的c值为2。
而代码1和代码2之间不存在数据依赖,假如volatile允许重排序的话,代码2先执行,由于a是volatile变量,所以会将a = 1, b = 1刷新进入主内存;此时线程A的cpu时间片用完了,轮到了线程B执行方法B,由于a是volatile变量所以代码3处执行的时候会将b = 1, a = 1从主内存中读出,代码4再执行的话c会变为1,而不是期望的2。
volatile读时
当发生volatile读时,JMM会做两件事:
- 将该线程本地内存中的缓存行置为无效。
- 从主内存中读取共享变量。
对于第二条规则,当第一个操作是volatile读时,会使缓存行中的普通共享变量也从主内存中重新获取,如果进行了重排序操作,无法保证这些数据一致。继续以前面的代码为例:
volatile int a = 0;
int b = 1;
public void B() {
int c = 0; //非共享变量
if (a == 1) // 1 volatile读
c = b; // 2 普通写
}
public void A (){
b = 2; // 3 普通写
a = 1; // 4 volatile写
}
这次假如线程B(方法B)先执行,线程A(方法A)后执行,正常情况下,语句1会返回false,最终的c值应当为0。
但语句1和语句2之间没有数据依赖关系,假如volatile允许重排序的话,代码2先执行,会将c(非共享变量)赋值为1并写到缓冲区。此时线程B的cpu时间片用完了,轮到线程A执行。线程A执行后,会将a=1,b=2的结果刷新到主存中,并将线程B本地缓存中的a和b所在缓存行置为无效。再次轮转到线程B执行时,执行语句1,会从主存中重新读取共享变量a,此时读到a为1,语句1返回结果为true,语句2之前的执行结果1会生效,这个1既不是我们期望的0,也不是当前b的最新值。
volatile使用场景
使用volatile就能保证线程安全了吗?如下例:
public volatile static int count = 0;
@Test
public void Test() throws InterruptedException {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++){
// 非原子操作
count++;
}
}
});
thread.start();
}
// 主线程+回收线程有两个,如果大于两个,说明上面线程还有执行完
while (Thread.activeCount() > 2) {
Thread.sleep(10);
}
System.out.println(count);
}
期望的输出结果是10000,但实际上每次运行的结果可能都不一样。(运行了10次,结果都不是10000)
可见,volatile只能保证可见性,不能保证原子性。因此,使用volatile的场景为:
1、对变量的写操作不依赖于当前值
例如上述示例。
2、该变量没有包含在具有其他变量的不变式中(这句话我也不是很理解,看例子把)
例如:一个非线程安全的数值范围类,它包含了一个不变式 —— 下界总是小于或等于上界,代码如下:
public class NumberRange {
private volatile int lower;
private volatile int upper;
public int getLower() {
return lower; }
public int getUpper() {
return upper; }
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;而仍然需要使用同步——使setLower() 和 setUpper() 操作原子化。
否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。
例如,如果初始状态是(0, 5),同一时间内,线程 A 调用setLower(4) 并且线程 B 调用setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是(4, 3) ,产生一个无效值。
CAS
原子操作类
前面提到,volatile不能保证线程安全,如果想要得到预期的10000,可以使用synchronized关键字,只需要在count++ 之前使用 synchronized (Test.class) 即可。
加上同步锁之后, count++ 就变成了原子性操作,代码实现了线程安全。但在某些情况下,synchronized不是最佳选择,它会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。
尽管Synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能仍然较低。
java.util.concurrent.atomic 包中提供了一系列原子操作类,其中 AtomicInteger 对 int 进行了封装,提供原子性的访问和更新操作,如下例:
public void CASTest1() throws InterruptedException {
AtomicInteger testCAS = new AtomicInteger(0);
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++){
testCAS.getAndIncrement();
}
}
});
thread.start();
}
while (Thread.activeCount() > 2) {
Thread.sleep(10);
}
System.out.println(testCAS.get());
}
运行结果符合预期:
可以看到,在这种情况下, AtomicInteger 达到了和synchronized一样的效果,且很多时候性能优于synchronized。
而 AtomicInteger 底层是通过CAS来保证原子性的。
CAS
CAS全称Compare And Set(或Compare And Swap),CAS包含三个操作数:内存位置(V)、原值(A)、新值(B)。简单来说CAS操作就是一个虚拟机实现的原子操作,这个原子操作的功能就是将旧值(A)替换为新值(B),如果旧值(A)未被改变,则替换成功,如果旧值(A)已经被改变则什么都不做。进入一个自旋操作,即不断的重试。
如下图:
以前面的自增操作为例,Java1.8之前,利用CAS实现原子性的方式如下:
private volatile int value;
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
通过compareAndSet将变量自增,如果自增成功则完成操作,如果自增不成功,则自旋进行下一次自增,由于value变量是volatile修饰的,通过volatile的可见性,每次get()都能获取到最新值,这样就保证了自增操作每次自旋一定次数之后一定会成功。
compareAndSet利用JNI(JAVA本地调用,允许java调用其他语言)来完成CPU指令的操作:
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
Java1.8中直接将getAndAddInt方法直接封装成了原子性的操作,更加方便使用:
public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); }
在这段代码中,涉及到两个重要的对象:unsafe和valueOffset。
unsafe:Java语言不像C,C++那样可以直接访问底层操作系统,但是JVM为我们提供了一个后门,这个后门就是unsafe。unsafe为我们提供了硬件级别的原子操作。
valueOffset:是通过unsafe.objectFieldOffset方法得到,所代表的是AtomicInteger对象value成员变量在内存中的偏移量。我们可以简单地把valueOffset理解为value变量的内存地址。
unsafe的compareAndSwapInt方法参数包括CAS的三个基本元素:valueOffset参数代表了V,expect参数代表了A,update参数代表了B。
CAS的缺陷
1、ABA问题
问题:
因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。
解决方法:
ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2、循环开销过大
问题:
前面说过,如果旧值(A)已经被改变,就会进入自旋操作。
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。例如,Unsafe下的getAndAddInt方法会一直循环,直到成功才会返回。
解决方案:
如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
3、只能保证一个共享变量的原子操作
问题:
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。
解决方案;
可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
边栏推荐
- SQL注入基础---order by \ limit \ 宽字节注入
- 15. Interceptor - HandlerInterceptor
- 【pypdf2】安装、读取和保存、访问页面、获取文本、读写元数据、加密解密
- 【考虫 六级英语】语法课笔记
- Easy-to-use translation plug-in - one-click automatic translation plug-in software
- Server Tips
- sqlmap combined with dnslog fast injection
- The Missing Semester of Your CS Education
- Which foreign language journals and conferences can be submitted for software engineering/system software/programming language?
- 两个链表的第一个公共节点——LeetCode
猜你喜欢
随机推荐
Go项目配置管理神器之viper使用详解
Jvm.分析工具(jconsole,jvisualvm,arthas,jprofiler,mat)
ROS Experiment Notes - Validation of UZH-FPV Dataset
YOLOv5的Tricks | 【Trick11】在线模型训练可视化工具wandb(Weights & Biases)
How to quickly grasp industry opportunities and introduce new ones more efficiently is an important proposition
Part of the reserve bank is out of date
编程语言为什么有变量类型这个概念?
"NIO Cup" 2022 Nioke Summer Multi-School Training Camp 4 ADHK Problem Solving
14. Thymeleaf
Starting a new journey - Mr. Maple Leaf's first blog
“蔚来杯“2022牛客暑期多校训练营4 ADHK题解
3. 容器功能
[C language] Implementation of guessing number game
图像识别和语义分割的区别
[Excel知识技能] 将数值格式数字转换为文本格式
Software Testing Certificate (1) - Software Evaluator
软件测试证书(1)—— 软件评测师
SQL injection base
UOJ#749-[UNR #6]稳健型选手【贪心,分治,主席树】
镜头之滤光片---关于日夜两用双通滤光片