Java多线程之内存可见性

在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好一点。

参考:《成神之路-基础篇》JVM——Java内存模型

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值