volatile深度解析
volatile是什么
volatile是Java虚拟机提供的轻量级的同步机制。它有三个特征:
- 保证可见性
- 不保证原子性
- 禁止指令重排
想要理解volatile的工作机制首先要了解JMM,java memory model,即java内存模型。
Java内存模型(JMM)
JMM本身是一种抽象的概念,并不真实存在。它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。
注意,JMM只是一种规则,在java多线程情况下,如果不做任何处理,每个线程都有自己的工作内存,如果访问主内存中的变量,将会从主内存中拷贝一份到工作内存,即使在某个线程的工作内存中做了修改,没有刷新回主内存,那么对其他线程来说这个修改将是不可见的。具体如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u6DHsKZd-1578637312474)(https://i.bmp.ovh/imgs/2019/06/5d19bc34bec03701.png)]
所以为了保证每个线程对主内存中的变量的修改对其他线程是可见的,就要引入volatile关键字。volatile不能保证原子性,但可以保证可见性和有序性(禁止指令重排)。
验证volatile可见性
class MyData{
private int age = 0;
public void setAge(int age) {
this.age = age;
}
public int getAge() {
return age;
}
}
public class VolatileTest1 {
@Test
public void test1() {
MyData myData = new MyData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "线程start");
try {
Thread.sleep(3000);
myData.setAge(60);
System.out.println(Thread.currentThread().getName() + "线程结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "thread1").start();
while(myData.getAge() != 60) {
// System.out.println(Thread.currentThread().getName() + "线程没有感知到age修改");
}
System.out.println(Thread.currentThread().getName() + "线程结束 age == " + myData.getAge());
}
}
运行结果:
thread1线程start
thread1线程结束Process finished with exit code -1
上述结果,主线程结束不了,因为没有被通知age值被改变,但是如果改成: private volatile int age = 0; 那么子线程对于age的修改就会即时刷新到主内存中并告知主线程。
验证volatile不保证原子性
@Test
public void test2() {
MyData myData = new MyData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addAgePlusPlus();
}
}, "线程" + String.valueOf(i)).start();
}
while (Thread.activeCount() > 2) {//默认一个主线程一个后台GC线程
Thread.yield();//线程礼让
}
System.out.println("计算结束,age == " + myData.getAge());
}
计算结束,age == 19157
Process finished with exit code 0
可以看到,开20个线程,每个线程执行1000次age++,但是最终得到的结果不是20000,而是比它小。
volatile不保证原子性原理
原理其实很简单,主要还是线程安全问题。因为age++看似只有一行代码,但是在汇编机器语言的情况下是三条指令。这里用到idea集成javap指令,集成方法见:https://jingyan.baidu.com/article/f71d6037c05ecc1ab741d163.html。 下面是java代码:
class MyData{
private int age = 0;
public void setAge(int age) {
this.age = age;
}
public int getAge() {
return age;
}
public void addAgePlusPlus() {
this.age++;
}
}
转为汇编语言后:
class top.tupobi.www.learnjavademo.learndemo.MyData {
top.tupobi.www.learnjavademo.learndemo.MyData();
....
public void addAgePlusPlus();
Code:
0: aload_0
1: dup
2: getfield #2 // Field age:I
5: iconst_1
6: iadd
7: putfield #2 // Field age:I
10: return
}
可以看到,this.age++是由三条汇编指令构成:首先是getfield然后iadd然后putfield。
2: getfield #2 // Field age:I 5: iconst_1 6: iadd 7: putfield #2 // Field age:I
这样的话一个线程拿到主内存共享变量后,刚赋值完成iadd,还没来得及putfiled,另一个线程同时要修改主内存中的共享变量,这时它的值还是0,该线程使它+1,上一个线程同时putfiled也是0+1,那么最终会丢失某线程的更新。所以上述案例最终结果小于20000的期望值。一种解决方法很简单,使用synchronize修饰addAgePlusPlus()方法,但是杀鸡焉用牛刀,下面是另一种解决方法。
使用原子类
使用AtomicInteger解决多线程volatile不保证原子性问题:
class MyData{
private int age = 0;
private AtomicInteger age1 = new AtomicInteger();
public void setAge(int age) {
this.age = age;
}
public int getAge() {
return age;
}
public void addAgePlusPlus() {
this.age++;
}
public void addAgeWithAtomic() {
age1.incrementAndGet();//相当于age1++
}
public AtomicInteger getAge1() {
return age1;
}
}
@Test
public void test2() {
MyData myData = new MyData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addAgePlusPlus();
myData.addAgeWithAtomic();
}
}, "线程" + String.valueOf(i)).start();
}
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println("计算结束,age == " + myData.getAge());
System.out.println("计算结束,age1 == " + myData.getAge1());
}
计算结束,age == 17367
计算结束,age1 == 20000Process finished with exit code 0
volatile禁止指令重排
指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。处理器在进行重排序时需要考虑指令之间的数据依赖性。
指令重排一般分为三种,源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行的指令。
在单线程情况下,程序执行结果将与代码编写顺序得到的结果一致,即使出现指令重排也不需要担心。
在多线程环境下,线程交替执行,由于编译器优化指令重排的存在,多个线程中使用的变量能否保持一致性是不确定的,结果无法预测。
内存屏障
内存屏障memory barrier,又称内存栅栏,是一种CPU指令,它的作用主要有两个:
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性
由于编译器和处理器都能执行指令重排优化。如果在指令见插入一条memory barrier则会告诉编译器和CPU,不管什么指令都不能和这条memory barrier指令重排。也就是说,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障的另一个作用是强制刷出各种CPU缓存的数据,因此任何CPU上的线程都能读取到这些数据的最新版本。