zoukankan      html  css  js  c++  java
  • 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只能保证可见性无法保证原子性

  • 相关阅读:
    Solidity通过合约转ERC20代币
    各种开源协议区别
    shell脚本之函数
    shell脚本之循环和循环控制
    shell脚本之if判断以及case多分支选择
    shell脚本之数组
    shell脚本之变量
    nginx常用内置变量
    nignx配置文件详解
    nginx源码安装./configure常见参数详解
  • 原文地址:https://www.cnblogs.com/xyabk/p/10894384.html
Copyright © 2011-2022 走看看