在Java中,我们谈到多线程就不能不谈到‘数据争用‘,而要解决“数据争用“问题,就必须要了解Java中关于内存可见性相关知识点。
下面我会简单结合Java内存模型讲解以下问题:
- 共享变量在多线程中的可见性问题
- synchronized/volatile关键字解决可见性问题
1. 可见性概念
可见性:一个线程对共享变量的修改,能及时被其他线程看到。满足这个条件,我们就可以说,这个共享变量是多线程可见的。
共享变量:如果一个变量在多个线程的工作内存中都存在一个独立都副本,则这个变量称为这几个线程都共享变量。
上面提到了“工作内存“这个概念,这个是Java内存模型中提到的一个概念,实际上内存中没有给每个线程分配“工作内存“这样专用的物理内存,只是一种虚拟的概念。
2. Java内存模型
Java内存模型又被称为JMM(Java Memory Model),它描述了Java程序中各种变量的访问规则,以及在JVM中变量存储到内存和从内存中读取变量的底层实现细节。(内存模型中的规则还是比较复杂的,在这篇文章中不会专门讲解,大家有兴趣可以去看看看看Java虚拟机相关的书籍)
在内存模型中,所有公共变量都存放在主内存中,而每个线程都有自己都“工作内存“,“工作内存“里面保持了该线程要使用的变量的副本(主内存中变量的拷贝)。
下面这张图简单描述了上面说的关系:
从上图中可以看到:
1. 线程没有从主内存中直接操作变量,而是通过了自己的“工作内存“来读写主内存中的变量
2. 线程之间不能之间交换变量,必须通过主内存进行
3. 可见性实现原理
线程1对变量的修改如果要想让线程2看到,必须进过一下步骤:
1. 线程1修改变量到自己的“工作内存“
2. 线程把“工作内存“中的变量刷新到主内存中
3. 线程2读取变量时,先把自己的“工作内存“清空,然后从主内存中刷新最新的变量的值到自己的“工作内存“
上面三个步骤完整执行后,才能保证这个变量在这两个线程中的可见性,任何一个步骤执行异常,线程2可能读取的就是脏数据。(这也是多线程并发时最常见的难点之一。)
4. 可见性实现
通过上面概念的描述,我们可以知道,要实现共享变量的可见性,就要保证以下两点:
- 线程修改共享变量后要及时刷新到主内存中
- 线程每次使用共享变量之前都要从主内存中刷新最新的值
那么Java如何保证多线程之间变量可见性呢?
从语言层面实现可见性的方式:synchronized、volatile
4.1 引起可见性问题的深层次原因分析
为了让后面的更好的理解,需要在这里先了解以下引起可见性的一些原因
4.1.1 指令重排
从字面上看,它的意思是代码书写顺序在执行时重新排序了,实际上是处理器为了在不影响单线程内执行结果的前提下,对代码顺序做了优化调整。
当前指令重排序有三种情况:
1. 编译器优化重排(编译器级别优化)
2. 指令并行重排(处理器级别优化,针对现在多核处理器)
3. 内存系统指令重排(处理器级别优化,针对的就是可见性场景了)
4.1.2 as-if-serial
定义:在指令重排背景下,as-if-serial保证,无论如何重排序,代码的执行的结果应该与顺序调整后一致(Java编译器、运行时和处理器都会保证Java在单线程下遵循as-if-serial语义)
参考下面代码,单线程中,步骤1、2在编译和执行中可能会被重排或者并行处理,但无论如何,步骤3都是在步骤1、2执行完的情况下才能被执行,这是由as-if-serial来保证的。注意这里的前提是单线程。多线程就不一定保证结果执行顺序来,后面会给出实例代码。
int a = 1; // 步骤1
int b = c; // 步骤2
int sum = a + b; // 步骤3
4.2 synchronized实现可见性
提到synchronized关键字,我们都知道其一个作用就是实现同步,被其修饰的方法同时只能被一个线程执行,但它还有一个特性很多人都忽略了,那就是“可见性“,下面我们看一看synchronized如何实现可见性。
JMM关于synchronized的两条规定:
- 线程解锁前,必须把共享变量的最新值刷新到主内存中
- 线程加锁时,必须把自己的“工作内存“中共享变量的值清空,然后从主内存中读取最新的值。
那我们拆分过程如下:
获得互斥锁 -> 清空工作内存 -> 从主内存加载共享变量最新值 -> 执行代码 -> 将更改后的变量值刷新到主内存中 -> 释放锁
下面通过一段代码看一下synchronized如何实现可见性:
public class Demo{
private int a;
private int b;
public void write(){
a = 1; // 步骤a
b = 2; // 步骤b
}
public void read(){
System.out.println(a+b); //步骤c
}
}
public class RunDemo extends Thread{
private boolean flag;
private Demo demo;
public RunDemo(boolean flag, Demo demo){
this.flag = flag;
this.demo = demo;
}
public void run(){
if(flag){
demo.write();
} else {
demo.read();
}
}
}
public static void main(String[] args){
Demo demo = new Demo();
new RunDemo(true, demo).start();
new RunDemo(false, demo).start();
}
多次执行上面的代码,你会发现输出结果会出现0、1、2、3好几种结果,这是为什么呢?关键就是上面步骤a、b、c的执行顺序不确定,或者说赋值操作对读线程不可见。
我们来详细说明一下上面结果出现的一种情况,其他类似:
- 线程1执行write操作,线程2执行read操作
- 情况1:线程交叉执行。当线程1执行完步骤a后,让出CPU时间,而线程2获取CPU时间,开始执行,由于没有保证变量可见性,线程1改变的a的值可能刷新到主内存了,也可能没有,而b显然为0(基本类型变量,类加载完成时会被初始化,参考类加载过程),这时read操作就可能得到0和1两种结果
- 情况2:指令重排+线程交叉执行。当线程1执行时,a、b可能重排序为b、a,然后执行完b后,线程2开始执行,同样由于没有保证可见性,线程2看到的b的值可能是2也可能是0,这时read的结果就是2或者0了
- 其他情况……
怎么解决呢?当然,本小结说的是synchronized关键字,当然用synchronized来解决,大部分人也能理解,就是write和read方法加上synchronized修饰,保证write和read的原子执行,这时候在看执行结果,MD还是会出现两个结果0和3。为什么啊!!
原因就是线程1和线程2的顺序不能保证顺序一定是1->2啊,怎么办?轮到2执行时,让出一次CPU执行时间不就可以来吗,来上代码
public synchronized void write(){}
public synchronized void read(){
try{
Thread.sleep(100); // 休眠100毫秒
}
System.out.println(a + b);
}
最后说明一点:不加synchronized关键字,线程也可能会把其“工作内存“中的值刷入到主内存,这是JVM对这种情况有一定调优,但不要依赖这种调优,比较不可靠,要自己实现可见性保证的代码。
4.3 volatile实现可见性
4.3.1 实现原理
深入来讲:通过内存屏障和禁止指令重排来实现
- 对volatile变量执行写操作时,会在写操作指令后加入一个叫store的屏障指令,这个指令会强制把volatile的变量刷新到主内存中。
- 对volatile变量执行读操作是,会在读操作指令前加入一个叫load的屏障指令,这个指令会强制把volatile的变量从主内存中加载一份最新的。
这样可以看出,volatile实现可见性和synchroized实现可见性基本差不多,区别就是volatile只能修饰变量。
4.3.2 volatile不保证原子性
volatile会保证变量赋值的原子性,也会禁止赋值过程中的指令重排(new一个对象在JVM中可不是一个原子操作,其中会有声明、分配内存空间,初始化等),但volatile不保证变量复合操作的原子性,如自增操作。看代码。
public class Demo{
private volatile int num;
private void doIncrease(){
num ++;
}
public static void main(String[] args){
final Demo demo = new Demo();
for(int i=0; i<500; i++){
new Thread(new Runnable(){
public void run(){
demo.doIncrease();
}
}).start();
}
// 所有线程执行完,才往下执行
if (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(num);
}
}
执行上面程序,你会发现结果中会出现500以及小于500的结果,为啥?不是以及用volatile修饰num变量来吗?
原因就是上面已经提到的,volatile不保证变量复合操作的原子性。
咱们分解一下一次自增操作在JVM中是如何执行的:
1. 读取num在主内存中的最新值
2. 将num加1
3. 将num的值刷入主内存中
我们看到,一次++操作,在指令化后竟然有3步,很明显不是原子操作,并发的时候,自然可能出现线程读取到脏数据的可能。
这种时候就需要别的手段来保证原子性来,如下:
// 方案1
synchroized(this){
num ++;
}
// 方案2
Lock lock = new ReentrantLock();
private void doIncrease(){
try{
lock.lock();
num ++;
}finally{
lock.unlock()
}
}
// 方案3:使用AtomicInteger类
4.3.3 volatile适用的场景
通过上面的例子,我们知道volatile变量只能保证可见性,不能可靠的保证原子性,那究竟什么时候适用volatile变量合适呢?
要在多线程安全的使用volatile变量,最后满足以下条件:
- 对变量的写入操作不依赖与当前值
- 类似num ++这种,结果就依赖与num的当前值,所以不太使用,或者单单使用volatile不能保证多线程下的执行结果正确性
- 满足场景:boolean变量,信号变量,标志变量等,如当前温度
- 该变量的赋值,不能依赖与其他变量的取值情况
- 类似if(m < n){m=xxx;}这中情况,同样单单使用volatile无法保证多线程下的安全性
5. volatile 与 synchronized对比
- volatile不需要加锁,比synchronized更轻量级,性能要好一些
- synchronized即能保证变量的可见性,也能保证块内代码的原子性,而volatile只能保证可见性
综上,volatile的适用场景,synchronized都能实现,但如果满足volatile使用场景时,还是最好选择volatile的,毕竟性能要比synchronized好一点。