一、内存可见性问题
内存可见性问题是出现线程安全问题的原因之一。
1、什么是内存可见性问题?
一个线程针对一个变量进行读取操作,另一个线程针对这个变量进行修改操作,此时读到的值不一定是修改后的值,出现了线程安全问题,这就是内存可见性问题。
2、为什么会产生内存可见性问题?
因为这个读线程没有感知到变量的变化,归根结底是jvm/编译器在多线程环境下优化时产生了误判。
3、从JMM角度表述内存可见性问题
对于内存可见性问题,其它一些资料会谈到JMM(Java Memory Model,Java内存模型)。
从JMM角度表述内存可见性问题:
Java程序里,有个主内存,每个线程还有自己的工作内存(工作内存不是内存,翻译的锅,相当于工作存储区)(线程t1和线程t2分别有各自的工作内存),t1线程进行读取的时候,只是读取了工作内存的值。t2线程进行修改的时候,先是修改了工作内存的值,然后再把工作内存的内容同步到主内存中。但是由于编译器的优化,导致t1线程没有重新从主内存同步数据到工作内存,读到的结果就是“修改之前”的结果。
主内存(main memory)就是内存,工作内存(work memory)可以理解为工作存储区,不是内存,是CPU上存储数据的单元(工作内存 = CPU的寄存器+CPU的缓存cache)
为啥会有cache?
因为CPU读取寄存器的速度比CPU读取内存的速度快太多太多了,于是CPU内部就有了cache(分CPU,有的CPU内部有cache,有的没有)
寄存器、cache、内存的关系:
寄存器:存储空间小,读写速度快,贵
cache:存储空间居中,读写速度居中,成本居中
内存:存储空间大,读写速度慢,便宜(相比寄存器)
当CPU读取一个内存数据的时候,可能直接读内存,可能读cache,也可能读寄存器。(如果CPU是读cache或寄存器,说明cache或寄存器中已经有从内存中同步的数据了。)无论是从内存中读取数据,还是从cache中读取数据,读取的数据都会存放在CPU的寄存器中,用来进行各种运算操作。参与运算操作的数据都是CPU寄存器里的。
二、如何解决内存可见性问题 —— volatile
1、一段出现线程安全问题的代码
有两个线程,一个成员变量flag。线程t1用来 读取变量flag 的值,线程t2用来 修改变量flag 的值,预期结果是:输出 flag 被修改成非零了
代码和输出结果如下:
class MyCounter{
public int flag = 0;
}
public class ThreadDemo14 {
public static void main(String[] args) {
MyCounter myCounter = new MyCounter();
//线程t1 用来 读取
Thread t1 = new Thread(()->{
while(myCounter.flag == 0){
//这个循环体中什么都不写
}
System.out.println("flag 被修改成非零了");
});
//线程t2 用来 修改
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个非零的整数:");
myCounter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
并没有输出 flag 被修改成非零了,和预期结果不一样,代码出bug了
2、这段代码为什么会出现线程安全问题?
和内存可见性问题有关。
myCounter.flag == 0这行代码,使用汇编来理解,就是两步操作,也就是两条指令:
1、load:把内存中flag的值,读取到 CPU的寄存器 中(可能是从内存读到寄存器,也可能是先从内存读到cache,再从cache读到寄存器)
2、cmp:把CPU寄存器里的值,和0进行比较。根据比较结果,决定下一步往哪个地方执行
由于while循环中什么都没写,所以这个循环的执行速度极快(一秒钟能执行百万次循环),而循环执行这么多次,只要 t2还没修改flag的值,load的flag的值就还是原来的0。
另一方面,load操作和cmp操作的执行速度也不一样,因为load操作要读取内存,cmp操作只要读取寄存器,CPU针对寄存器的操作能比针对内存的操作快3-4个数量级,也就是说,相比cmp来说,load执行速度太慢了。
循环的执行速度极快,说明需要非常多次load和cmp,cmp执行又很快,说明基本上都是一直在load,一直在花费时间读取内存中的flag,flag的值还不变,所以JVM认为线程一直在花费时间不断的读取一个相同的数字,于是就给优化了。不再去内存中读取了,而是直接用寄存器里面未更改的值。(参与运算的寄存器里的值是指CPU去工作内存读取,存放到CPU的寄存器中的数据)
在线程t2 修改flag的值之前,编译器已经自作主张给优化了(编译器说:一直在浪费时间读一个不变的数,我不让你去内存读了),所以就算后来线程t2修改了flag的值,线程t1也读不到了。这就出现了bug,出现了线程安全问题。
这其实就是内存可见性问题。
3、如何解决内存可见性问题?
给这个变量加个 volatile关键字 修饰,告诉编译器,这个变量是“易变的”,你一定要每次都去内存中重新读取变量的值,指不定啥时候就变了,你可不能偷懒给优化了。
什么时候用 volatile 关键字?
解决内存可见性问题时
4、内存可见性问题的出现是随机的,不是100%
比如上面那段出现问题的代码,在循环中加了sleep控制了循环的速度,不加volatile,运行结果也正确了。编译器的优化,很多时候是“玄学问题”,应用程序这个角度无法感知,因此稳妥一点,我们还是把该加 volatile 的地方都给加上。
三、synchronized 和 volatile 的区别
synchronized 和 volatile 的区别:
synchronized 只能修饰方法和代码块,不能修饰变量。
volatile 只能修饰变量,且修饰的是成员变量,不能修饰局部变量,因为局部变量只能在当前方法中使用,出了方法就没了。也就是说,方法里的局部变量,只能在当前线程的这个方法中使用,不能用于多线程之间的读取或修改,天然就是线程安全的。
synchronized 和 volatile 都能保证线程安全,但适用的场景不同。synchronized 是针对两个线程修改一个变量的原子性问题,volatile 是针对一个线程读取一个线程修改的内存可见性问题。volatile能保证内存可见性,不保证原子性。如果涉及到某个代码,既需要考虑原子性,又需要考虑内存可见性,就把synchronized 和 volatile 都加上。