1.前言
synchronized和volatile都是Java多线程中很重要的关键字,但它们的作用和使用场景有所不同。
synchronized关键字可以保证同一时刻只有一个线程可以访问被synchronized关键字保护的代码块,从而避免多个线程对共享资源的并发访问导致的数据不一致问题。
关于synchronized关键字更详细的介绍,可以参考我之前写的这篇文章线程安全问题以及synchronized使用实例
volatile用于保证变量在多个线程之间的可见性和有序性。
本文主要介绍valatite关键字
在介绍volatile关键字之前,先来认识一下编译器优化带来的问题.
2. 编译器优化带来的内存可见性问题
编译器优化是编译器在编译源代码时,对代码进行的一系列优化处理,以提高程序的运行效率和性能。
编译器优化的主要目标是在不改变程序功能的前提下,尽可能地减少程序的运行时间和内存占用。
但编译器优化在多线程环境下可能会造成内存可见性问题.
内存可见性问题:
当一个线程修改一个共享变量的值时,这个值会被保存在该线程的本地内存中,而不是直接写入主内存中。
如果其他线程需要读取该共享变量的值,它们可能会从自己的本地内存中读取旧值,而不是从主内存中读取最新的值,从而导致数据不一致的问题。
示例:
public class Demo9 { static class Counter{ public int count = 0; } public static void main(String[] args) { Counter counter = new Counter(); Thread t1 = new Thread(()->{ while(counter.count == 0){ } System.out.println("t1 执行结束!"); }); t1.start(); Thread t2 = new Thread(()->{ Scanner scanner = new Scanner(System.in); System.out.print("> "); counter.count = scanner.nextInt(); }); t2.start(); } }
运行结果:
虽然counter.count的值修改成1了,但是t1的循环并没有结束. 为什么呢?
其实原因主要是在这里
counter.count == 0 这个操作会有两步,读内存(load)和进行比较(cmp).加上这里的条件是while,那么此时读内存和进行比较就会执行很多次.
编译器就会对上述代码进行优化,读内存比进行比较这个操作慢得多.
既然频繁读内存,且每次读内存后的值都是一样的,那么就没必要多次读内存了.只读一次后面就直接读本地内存中的值(提高效率).
因此在进行修改counter.count的值之前,t1线程就已经读过counter.count的值了,t2修改了但t1并没有感知到. 这也就是编译器优化带来的内存可见性问题
3. 使用volatile保证内存可见性
内存可见性是指多个线程之间共享变量时,对变量的修改能够被其他线程及时地看到。
当一个变量被声明为volatile时,每次读取该变量时,都会从主内存中读取最新的值,而不是从线程的本地内存中读取。同样,每次写入该变量时,都会立即将值刷新到主内存中,而不是仅在线程的本地内存中修改。
接下来就可以通过volatile关键字解决上述的问题
只需在count变量前加上volatile即可.
运行结果:
可以看到加上volatile关键字之后,修改count的值,t1就能够"感知"到了.
上述就是简单的使用volatile保证内存可见的简单案例
但其实编译器优化导致的内存可见性问题,也并不是一定就会发生.
如果让t1线程这里的while里面加一个线程休眠2s这段代码,此时即使不加volatile关键字,也不会导致内存可见性问题.
这里为什么不会产生内存可见性呢?
2s对于我们人来说,很短.但是对于计算机来说却是很漫长的.
别忘了编译器优化的目的,编译器优化主要是为了提高效率.
休眠2s,编译器即使优化了也没有什么提升.
5.volatile不能保证原子性
原子性是指操作或事务的不可分割性和不可中断性
就以两个线程针对同一个变量,同时进行修改操作为例:
class Counter{ public volatile int count; public void add(){ count++; } } public class Demo10 { private static Counter counter = new Counter(); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { counter.add(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { counter.add(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("count = "+counter.count); } }
运行结果:
其实之前谈synchronized的时候,就说过这个问题.
count++这个操作可以分为3步:
因此count++并不是原子性. 我们需要使用synchronized来保证原子性.但是volatile并不能保证原子性
以JMM的角度看待volatile
JMM是Java内存模型(Java Memory Model)的简称,是一种规范,用于规定Java虚拟机(JVM)如何与计算机内存交互,以及多线程如何访问共享内存。
volatile禁止了编译器优化,避免了直接读取缓存(工作内存)中的数据,而是每次都去读取主内存中的数据.
以JMM的视角看待volatile:
正常程序运行时,会把主内存中的数据加载到工作内存中,在进行计算处理.
编译器优化可能会导致读到的数据来自于工作内存,而不是主内存
volatile的效果就是保证每次读到的数据都是从主内存读到的
总结
volatile关键字主要用于保证可见性和有序性,但不能保证原子性。
适用场景:
1.变量被多个线程共享,且其中一个线程修改了该变量的值,需要让其他线程立即看到该修改。
2.变量的值在程序中的读写顺序很重要,需要保证操作的有序性。
volatile会禁止编译器和JVM对代码进行优化,增加了内存的读写操作,降低了程序的执行效率。