volatile
volatile
是JVM提供的一个轻量级的同步机制,除了能够“避免JVM对long/double的误操作”外,还有以下两个作用:
-
volatile
修饰的变量可以对所有线程立即可见
不同的线程如果要访问同一个变量,就必须借助主内存进行传递。但是如果给变量加了votatile
关键字,则该变量的值就可以被所有线程立即感知。
-
volatile
可以禁止指令“重排序”优化
在理解重排序之前,有必要了解一下“原子性”,因为重排序的对象必须是原子性的语句。但是在Java中,并不是所有语句都是原子性的。例如,如果已经存在变量num
,那么对num = 10
是一个原子性操作;但是如果不存在age
,声明并赋值age
的语句int age = 23
就不是原子操作。该语句会被分为以下两条语句执行:
int age;
age = 23;
重排序是指JVM为了提高执行效率,会对编写的代码进行一些额外的优化。例如,会对已经写完的代码指令,重新进行排序。重排序的的优化不会影响单线程执行的结果。
int height = 10;
int width;
width = 20;
int area = height * width;
因为重排序不会影响单线程程序的执行结果。因此以上代码的实际执行顺序可以是1、2、3、4或者2、3、1、4或者2、1、3、4的结果都是相同的。
懒汉式单例模式
了解了原子性和重排序之后,我们看看双重检查方式的懒汉式单例模式。
public class Singleton{
private static Singleton instance = null; // 多线程共享的instance
private Singleton(){}
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
return instance;
}
}
}
上面的代码的第8行也不是一个原子性操作,JVM会在执行时将这条语句大致拆分为3步:
-
分配内存地址、内存空间
-
使用构造方法实例化对象
-
将分配好的内存地址赋值给
instance
由于重排序的存在,第8行的内部执行顺序可能是1、2、3,也可能是1、3、2。如果是后者,当一个线程正在执行第8行,具体情况是执行完3,但是还没执行2(即Instance虽然已被赋值,但是还没实例化),另一个线程Y正好抢占了CPU并且执行到第5行,判断Instance
不为null
,因此线程Y会直接返回instance
对象,但是Instance
是还没有实例化的对象,所以后续使用Instance
就会出错。
为了避免这种JVM重排序而造成的问题,我们就可以给Instance
加上volatile
关键字,如下:
private volatile static Singleton instance = null;
经过这样的修改,就算真正意义上实现了单例模式。
实际上,volatile
关键字是通过“内存屏障”来防止指令重排序的,具体的实现步骤如下:
-
在
volatile
写操作前,插入一个StoreStore
屏障; -
在
volatile
写操作后,插入一个StoreLoad
屏障; -
在
volatile
读操作前,插入一个LoadLoad
屏障; -
在
volatile
读操作后,插入一个LoadStrore
屏障;
此外,要特别注意,虽然volatile
修饰的变量具有原子可见性,但是并不具备原子性,因此volatile
不是线程安全的。要理解这一点,就得明确区分原子性和重排序的概念。
-
原子性是指某一条语句JVM不可再拆分执行;
-
重排序是指某一条语句内部的多个指令在不影响结果的前提下,可以进行的重新排序。
下面的代码可以说明volatile
的非线程安全的问题。
/**
* @Author: Wangb
* @EMail: 1149984363@qq.com
* @Date: 23/12/2021 上午9:16
* @Description
*/
public class TestVolatile {
public static volatile int num = 0;
@SuppressWarnings("AlibabaAvoidManuallyCreateThread")
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
for (int j = 0; j < 20000; j++) {
num++;//num++不是一个原子性操作
}
}).start();
}
//休眠3秒,确保创建的100个线程都已执行完毕
Thread.sleep(3000);
System.out.println(num);
}
}
当开始num=0
时,创建了100个线程,并且每个线程都会执行20000次num++
。因此如果volatile
线程安全的,打印的结果应该是2000000,但是实际结果并非如此,如下图,由于多线程的资源抢占,每次执行的结果都不一样:
从运行结果就可以发现,volatile
并不能将修饰的num
设置为原子性操作,这会造成num++
被多个线程同时执行,最终导致出现漏加的线程不安全的情况,即结果远小于20000000.
如果要将上面的程序修改为线程安全的,我们可以使用java.util.concurreent.atomic
包中提供的原子类型。
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author: Wangb
* @EMail: 1149984363@qq.com
* @Date: 23/12/2021 上午9:51
* @Description
*/
public class TestVolatile_2 {
public static volatile AtomicInteger num = new AtomicInteger();
@SuppressWarnings("AlibabaAvoidManuallyCreateThread")
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
new Thread(() ->{
for (int j = 0; j < 20000; j++) {
//num自增
num.getAndIncrement();
}
}).start();
}
Thread.sleep(3000);
System.out.println(num);
}
}
AtomicInteger
实现原子性的操作的关键是compareAndget()
,因为它实现了CAS算法,该算法 能够保证变量的原子性操作。
CAS算法
CAS全称为Compare And Swap即比较并交换,其实现方式是基于硬件平台的汇编指令,在intel的CPU中,使用的是cmpxchg
指令,也就是说CAS是靠硬件实现的,从而在硬件层面提升效率。其算法公式如下:
函数公式:CAS(V,E,N) V:表示要更新的变量 E:表示预期值 N:表示新值
CAS原理
如果V值等于E值,则将V的值设为N。若V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。通俗的理解就是CAS操作需要我们提供一个期望值,当期望值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行CAS操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作。
ABA问题: ABA问题是CAS中的一个漏洞。CAS的定义,当且仅当内存值V等于就得预期值A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。那么如果先将预期值A给成B,再改回A,那CAS操作就会误认为A的值从来没有被改变过,这时其他线程的CAS操作仍然能够成功,但是很明显是个漏洞,因为预期值A的值变化过了。如何解决这个异常现象?java并发包为了解决这个漏洞,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性,即在变量前面添加版本号,每次变量更新的时候都把版本号+1,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
了解CAS算法就得先了解,无锁的概念:
无锁分为以下两大派系:
**乐观派系:**它们认为事情总会往好的方向去发展,总是认为坏的情况发生概率特别小,可以无所顾忌的做任何事情.
**悲观派系:**它们总会认为发展事态如果不及时控制,以后就无法挽回,即时此种局面不会发生的情况下。上述两大派系映射到并发编程中就如同加锁与无锁策略,即加锁是一种悲观策略,无锁是一种乐观策略,因为对于加锁的并发程序来说,它们总是认为每次访问共享资源时总会发生冲突,因此必须对每一次数据操作实施加锁策略。而无锁则总是假设对共享资源的访问没有冲突,线程可以不停执行,无需加锁,无需等待,一旦发现冲突,无锁策略则采用一种称为CAS的技术来保证线程执行的安全性,这项CAS技术就是无锁策略实现的关键。
实现思想 在线程开启的时候,会从主存中给每个线程拷贝一个变量副本到线程各自的运行环境中,CAS算法中包含三个参数(V,E,N),V表示要更新的变量(也就是从主存中拷贝过来的值)、E表示预期的值、N表示新值。
实现过程 假如现在有两个线程t1,t2,,他们各自的运行环境中都有共享变量的副本V1、V2,预期值E1、E2,预期主存中的值还没有被改变,假设现在在并发环境,并且t1先拿到了执行权限,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次发起尝试,然后t1比较预期值E1和主存中的V,发现E1=V,说明预期值是正确的,执行N1=V1+1,并将N1的值传入主存。这时候贮存中的V=21,然后t2又紧接着拿到了执行权,比较E2和主存V的值,由于V已经被t1改为21,所以E2!=V,t2线程将主存中已经改变的值更新到自己的副本中,再发起重试;直到预期值等于主存中的值,说明没有别的线程对旧值进行修改,继续执行代码,退出;
CAS的优点: 这个算法相对synchronized是比较“乐观的”,它不会像synchronized一样,当一个线程访问共享数据的时候,别的线程都在阻塞。synchronized不管是否有线程冲突都会进行加锁。由于CAS是非阻塞的,它死锁问题天生免疫,并且线程间的相互影响也非常小,更重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,所以它要比锁的方式拥有更优越的性能。
CAS的缺点:
CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。
-
循环时间长、开销很大。
当某一方法比如:getAndAddInt执行时,如果CAS失败,会一直进行尝试。如果CAS长时间尝试但是一直不成功,可能会给CPU带来很大的开销。
-
只能保证一个共享变量的原子操作。
当操作1个共享变量时,我们可以使用循环CAS的方式来保证原子操作,但是操作多个共享变量时,循环CAS就无法保证操作的原子性,这个时候就需要用锁来保证原子性。
-
存在ABA问题