Java多线程之内存可见性

什么叫“可见性”?

一个线程对共享变量值的修改,能够被其他线程及时看到。

共享变量:如果一个变量在多个线程的工作内存中存在副本,那么这个变量就是这几个线程的共享变量。

所有变脸都存在主内存中,每个线程都有自己独立的工作内存,里面保存该线程使用大的变量副本,关系如下图所示:

 

多线程遵守的两条规定

1.线程对共享变量所有的操作都只能在自己的工作内存中完成,无法直接从主内存中读写

2.不同线程之间无法访问其他线程中的变量,线程中变量值的传递需要通过主内存来完成。

 

共享变量可见性的实现原理

线程1对共享变量的修改如果要被线程2及时看到,需要经过2个步骤:

1.把工作内存1中更新过的共享变量值刷新到主内存中

2.把主内存中最新的共享变量的值更新打工作内存2中

以上2个步骤,任意一个出现问题,都会导致共享变量无法被其他线程及时看到,无法实现可见性,导致其他线程读取的数据不准确从而产生线程不安全。

 

共享变量可见性的实现方式

Java语言层面支持的可见性实现方式有2种,分别是synchronizedvolatile

synchronized:能够实现原子性(同步)和可见性

volatile:能够保证可见性,但是无法保证原子性

 

synchronized是如何实现可见性?

java内存模型(JMM)中关于synchronized的两条规定:

1).线程解锁前,必须把共享变量的最新值刷新到主内存中

2).线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新值(注意:加锁与解锁需要是同一把锁)

 

线程执行互斥代码的过程

1.获得互斥锁

2.清空工作内存

3.从主内存拷贝变量的最新副本到工作内存中

4.执行代码

5.将更改后的共享变量值刷新到主内存中

6.释放互斥锁

 

指令重排序

代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或者处理器为了提高程序性能而做的优化。

目前的指令从排序有3种方式:

1.编译器优化的重排序(编译器优化)

2.处理器优化的重排序(处理器优化)

3.内存优化的重排序(处理器优化)

 

as-if-serial

无论如何重排序,程序执行的结果都应该与代码顺序执行的结果一致(java编译器和处理器运行时都会保证在单线程中遵循as-if-serial规则,多线程存在程序交错执行时,则不遵守)

举例:

int num1 = 1;

int num2 = 2;

int num3 = num1 + num2;

上面3行代码,在单线程时,第1、2行可以进行重排序,但是第3行不可以,否则结果将不一样,所以从排序不会给单线程带来内存可见性的问题。

而在多线程中,程序交错执行时,重排序则会造成内存可见性的问题。

 

Synchronized实现可见性的代码,以下的这个类SynchronizedDemo 

public class SynchronizedDemo {
    // 共享变量
    private boolean ready  = false;
    private int     num    = 1;
    private int     result = 0;

    // 写操作
    public void write() {
        ready = true; // 1.1
        num = 2; // 1.2
    }

    // 读操作
    public void read() {
        if (ready) { // 2.1
            result = num * 3; // 2.2
        }
        System.out.println("result = " + result);
    }
    
    private class ReadWriteThread extends Thread {
        private boolean flag;

        public ReadWriteThread(boolean flag) {
            this.flag = flag;
        }

        @Override
        public void run() {
            if (flag) {
                write();
            } else {
                read();
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
        synchronizedDemo.new ReadWriteThread(false).start();
        synchronizedDemo.new ReadWriteThread(true).start();
    }

}

上面的这一段代码重排序后的执行顺序可能是

1. 1.2-->2.1-->2.2-->1.1;  result=0

2. 1.1-->2.1-->2.2-->1.2;  result=3

......

导致共享变量在线程之间不可见的原因

1.线程的交叉执行

2.重排序结合线程交叉执行

3.共享变量更新后的值,没有在工作内存与主内存间及时刷新

 

安全的代码,加入synchronized关键字

    // 写操作
    public synchronized void write() {
        ready = true; // 1.1
        num = 3; // 1.2
    }

    // 读操作
    public synchronized void read() {
        if (ready) { // 2.1
            result = num * 2; // 2.2
        }
        System.out.println("result = " + result);
    }

 

volatile是如何实现可见性?

深入来说,是通过加入内存屏障和禁止重排序优化来实现的。

对volatile变量执行写操作时,会在写操作后加入一条store屏障指令,会将cup数据强制刷新到主内存中去

对volatile变量执行读操作时,会在读操作前加入一条load屏障指令,强制缓存器中的缓存失效,每次使用都要去主内存中重新获取数据

通俗地讲,volatile变量在每次被访问的时候,都强迫从主内存中读取该变量的值,而当该变量在发生变化时,又会强迫变量讲最新的值刷新到主内存中,这样,任意时刻,不同的线程总能看到该变量的最新值。

 

线程写volatile变量的过程:

1.改变线程工作内存中volatile变量副本的值

2.将改变的副本的值从工作内存中刷新到主内存中

 

线程读volatile变量的过程:

1.从主内存中读取volatile变量的最新值到工作内存中

2.从工作内存中读取volatile变量的副本

 

volatile不能保证原子性,请看下面的代码:

public class VolatileDemo {
    private volatile int num = 0;

    public int getNum() {
        return this.num;
    }

    public void increase() {
        // num++,不是原子操作,这里会先读取,再加1
        this.num++;
    }

    public static void main(String[] args) {
        final VolatileDemo volatileDemo = new VolatileDemo();
        // 创建500个子线程,执行increase方法,每次都让num加1
        for (int i = 0; i < 500; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    volatileDemo.increase();
                }
            }).start();
        }
        // 等到所有子线程执行完毕,eclipse这里是1,IntelliJ IDEA执行用户代码的时候,实际是通过反射方式去调用,而与此同时会创建一个Monitor Ctrl-Break 用于监控目的,所有是2
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        // 由于num使用了volatile关键字,所以预期值应该是500
        System.out.println("当前的num值=" + volatileDemo.getNum());
    }
}

执行后,会发现,有时候不是500,而是499或者498或者497等等,原因是num++不是原子操作,volatile只能保证变量修改后的可见性,但是无法保证原子性,请看下面的步骤:

假设现在num=5

1.线程A读取num的值,线程A的工作内存中,num=5

2.线程B也读取了num的值,线程B的工作内存中,num=5

3.线程B进行加1操作,线程B的工作内存中,num=6

4.线程B写入最新的num值,主线程中num的值变为6

5.线程A执行加1操作,线程A的工作内存中,num=6

6.线程A写入最新的num值,主线程中num的值变为6

这样,两个线程各自执行了一次加1操作,但是主线程中的数据num=6,这就是由于volatile没办法保证代码的原子性,使得读和写不是一起的

解决方案:

1.使用synchronized关键字

2.使用ReentrantLock

3.使用AtomicInteger

 

volatile的适用场景

1.对变量的写入操作不依赖其当前值

  不满足:num++、count = count * 5

  满足:boolean值变量,记录温度变化的变量等等

2.该变量没有包含在具有其他变量的不变式中

  不满足:low < up

一般的应用场景很多会不满足其中一个,所以volatile是使用没哟synchronized这么广泛。

 

synchronized与volatile比较

1.volatile不需要加锁,比synchronized更轻量级,不会阻塞线程

2.从内存的角度,volatile读操作相当于加锁,写操作相当于解锁

3.synchronized既能保证原子性又能保证可见性,而volatile只能保证可见性无法保证原子性

 

posted @ 2019-05-20 15:26  迷失于笔迹  阅读(711)  评论(0编辑  收藏  举报