文章目录
一、volatile介绍
volatile牵扯的知识不少,我们慢慢来说。
volatile是Java虚拟机提供的轻量级同步机制,好比轻量级synchronized。它有如下特点:
- 能够保证可见性
- 禁止指令重排序
- 不能保证原子性(多线程下并不能保证线程安全)
要好好说道上面几点,我们需要先了解JMM(Java内存模型)
二、JMM(Java内存模型)
Java内存模型(即Java Memory Model——JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量值,以及在必须时,如何同步的访问共享变量。
- 其根本目的就是解决多线程情况下的线程安全问题
JMM的主存
主存是数据共享区域,线程在此获取数据
- 存储Java实例对象
- 存储成员变量、类信息、静态变量、常量等
JMM中的工作内存
- 每个线程都有一个属于自己的工作内存(JMM中是指cpu的寄存器和高速缓存)
- 存储当前方法的所有本地变量的信息,这些变量是主存中变量的副本,每个线程只能访问自己的工作内存,工作内存中的变量对其他线程不可见
- 也包括字节码行号指示器,Native方法信息
- 属于线程私有数据区域,不存在线程安全问题
主存是线程的共享数据区域,线程想要操作变量需要从主存中拷贝一份到自己的工作内存中,线程只能对工作内存进行操作。不同线程之间是无法直接通信,也就是说不能访问其他线程的工作空间,只能通过主内存来完成通信
JMM内存模型图
三、细说volatile的特点
3.1、可见性
volatile的可见性,什么是可见性呢?
- 可见性,即某个线程修改了某个变量的值,并把该值刷新到主内存,其他线程要能够知道该变量被修改了
- 而volatile的可见性则是,当前线程修改了volatile变量的值,刷新回主内存,其他线程要访问volatile变量时(比如自己的工作内存已经有volatile变量)但是要将工作内存中的volatile变量设置为无效,重新去主内存供读取,这样就保证了,volatile变量修改,其他线程能立马知道,并且采用新的值
- 有人会问,为什么另外一个线程就必须要在这个线程之后访问volatile变量。这个是因为happens-before中的volatile原则
接下来我们用一个实例进行测试
/**
* @Description:
* @Author: Mt.Li
* @Create: 2020-07-23 11:49
*/
public class VolatileVisibleDemo {
public static void main(String[] args) {
OperateData operateData = new OperateData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "开始执行======");
operateData.add();
System.out.println("线程调用OperateData中的add方法,对a进行加一操作======");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "Thread A").start();
while (operateData.a == 0){
}
System.out.println("a的值不为0,循环结束......");
}
static class OperateData{
int a = 0;
public void add(){
this.a += 1;
}
}
}
结果:
可以看到结果,一直没有结束,代表一直卡在死循环中,我们给a加上volatile试试
/**
* @Description:
* @Author: Mt.Li
* @Create: 2020-07-23 11:49
*/
public class VolatileVisibleDemo {
public static void main(String[] args) {
OperateData operateData = new OperateData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "开始执行======");
operateData.add();
System.out.println("线程调用OperateData中的add方法,对a进行加一操作======");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "Thread A").start();
while (operateData.a == 0){
}
System.out.println("a的值不为0,循环结束......");
}
static class OperateData{
volatile int a = 0;
public void add(){
this.a += 1;
}
}
}
结果主线程立马发现a改变,结束循环
3.2、volatile不能保证原子性
- 什么是原子性
- 原子性是不可分割的,完整的,即某个线程执行某个业务时,中间过程不能被加塞、被分割,必须整体完成,要么同时成功,要么同时失败
我们知道具备这样特性的有synchronized,或者使用通过JUC下的AtomicInteger来实现。我们先来测试下volatile能不能保证原子性(多线程)
/**
* @Description:
* @Author: Mt.Li
* @Create: 2020-07-23 12:20
*/
public class VolatileAtomicDemo {
public static void main(String[] args) {
OperatorData operatorData = new OperatorData();
for (int i = 0; i < 30; i++){
new Thread(()->{
for (int j = 0; j < 1000; j++){
operatorData.addNumber();
}
},"Thread-" + i).start();
}
while(Thread.activeCount() > 2){
// 让出CPU执行权,让自己或者同级的线程执行,让线程有更多的执行机会
Thread.yield();
}
System.out.println(operatorData.number);
}
static class OperatorData{
volatile int number = 0;
public void addNumber(){
this.number ++;
}
}
}
运行结果1:
运行结果2:
连续运行四五次,大部分是无法达到30000的,对于此原因,目前有如下解释:
线程操作有如下部分:
1、线程读取i
2、temp = i + 1
3、i = temp
某个时刻,A和B同时读取了i,假设i是5,然后都对其进行+1,并赋值给各自的temp,然后就是进行写操作,如果A先进行写操作,那么B线程要操作i,根据可见性,就必须设置工作空间中的i为无效,然后重新去主内存中读取,此时i经过A操作为6,但是B中的temp已经是6了,当前需要执行i = temp
,于是i还是为6,这样对于整体来说,少加了一次。
通过以上例子,我们可以知道volatile是不能保证原子性的,也就是说多线程情况下是存在线程安全的
解决原子性问题,我们可以使用之前说的synchronized实现(直接加在方法上),但是这样太耗费性能,当然这里三万次,是小数目,时间上感觉不出来。这时候就可以使用JUC下的AtomicInteger实现
加粗样式
3.3、volatile如何禁止重排优化的
首先我们要说说指令重排序要满足什么:
- 在单线程环境下不能改变程序运行的结果
- 若存在数据依赖的关系则不允许重排序
总结来说就是无法通过happens-before原则推导出来的,才能进行指令的重排序。A操作的结果需要对B操作可见,则A与B存在happens-before关系。
happens-before是判断数据是否存在竞争、线程是否安全的主要依据,用于解决并发环境下两个操作存在冲突的问题
happens-before八大原则
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
- 管程锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;(此处后面指时间的先后)
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;(此处后面指时间的先后)
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束Thread.isAlive()的返回值手段检测到线程已经终止执行;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
举例:
i = 1; // 让线程A执行
j = i; // 让线程B执行
结果大家肯定知道j = 1,请仔细想,如果是线程B先执行呢?线程B如何知道线程A的结果,这就是happens-before关系的作用,上边的例子中,两者是存在happens-before关系的,故,线程A的结果对线程B可见。
内存屏障(Memory Barrier)
内存屏障是一个CPU指令,volatile的可见性和禁止重排优化都离不开它,作用如下:
- 保证特定操作的执行顺序:通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
- 保证某些变量的内存可见性:强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本
3.4、经典问题——单例的双重检测实现
public class Singleton {
private static Singleton instance;
private Singleton(){};
public static Singleton getInstance() {
// 第一次检测
if (instance == null) {
// 同步
synchronized (Singleton.class) {
if(instance == null){ // 第二次检测
// 多线程下可能会出现问题的地方
instance = new Singleton();
}
}
}
return instance;
}
}
说这个之前先了解一下创建对象的过程
memory = allocate() // 1.分配对象内存空间
instance(memory) // 2.初始化对象
instance = memory; //3.设置instance指向刚才分配的内存地址,此时instance !=null;
我们可以知道,第二步和第三步不存在依赖关系,所以JVM可能对它们进行重排序,变成如下顺序
memory = allocate() // 1.分配对象内存空间
instance = memory; // 3.设置instance指向刚才分配的内存地址,此时instance !=null;但是对象没有初始化完成
instance(memory) // 2.初始化对象,这样排序不会影响单线程下的结果的
这样排序后可能造成线程A进行到instance = new Singleton();
但是还没有执行到第三步初始化,只是instance已经指向分配的对象地址,线程B这时候刚好执行到第二次检测,原本上它应该返回true,但是此时发现,instance已经有指向的对象,于是跳过新建对象。违反了初衷,带来了安全隐患。
我们使用volatile进行预防:
public class Singleton {
private volatile static Singleton instance;
private Singleton(){};
public static Singleton getInstance() {
// 第一次检测
if (instance == null) {
// 同步
synchronized (Singleton.class) {
if(instance == null){
// 多线程下可能会出现问题的地方
instance = new Singleton();
}
}
}
return instance;
}
}
这样instance的前后步骤都被禁止重排,保证了其执行顺序和程序的安全
四、volatile和synchronized的区别
以上仅为个人理解,如有不妥,请及时指出