内存可见性和指令重排序——volatile

内存可见性

影响线程安全的原因有很多,内存可见性的也会引起线程不安全。
以下面的案例来看,线程启动后,t1不断进行循环,直到t2输入数字后改变状态,t1线程才会结束。

	private static int count;
    public static void main(String[] args) {
        Thread t1 = new Thread(()-> {
            while (count == 0) {
                ;
            }
            System.out.println("t1执行结束");
        });
        Thread t2 = new Thread(()-> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入count:");
            count = scanner.nextInt();
            System.out.println("count = "+count);
        });
        t1.start();
        t2.start();

    }

通过下面的打印我们可以知道,线程t1并没有执行结束,仍然处于while循环中。那么为什么count值改变了,结果却没有发生改变。这就涉及到了内存可见性的问题。
在这里插入图片描述

内存可见性原因

在上面的代码中,由于循环体中没有内容,线程t1的while循环中需要执行两个操作。

第一个是load操作,即从内存中读取count数据到cpu寄存器中;
第二个是compare操作,即进行count==0的判断操作。如果条件成立,则会顺序执行,条件不成立,就会跳出while循环执行后续代码。

前置条件: 这段循环体中不存在代码,只能反复执行load和compare操作。
1)在循环操作过程中load比compare指令所花费的时间多上许多,一次load操作所花费的时间足够compare执行成千上万次。
2)在JVM中发现load操作每次执行的结果都是相同的(在线程t2修改count值之前已经经过许多次load了)
优化操作: 在发现这样的操作十分的无效以后,JVM开始了他的优化操作:在第一次真正执行了load操作以后,JVM后续继续执行后面的代码以后都不进行load操作了,而是直接读取一开始的load值。
后果: 经过JVM的优化操作,后续的count值即使修改了也无法更新,这也就导致了while循环无法结束。

volatile

为了防止JVM的自作聪明引起的祸端,于是创建了volatile关键字,即反复无常。提醒JVM不可以对带有这个关键字的变量进行优化。

private volatile static int count;

对count变量添加volatile以后,再执行代码,我们可以成功修改count值并更新到while循环中。
在这里插入图片描述

IO操作

在线程t1中,因为while循环中没有语句,最后导致了load操作被优化。如果我们在while循环中添加了打印语句,load操作是否还会被优化?
结果:在执行了下面的代码以后,修改count值是可以停止while循环的。
**原因:**我们知道,load操作是因为过程浪费资源且没有改变才会被优化,而在while循环中,打印的IO操作所耗费的资源比load操作要多得多,并且每次的IO操作所带来的结果是不相同的,于是就形成了 “load操作浪费,但IO操作所花费的资源更多” 。因此load操作没有被优化。

    private volatile static int count;
    public static void main(String[] args) {
        Thread t1 = new Thread(()-> {
            while (count == 0) {
                System.out.println("t1");
            }
            System.out.println("t1执行结束");
        });
        Thread t2 = new Thread(()-> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入count:");
            count = scanner.nextInt();
            System.out.println("count = "+count);
        });
        t1.start();
        t2.start();
    }

指令重排序

指令重排序也是编译器的一种优化策略。

指令重排序的原因

众所周知,在代码编译之后会进行一系列的二进制指令。理论上应该是依次执行,但是编译器比较智能,它为了提高效率 会根据情况安排指令的执行顺序。如果发生了这种情况,就可能和最初写的代码顺序发生差别。
在单线程的情况下,指令重排序的操作基本上不会发生问题,编译器可以准确的识别并重新排序指令以达到效率提升且逻辑等价的效果。
但是在多线程的情况下,指令重排序的操作可能会造成代码逻辑不等价。

案例

以下面的代码为例
在这段代码中 lazy = new SingletonLazy() 大致可以分为三个步骤(实际上有成百条指令)

  1. 申请内存空间
  2. 调用构造方法(对内存空间进行初始化)
  3. 把此时内存空间的地址赋值给lazy引用
    而在指令重排序的操作下,这三个步骤就不一定是依次排序的了。
private volatile static SingletonLazy lazy = null;
    
    public static SingletonLazy getLazy() {
        if (lazy == null) {
            synchronized (lazy) {
                if (lazy == null) {
                    lazy = new SingletonLazy();
                }
            }
        }
        return lazy;
    }

在多线程的情况下,我们可以通过下面的时间轴查看一二
如下图所示,在进入加锁操作以后,理论上应该调用构造方法,但是这时候执行的是3号指令,执行完以后则代表着lazy非空
但是lazy中的对象是未初始化的(里面的成员都是0)
按照时间轴,此时轮到线程B执行接下来的操作。现在的想法是此时lazy = null 加锁情况下线程B只能阻塞等待。
但是 前面我们知道了 lazy已经被赋予地址了,它本身是非空的(即使它里面的对象是未初始化的) 因此线程B直接跳过if返回未初始化对象lazy。这样的操作无疑是十分危险的。
在这里插入图片描述

解决方案

要解决这种问题,volatile关键字就起到了重要的作用。调用volatile之后,针对该对象的读写操作过程不会出现重排序问题。

总结

volatile关键字的出现是专门针对内存可见性的场景来解决问题的,并不能解决多线程中多个线程修改同一变量的问题。虽然使用加锁操作也可以在一定程度上解决内存可见性的问题,但加锁所耗费的资源比volatile多得多。
源码☞内存可见性源码

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值