目录
1.什么是内存可见性问题
2.内存可见性问题是怎么发生的
3.解决方法:volatile
4.volatile使用的注意事项
5.内存可见性问题的延伸
缓存(cache)
1.什么是内存可见性问题
首先来看一段代码
class Counter{
public int flag = 0;
}
public class VolatileDemo1 {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while(counter.flag == 0) {
//循环里面不进行任何操作
}
System.out.println("t1 循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入flag: ");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
这段代码一共创建了两个线程,其中t1线程去判断flag的值(默认为0),如果不为0则跳出循环(循环里面不执行任何操作)当flag不为0时,提示t1线程结束,t2线程则是输入一个值,赋给flag。
按照我们的逻辑,当t2线程输入一个不为0的数字时,t1线程会打印“t1 循环结束”,那么我们来看一下结果,如下:
可以看到,我们输入1,赋值给flag,但是t1循环却没有对此做出相应的操作,这就是出现了内存可见性问题。
2.内存可见性问题是怎么发生的
首先针对上面的例子,我们做一些分析。
t1线程中有一个循环,循环条件是判断flag这个变量是否为0,循环体为空
t2线程是输入一个数字赋值给flag
按照逻辑当t2输入数字不为0,那么t1循环结束,那么为什么当t2输入了一个不为0的数字时,t1循环仍然没有结束呢?
可以肯定的是:t2中的输入和赋值操作都是没有问题的,那么问题的所在就一个在t1的身上。
那么我们对t1中的执行语句做一些分析:
t1线程中储粮打印操作,唯一可以被执行的计算循环的判断条件 counter.flag == 0 。
这条语句我们可以把它拆分成两条指令:
一条是从内存中获取flag的值--load
一条是将这个值和0进行比较--cmp
按理来说,如果每次进入循环条件判断的时候,都对flag的值进行获取,那么结果就不会出现死循环的现象,而此时出现了死循环,那么就说明对flag的获取出现了问题。
t1中的这个循环是空体,这个循环在执行时的速度极快,1秒钟可以执行上百万次,而执行了这么多次load的获取结果都是一样的。另一方面,load的执行速度相比于cmp慢了太多了。此时JVM就做出来一个非常大胆的决定--不再真正的去重复load了,因为判定好像没人去修改flag的值,所以干脆就只获取一次就好了,此时就出现了前面运行的情况了。
上述的这种情况是编译器优化的一种方式,而内存可见性问题归根结底就是编译器/JVM在多线程环境下优化时产生了误判,此时就需要我们去手动干预,让编译器不要瞎搞,而这个操作结束在变量前面加上 volatile 关键字。
3.解决方法:volatile
继续挪用上面的代码,并且给flag这个变量加上volatile
class Counter{
volatile public int flag = 0;
}
public class VolatileDemo1 {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while(counter.flag == 0) {
//循环里面不进行任何操作
}
System.out.println("t1 循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入flag: ");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
此时再去运行可以看到
加了volatile之后,代码的运行情况就符合我们的预期了。
当然,代JVM并不是任何时候都会出现优化误判的情况,比如下面的代码
class Counter{
public int flag = 0;
}
public class VolatileDemo1 {
public static void main(String[] args) {
Counter counter = new Counter();
//编译器不是任何时候都会进行优化或者优化出错 如下,即使没有 volatile 也可以正常运行
Thread t1 = new Thread(() -> {
while(counter.flag == 0) {
//循环里面不进行任何操作
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入flag: ");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
我们在循环体中加入了sleep,此时代码中没有加 volatile 但是代码也可以正常运行,但是这开发中,对于这种不确定的情况,还是加上volatile更加稳妥。
4.volatile使用的注意事项
volatile 只可以对变量进行修饰,不可以对方法进行修饰。
volatile 不可以对方法中的局部变量进行修饰。
volatile 不保证原子性,若想保证原子性要使用 synchronized
5.内存可见性问题的延伸
关于内存可见性问题,还可以从JMM(Java Memory Modle java内存模型)的角度去重新表述
Java程序里除了主内存,每个线程还有自己的“工作内存”
t1线程进行读取的时候只是读取了它工作内存的数据
t2线程进行修改的时候,先修改工作内存的数据,然后再把工作内存的数据同步到主内存中,但是由于编译器优化,导致t1没有重新从主内存中同步数据到它的工作内存中,所以读到的结果就是错误的结果。(主内存和工作内存这样的表述来自于Java文档)
上面的主内存既可以理解为前面说的内存;
而工作内存可以理解为工作存储区,也就是CPU上存储数据的单元(寄存器)以及缓存。
缓存(cache)
CPU中的寄存器存储的空间小,读写速度快,成本高;
内存的存储空间大,读写速度慢,成本低(相对于寄存器来说)
缓存就是他俩的中间值,缓存存储空间居中,读写速度居中,成本居中
当cpu在读取一个数据的时候,可能是直接读取内存,也可能是读取缓存,还可能是读取寄存器
前面说的工作内存,之所以将寄存器和缓存都包含进去,一方面是因为描述简单,另一方面,无论是缓存还是寄存器都不会对我们得到的结论产生影响。