volatile关键字原理解析与实例讲解
volatile是Java虚拟机提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块和volatile 变量,相比于synchronized (synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。
要想深入了解volatile关键字,首先要熟悉一下并发编程的基本概念:
1、原子性
原子性指的是在程序执行的过程中,一个操作或多个操作,要么全部执行并且执行的过程中不会被任何因素打断,要么全部不执行。Java中的原子性操作包括:
-
基本类型的读取和赋值操作,且赋值必须是数字赋值给变量,变量之间的相互赋值不是原子性操作。
-
所有引用reference的赋值操作
-
java.concurrent.Atomic.* 包中所有类的一切操作
2、可见性
可见性指的是当多个线程同时访问一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3、有序性
有序性是指程序执行的顺序按照代码的顺序执行。在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
JMM(Java Memory Model)内存模型以及共享变量的可见性
JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
JMM本身是一种抽象概念并不真实存在。它的简要访问过程如下:
对于普通的共享变量来讲,线程A将其修改为某个值发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。解决这种共享变量在多线程模型中的不可见性问题,较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,比较合理的方式其实就是volatile。JMM关于同步的规定:
-
线程解锁前,必须把共享变量的值刷新回主内存
-
线程加锁前,必须读取主内存的最新值到自己的工作内存
-
加锁解锁是同一把锁
锁的互斥与可见性
锁主要提供了两种特性,互斥(mutual exclusion)和可见性(visibility)。互斥是指一次只允许一个线程持有某个特定的锁,一次只有一个线程能够共享数据。可见性是指多个线程同时执行时,当一条数据修改了共享变量的值,新值对于其他线程来说是可以立即得知的。如果没有这种同步机制,将会引发许多严重的问题。要使volatile变量提供理想的线程安全,必须满足下面两个条件:
-
对变量的写操作不依赖于当前值
-
该变量没有包含在具有其他变量的不变式中
上面的内容是基础,下面开始划划划重点了,小伙伴们集中注意力哦
为了方便下面的代码演示,我们先定义一个公共类MyData:
class MyData{
volatile int data = 0;
volatile int number = 0;
public void add(){
this.data = 60;
}
public void addPlus(){
number++;
}
AtomicInteger atomicInteger = new AtomicInteger();
public void addMyatomic(){
atomicInteger.incrementAndGet();
}
}
volatile变量的特性
1、保证可见性
-
当写一个volatile变量时,JMM会把本地内存中的变量强制刷新到主内存中。
-
这个写操作会导致其他线程中的volatile变量缓存无效,其他线程只能重新到主内存中读取数据。
可见性代码演示如下:
/**
* Volatile 可以保证可见性
* 及时通知其他线程,主物理内存的值已经被修改
*/
private static void seeOKByVolatile() {
MyData myData = new MyData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"\t come in");
myData.add(); System.out.println(Thread.currentThread().getName()+"\t update number value:" + myData.data);
},"A").start();
while (myData.data == 0){
//data 为0 一直执行该循环
}
System.out.println(Thread.currentThread().getName()+"\t get number value:" + myData.data);
}
当MyData中的data变量没有用volatile修饰时,A线程调用add()方法将number值更新为60后,未将新的number值刷新入主内存,所以number值的更新对main线程是不可见的,导致main线程中的number值为0。
MyData中的data变量用volatile修饰时,任意线程对该变量所做的更改操作均会刷新进入主内存,其他线程本地内存中的该变量失效,需要重新从主内存中获取。
2、不保证原子性
我们知道number++操作不是原子性操作,此操作共分为三步,首先线程从主内存中取数据到本地内存,然后执行增1操作,最后再把新值更新入主内存。假设现在有线程A和线程B同时对number变量执行增1操作,当线程A从主内存中取出number值后,线程B也从主内存中取出number值,此时线程A,B各自在自己的本地内存执行增1操作,更新到主内存,可是主内存中的值只增1,这与我们期望的结果就是不一样的了。
number++在多线程下是非线程安全的,如何不加synchronized或者Lock这些重量级锁解决,我们可以用java.util.concurrent.*包下的类。
不保证原子性代码如下:
/**
* 证明volatile不保证原子性
*/
private static void atomic() {
MyData myData = new MyData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addPlus();
myData.addMyatomic();
}
},"A").start();
}
while(Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"main number:"+myData.number);
System.out.println(Thread.currentThread().getName()+" atomicInteger number:"+myData.atomicInteger);
}
上述程序执行完毕打印出的结果应该是20000,addPlus()方法我们用的是number++,执行之后打印的 结果总是小于20000的,由此可知volatile并不能保证原子性;而addMyatomic()操作底层是由CAS来实现的,有现成的方法来保证我们操作的原子性(CAS是什么东东,我们下篇文章详细剖析),JUC包下的类大多数都用到了CAS。
3、禁止指令重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:
-
重排序操作不会对存在数据依赖关系的操作进行重排序。
-
重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。
指令重排代码演示如下:
public class VolatileDemo {
static int a = 0;
static boolean flag = false;
public static void method01(){
a = 2;
flag = true;
}
//多线程环境中线程交替执行,由于编译器优化重排的存在
//两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测
public static void method02(){
if (flag){
a = a+5;
System.out.println("aaaaaaaaaaaa:"+a);
}else {
System.out.println("666");
}
}
}
volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。先了解一个概念,内存屏障(Memory Barrier)又称为内存栅栏,是一个CPU指令,它的作用有两个:
-
保证特定操作的执行顺序。
-
保证某些变量的内存可见性(利用volatile实现)。
通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障的另一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。对volatile变量进行写操作时:会在写操作后加入一条store屏障指令,将工作内存中的共享变量刷新回到主内存:
对volatile进行读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量:
你在哪里用过volatile——单例模式
public class TestInstance{
private volatile static TestInstance instance;
public static TestInstance getInstance(){ //1
if(instance == null){ //2
synchronized(TestInstance.class){ //3
if(instance == null){ //4
instance = new TestInstance(); //5
}
}
}
return instance; //6
}
}
需要volatile关键字的原因是,在并发情况下,如果没有volatile关键字,在第5行会出现问题。instance = new TestInstance();可以分解为3行伪代码:
a. memory = allocate() //分配内存
b. ctorInstanc(memory) //初始化对象
c. instance = memory //设置instance指向刚分配的地址
上面的代码在编译运行时,可能会出现重排序从a-b-c排序为a-c-b。在多线程的情况下会出现以下问题。当线程A在执行第5行代码时,B线程进来执行到第2行代码。假设此时A执行的过程中发生了指令重排序,即先执行了a和c,没有执行b。那么由于A线程执行了c导致instance指向了一段地址,所以B线程判断instance不为null,会直接跳到第6行并返回一个未初始化的对象。