volatile 详解
Ⅰ volatile 关键定义
首先要清楚 volatile 的定义:
volatile是Java虚拟机提供的轻量级的同步机制。
它一共有三大特性:
1. 保证可见性;
2. 不保证原子性;
3. 禁止指令重排。
这就是关于 volatile 必须必须要明确的四句话,说它轻量级,可以理解成一个乞丐版的 synchronized
。 接下来的文章,我们就来通过代码的方式验证 volatile 的三大特性,明确好它定义的含义。
Ⅱ volatile 特性详解与验证
A. 可见性
在解释清楚 volatile 之前,我们有必要先明确好 JMM(Java 内存模型)是什么,它的特性又是什么。
JMM(Java Memory Module)本身是一种抽象的概念,并不真实存在。它描述的是一组规范或者规则,通过这组规则定义了程序中各个变量的访问方式。
它也有三大特性: 可见性,原子性,有序性。
并且JMM 中关于同步,有以下三个规定:
- 线程解锁前,必须把共享变量的值刷新回主内存;
- 线程加锁前,必须读取主内存的最新值到自己的工作内存;
- 加锁解锁是同一把锁。
这里面一共出现了两个比较麻烦的词,一个是主内存, 一个是工作内存,我们来看一下它们都是什么。
先来看一段话:
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(栈空间),工作内存是每个线程的私有数据区域,而 Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但是线程对变量的操作(读、赋值等)都必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存, 不能直接对主内存中的变量进行操作。各个线程中的工作内存中存储着主内存中的变量拷贝副本,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
这点非常好理解,变量存储在主内存中,第一个线程要修改这个变量,必须把这个变量先复制出来,到自己的工作内存中,然后在工作内存中修改,最后将修改好的结果写回到主内存中,这样原来的值就被现在新写的值替代了,再有其他线程来读这个数据,得到的就是新写的这个值,这就是JMM的可见性。
接着我们来验证一下,volatile 是否真的保证了 JMM 的可见性。
先来看看没有 volatile
的情况。
我们先来写一个线程操作资源类,add()
方法会将 num
值改为 710.
主函数调用也很简单,直接 new 一个线程,命名为 A,它会调用资源类的 add()
方法,修改 num
值。在线程启动时会输出一个 come in,然后等待3秒,再修改num
值,修改完成后再输出一个 update number
。
紧接着,我们写一个主线程也就是main线程要做的工作。
就是一个空循环,如果线程A在修改完num之后,主线程发现了num值被更改,就会跳出循环,输出下面的一行话。
结果是主线程一直结束不了,因为在循环中出不来,可见线程与线程之间的工作的独立的,A线程已经做完了工作,但是无法通知主线程,主线程就只能傻傻地等在那里。所以线程与线程之间是不可见的…
好,现在我们加上 volatile 看看会怎么样。
注意,这里我只做了这一个修改。
主线程就从循环中跳出来了,这就证明了 volatile 的保证可见性,一个线程对主内存的变量做了更改,另一个线程立马就可以收到。
package com.tyz.juc.volatileDemo;
import java.util.concurrent.TimeUnit;
class MyData {
volatile int num = 0;
// 没有可见性
// int num = 0;
public void add() {
this.num = 710;
}
}
/**
* @author tyz
*/
@SuppressWarnings("AlibabaAvoidManuallyCreateThread")
public class VolatileVisibility {
public static void main(String[] args) {
MyData myData = new MyData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.add();
System.out.println(Thread.currentThread().getName() + "\t update number" + myData.num);
}, "A").start();
while (myData.num == 0) {
//main 线程在这里等待,只有当 A 线程把num值修改了,主线程才会跳出循环
}
System.out.println(Thread.currentThread().getName() + "\t see the update number");
}
}
B. 不保证原子性
a. 关于原子性的验证
在前面的线程操作资源类中,我再添加一个方法,就是当前值加1。
在主函数里,我们写二十个线程,每个线程都对调用一千次它。
按照我们想要的结果,应该最终num为20000,但是看最后的结果。
加了 volatile
,num 并没有被加两万次。所以可以知道,num++
这个操作在多线程下是非线程安全的,也证明了 volatile
并不能保证原子性。
那为什么会发生这种事呢?
前面说JMM的时我们说了线程要操作变量,需要把主内存中的变量拷贝到自己的工作内存中,操作完再重新写回到内存中。
假设线程1、2、3同时拿到主内存中最初的0, 然后各自加1,这时候线程1正要写到主内存中,突然被挂起了,轮到了线程2,这时候还未来得及通知其他线程,线程1活了,直接把它加的值覆盖了上去,本来线程1和线程2应该是加了两次,但是这样就只加了一次,最后的状况就是因为发生了很多次这个事,所以最终num并没有被加两万次,有好多次增加被覆盖掉了。
再进一步,为什么线程可以在要把值写回主内存时突然被挂起呢?我们来看一下 num++
的字节码。
可以看到,要执行一个一行的 num++
操作,在字节码中需要四条语句,线程就是在最后一条 putfield
,也就是将加了的数据写回给主内存时被挂起了,所以才会出现上面的情况。
不保证原子性,就可能发生写丢失的情况。
b. 原子性问题的解决
① synchronized
第一个比较简单能想到的方法当然就是加 synchronized
。
这样肯定能得到正确的数据了,但是这相当于是杀鸡用牛刀,synchronized
是一个很重的锁,没必要用来解决加1这种问题,
② AtomicInteger
AtomicInteger
是 JUC 下的一个类,我们可以扫一眼它的api
getAndIncrement
就当是 num++
,而incrementAndGet
就相当于是 ++num
,它的api都比较简单,大家可以去瞄一眼。这里我们再用它写一个加1的方法。
AtomicInteger
无参构造默认是0,所以和num
是一样的。
用相同的流程执行这两个方法,我们来看一下输出结果。
可以看到 AtomicInteger
执行了正确的结果。
关于 AtomicInteger
的底层原理 CAS,我会在下一篇文章中写清楚。
C. 禁止指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分以下三步。
处理器在重排的时候必须要考虑指令之间的数据依赖性。
单线程环境中,指令重排是绝对没有问题的,程序最终执行结果和代码顺序执行结果一样。
但是多线程环境中,由于线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
我用一张图来说明一下指令重排可能会在多线程环境中导致的问题。
所以编译器的指令重排是要看具体情况的,有时候不该优化的优化了,就有可能导致上图中的数据不一致的问题。
比如下面这个case
由于method1
中 a = 1
和 flag = true
之间是不存在依赖关系的,指令重排后有可能先赋值的 flag
,这时候有的线程还没有等到 a = 1
可能就执行了method2
,在 a = 0
的时候执行了加 5 的操作。
所以多线程环境下,指令重排后的结果是不确定的。volatile
就可以防止这种不确定性,用它修饰变量,就可以避免被指令重排导致结果不确定。
volatile
之所以能做到禁止指令重排,是因为在指令中插入了内存屏障(Memory Barrier)。
内存屏障又称为内存栅栏,是一个CPU指令,它的作用一共有两个。
- 保证特定操作的执行顺序;
- 保证某些变量的内存可见性。(利用该特性实现volatile内存可见性)
如果在指令间插入一条 Memory Barrier 则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是通过插入内存屏障禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
Ⅲ 单例模式
A. 普通单例的问题所在
我们先来写一下单线程环境下经常写的一种经典的单例。
这里我在构造方法中写一句话,我们知道如,如果是单例模式的话,构造方法必然只会执行一次,这句话也就只会被打印一次。
在主函数中,我直接调用它六次,如果是单例的话,这三个判断一定都是true,因为得到的都是一个对象。
运行一下,结果并没有错,并且构造方法确实只执行了一次。
好,现在我们用多线程获取一下单例试试。
一共就十个线程,结果执行了五次单例的构造方法,这是不是问题就很严重了?
要解决这个问题,当然还是可以直接加synchronized
。
可以,但是没有必要,因为我们前面已经说过了,没必要为了一个简单的方法把整个代码块都锁上,这样并发性会大大降低。
B. 高并发下的单例——DCL
DCL(Double Check Lock),双端检锁机制。
DCL非常简单,我们说synchronized
加在方法外面直接解决问题,但是会导致并发性下降,现在我们看看DCL下的synchronized
该怎么用。
这样看貌似问题已经解决了,通过两次判断,很好地实现了单例,从运行结果上看也是对的。但是这样就可以了吗?
在前面我们说了编译器的指令重排,要知道,上面的代码是有可能被编译器优化进行重新排序的,所以DCL看上去好像非常完美,但是还是有漏洞,会造成线程安全问题。
我用一个伪代码来表示初始化对象instance = new Singleton()
的过程:
1. memory = allocate(); /分配对象内存空间
2. instance(memory); /初始化对象
3. instance = memory; /设置instance指向刚分配的内存地址,此时instance!=null
步骤2 和 步骤3 不存在依赖关系,无论是重排前还是重排后,程序的执行结果在单线程环境下是没有变化的,所以这种重排是被允许的。
但是在多线程环境下,如果调换了 2、3 的顺序,明明instance引用的对象还没有初始化,但是instance已经指向了该对象,判断出 instance != null,这样就会造成线程安全问题,因为返回的 instance
空有一个地址,但是是没有对象的。
因而,我们必须要用 volatile
修饰 instance
。最终高并发下的单例模式代码就是下面这样:
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}