谈谈你对 volatile 的理解
1、volatile 是 Java 虚拟机提供的轻量级的同步机制
- 保证可见性
- 不保证原子性
- 禁止指令重排
2、谈谈JMM(Java 内存模型)
JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
JMM关于同步的规定:
1、线程解锁前,必须把共享变量的值刷新回主内存
2、线程加锁前,必须读取主内存的最新值到自己的工作内存
3、加锁解锁是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中运行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:
2.1、可见性
通过前面对JMM的介绍,我们知道
各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后再写回到主内存中的。
这就可能存在一个线程AAA修改了共享变量X的值但还未写回主内存时,另外一个线程BBB又对主内存中
同一个共享变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说并不可见,
这种工作内存与主内存同步延迟现象就造成了可见性问题
2.2、原子性
原子性指的是什么意思?
不可分割, 完整性, 也即某个线程正在做某个具体业务时, 中间不可以被加塞或者被分割. 需要整体完整
要么同时成功, 要么同时失败
number++ 在多线程下是非线程安全的,如何不加 synchronized解决?
2.3、VolatileDemo代码演示可见性+原子性代码
package com.brian.interview.study.thread;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Copyright (c) 2020 ZJU All Rights Reserved
* <p>
* Project: JavaSomeDemo
* Package: com.brian.interview.study.thread
* Version: 1.0
* <p>
* Created by Brian on 2020/2/10 20:50
*/
class MyData { // MyData.java ===> MyData.class ===> JVM字节码
volatile int number = 0;
public void addTo60() {
this.number = 60;
}
// 请注意, 此时 number 前面是加了 volatile 关键字修饰的, volatile 不保证原子性
public void addPlusPlus(){
number++;
}
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic(){
atomicInteger.getAndIncrement();
}
}
/**
* 1、验证 volatile 的可见性
* 1.1 假如 int number=0; number 变量之前根本没有添加 volatile 关键字修饰, 没有可见性
* 1.2 添加了 volatile, 可以解决可见性问题
*
* 2、验证 volatile 不保证原子性
* 2.1 原子性指的是什么意思?
* 不可分割, 完整性, 也即某个线程正在做某个具体业务时, 中间不可以被加塞或者被分割. 需要整体完整
* 要么同时成功, 要么同时失败
*
* 2.2 volatile 不保证原子性的案例演示
*
* 2.3 why
*
* 2.4 如何解决原子性?
* * 加 synchronized
* * 使用我们的 JUC 下 AtomicInteger
*/
public class VolatileDemo {
public static void main(String[] args) {
MyData myData = new MyData();
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
myData.addPlusPlus();
myData.addAtomic();
}
}, String.valueOf(i)).start();
}
// 需要等待上面20个线程都全部计算完成, 再用main线程取得最终的结果值看是多少?
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t int type, finally number value: " + myData.number);
System.out.println(Thread.currentThread().getName() + "\t AtomicInteger type, finally number value: " + myData.atomicInteger);
}
// volatile 可以保证可见性, 及时通知其它线程, 主物理内存的值已经被修改
public static void seeOkByVolatile() {
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.addTo60();
System.out.println(Thread.currentThread().getName() + "\t update number value: " + myData.number);
}, "AAA").start();
// 第2个线程就是我们的main线程
while (myData.number == 0){
// main 线程就一直在这里等待循环,直到 number 值不再等于零
}
System.out.println(Thread.currentThread().getName() + "\t mission is over, main get number value: " + myData.number);
}
}
2.4、有序性
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分以下3种
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
处理器在进行重排序时必须要考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编程器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测
重排1
public void mySort(){
int x = 11; // 语句1
int y = 12; // 语句2
x = x + 5; // 语句3
y = x * x; // 语句4
}
- 1234
- 2134
- 1324
问题:请问语句4可以重排后变成第一条吗?
- 答:不可以,处理器在进行重排序时必须要考虑指令之间的数据依赖性
重排2
int a, b, x, y=0;
线程 1 | 线程 2 |
---|---|
x=a; | y=b; |
b=1; | a=2; |
x=0, y=0 |
如果编译器对这段程序代码执行重排优化后,可能出现下列情况
线程 1 | 线程 2 |
---|---|
b=1; | a=2; |
x=a; | y=b; |
x=2, y=1 |
这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。
重排2–案例
package com.brian.interview.study.thread;
/**
* Copyright (c) 2020 ZJU All Rights Reserved
* <p>
* Project: JavaSomeDemo
* Package: com.brian.interview.study.thread
* Version: 1.0
* <p>
* Created by Brian on 2020/2/10 23:18
*/
public class ReSortSeqDemo {
int a = 0;
boolean flag = false;
public void method1() {
a = 1; // 语句1
flag = true; // 语句2
}
// 多线程环境中线程交替执行,由于编程器优化重排的存在,
// 两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测
public void method2() {
if (flag) {
a += 5; // 语句3
System.out.println("*********retValue: " + a);
}
}
}
禁止指令重排小总结
volatile 实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象
先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
一是保证特定操作的执行顺序,
二是保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)。
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重新排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
总结: 可见性、原子性、有序性 线程安全性获得保证
工作内存与主内存同步延迟现象导致的可见性问题
可以使用 synchronized 或 volatile 关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见
对于指令重排导致的可见性问题和有序性问题
可以利用 volatile 关键字解决,因为 volatile 的另外一个作用就是禁止重排序优化。
3、在哪些地方用到过 volatile?
3.1、单例模式 DCL 代码
package com.brian.interview.study.thread;
/**
* Copyright (c) 2020 ZJU All Rights Reserved
* <p>
* Project: JavaSomeDemo
* Package: com.brian.interview.study.thread
* Version: 1.0
* <p>
* Created by Brian on 2020/2/10 23:58
*/
public class SingletonDemo {
public static volatile SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo()");
}
// DCL (Double Check Lock 双端检锁机制)
public static SingletonDemo getInstance() {
if (instance == null) {
synchronized (SingletonDemo.class) {
if (instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
// 单线程(main线程的操作动作......)
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
//
// System.out.println();
// System.out.println();
// System.out.println();
// 并发多线程后, 情况发生了很大的变化
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
}
}
3.2、单例模式 volatile 分析
DCL(双端检锁)机制不一定线程安全,原因是有指令重排序的存在,加入 volatile 可以禁止指令重排
原因在于某一个线程执行到第一次检测,读取到的 instance 不为 null 时,instance 的引用对象可能没用完成初始化。
instance = new SingletonDemo(); 可以分为以下3步完成(伪代码)
memory = allocate(); // 1、分配对象内存空间
instance(memory); // 2、初始化对象
instance = memory; // 3、设置 instance 指向刚分配的内存地址,此时 instance!=null
步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
memory = allocate(); // 1、分配对象内存空间
instance = memory; // 3、设置 instance 指向刚分配的内存地址,此时 instance!=null,但是对象还没有初始化完成!
instance(memory); // 2、初始化对象
但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。
所以当一条线程访问 instance 不为 null 时,由于 instance 实例未必已初始化完成,也就造成了线程安全问题。