通俗易懂 volatile 关键字解析
文章目录
volatile
1. 什么是 volatile ?
volatile 是 Java 虚拟机提供的轻量级的同步机制,它可以保证可见性和有序性(禁止指令重排序),但是不保证原子性。
2. 先简单了解下 java 内存模型——JMM
Java 内存模型——JMM 是一个抽象的概念,其实并不存在,它描述的是一种规范。这些规范定义了程序中各种变量的访问规则。
来看看下面这张图:(图片来源于网络)
解读:
首先每个线程被创建时,JVM 都会为其分配一个工作内存(或者称为栈空间;在图中显示的本地内存 A 和 B),工作内存是线程私有的。在 Java 内存模型中,所有变量都是存储在主内存中的,主内存是线程共享的。当我们的一个线程要对某个变量进行操作时,需要先将变量从主内存中拷贝到自己的工作内存中,然后对这个变量进行操作,操作完成后,再将变这个量写回到主内存中。
(对于这篇文章,暂时了解这么多就够了,不过 JMM 还是很重要的,建议多去看看)
3. volatile —— 保证可见性
先聊聊不可见:
看下上面那张图,如果是单线程的程序,没有任何问题。
可如果是多线程的程序,就要考虑多个线程对共享变量的操作问题了。
比如:
线程A、线程B 都要对主内存中的 共享变量M 进行操作。
首先 线程A、线程B 都从主内存中将 共享变量M 拷贝到了自己的工作内存中。
然后 线程A 修改了 共享变量M 的值但还未写回到主内存中,因为工作内存是线程私有的,所以此时,这个修改对于 线程B 是不可见的,这就会造成一些问题。
看一下问题演示:
class Data {
int number;
//将number的值改成10
public void changeNmberTo10() {
number = 10;
}
}
public class volatile_test1 {
public static void main(String[] args) {
Data data = new Data();
new Thread(() -> {
System.out.println("线程 " + Thread.currentThread().getName() + " 进来了,等待 2 秒");
// 线程1 等待2秒,确保 main 线程已经将 number 拷贝到自己的工作空间了
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//将numebr改成10
data.changeNmberTo10();
System.out.println("线程 " + Thread.currentThread().getName() + " 将 number 改成了 10");
},"Thread-1").start();
//当 main 认为 number 为 0 时
while (data.number == 0) {
}
//当 main 认为 number 不 0 时
System.out.println("线程 " + Thread.currentThread().getName() + " 读取到的 number 值为:" + data.number);
}
}
number 初始化是 0 ,线程 Thread—1 和 main 线程都进来将 number = 0 拷贝回了工作空间。main 线程在等待 number 值不为0的时候再往后执行。此时 Thread—1 将number 的值修改成了 10,但是 main 线程却还是在等待,因为 Thread-1 的的修改对于 main 线程是不可见的,那么 main线程 自然也就不知道 number 已经被改成了 10 了,所以就造成了 main 线程一直在等待的问题。
这也就是不可见性可能会造成的问题。
而 volatile 能保证变量在多个线程间的可见性。
还是一样的代码,我们给变量 number 加上一个 volatile 关键字之后再试试:
class Data {
volatile int number;
//将number的值改成10
public void changeNmberTo10() {
number = 10;
}
}
public class volatile_test1 {
public static void main(String[] args) {
Data data = new Data();
new Thread(() -> {
System.out.println("线程 " + Thread.currentThread().getName() + " 进来了,等待 2 秒");
// 线程1 等待2秒,确保 main 线程已经将 number 拷贝到自己的工作空间了
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//将numebr改成10
data.changeNmberTo10();
System.out.println("线程 " + Thread.currentThread().getName() + " 将 number 改成了 10");
},"Thread-1").start();
//当 main 认为 number 为 0 时
while (data.number == 0) {
}
//当 main 认为 number 不 0 时
System.out.println("线程 " + Thread.currentThread().getName() + " 读取到的 number 值为:" + data.number);
}
}
从运行结果可以知道, Thread-1 线程一讲 number 改成了10,main 线程立马看见了。
这也证明了 volatile 是保证可见性的, 也就是当一个线程修改了本地内存中的共享变量的副本之后,会立马将修改更新到主内存中,并通知其他线程更新更新变量的值。
3. volatile —— 不保证原子性
我们先看一段代码:
class Data2 {
volatile int number;
//将number的值改成10
public void add_1() {
number++;
}
}
public class volatile_test2 {
public static void main(String[] args) {
Data2 data2 = new Data2();
//10个线程,每个线程调用1000个加1操作
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
data2.add_1();
}
}).start();
}
//让 main 线程等待5秒,等上面10000个线程执行完
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//输入 number 的值
System.out.println(data2.number);
}
}
number初始值为 0 ,10个线程,每个线程调用1000个加1操作(number++),那么number本应该是10000;
但是可以看到,运行的结果小于10000;
并且注意我们的代码,number 是加了 volatile 关键字修饰的!
为什么会出现这样的事?volatile 不是保证可见性吗?
其实这是因为 number++ 底层分成了 3 步:
- 获取 number 的原始值;
- 对 number 进行加 1 操作;
- 将累加后的 number 值写回。
也就说,其实 number++ 并不是一个原子操作,而是一个复合操作,而我们的 volatile 并不能保证原子性。
(当然,如果想要 number++ 是原子性的,也是有办法的,可以使用原子类 AtomicInteger ,这个我们后面再说。)
4. volatile —— 保证有序性(禁止指令重排序)
什么是指令重排序?
计算机在执行程序的时候,为了提高性能,编译器和处理器常常会对指令进行重排,一般有如下3种:
JVM 可以在不改变数据依赖关系的情况下对指令进行任意排序以提高程序性能(但不管怎么重排序,单线程情况下,程序的执行结果不能被改变,也就是和指令顺序执行的结果一致)。
但是这里所说的数据依赖性仅针对单个线程中执行的指令序列和操作,也就是说在多线程环境下,不同线程之间的数据依赖性不会被 JVM 考虑,
那么多线程环境中,线程交替执行,由于编译器优化重排的存在,多个线程中使用的变量无法保证一致性,结果也是无法预测的。
可能听起来有些抽象,看个列子就明白了:
线程A 和 线程B 中分别有一些操作:
//线程A
user = new User("张三"); //(1)
boolean flag = true; //(2)
//线程B
while(true){
//(3)
String userName = user.name; //(4)
}
正常情况下,我们希望的执行顺序是 (1)(2)(3)(4),这样的话不会有什么问题。
但是由于指令重排序,我们指令的执行顺序可能会变成如下这样:
//线程A
boolean flag = true; //(2)
user = new User("张三"); //(1)
```,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, 吗
```java
//线程B
while(true){
//(3)
String UserName = user.name; //(4)
}
指令(1)(2) 的顺序改变,对于线程A 来说并没有什么影响,所有JVM 可能会这样进行指令的重排序。
但是,对于线程B 就会出现,调用 user.name 的时候,user 还没有初始化的错误。
所有对于这样的情况,我们不希望 JVM 对相关变量有关的指令进行指令重排序,那么就可以使用 volatile 禁止相关指令重排序。
5. volatile 保证可见性和有序性的底层原理
volatile 底层是基于 内存屏障 来实现的。
内存屏障有两个功能:
- 通过插入内存屏障,禁止在内存屏障前后的指令执行重排序优化。
- 强制刷出各种 CPU 的缓存数据,任何 CPU 上的线程都能读取到这些数据的最新版本。
注: 如果这篇博客有任何错误和建议,欢迎大家留言交流,不胜感激!