介绍下volatile的底层原理

电子说

1.3w人已加入

描述

介绍

线程安全的三大特性,原子性、可见性、有序性,这三大特性与我们之前整理的内容息息相关。本篇重点介绍下volatile的底层原理,帮助我们更好的理解java并发包。

一、原子性

提供了互斥访问,同一时刻只能有一个线程来对它进行操作。

1. 原子性-synchronizes

2. 原子性-lock

  • lock属于jdk提供的代码层面上的锁,后面单独总结。

3. 原子性-cas

4. 原子性-对比

  • synchronized:不可中断锁(在作用范围内必须等待执行完),适合竞争不激烈。
  • Lock:可中断锁(unlock),竞争激烈时能保持性能常态。
  • Atomic:竞争激烈时能保持性能常态,比Lock性能好,只能同步一个值。

二、可见性

可见性指的是一个线程对主内存的修改,可以被其他线程及时的观察到。导致共享变量在线程间不可见的原因:

  • 线程交叉执行。
  • 重排序结合线程交叉执行。
  • 共享变量更新后的值没有在工作内存与主内存间及时更新。

1. 可见性-synchronizes

JMM中关于synchronized的内存语意:

  • 进入synchronized块的内存语义是把synchronized块内使用到的变量从线程的工作内存中清除,这样synchronized块内使用到该变量就是直接从主内存中获取。
  • 退出synchronized块的内存语义是把synchronized块内对共享变量的修改刷新到主内存。

2. 可见性-volatile

通过加入内存屏障禁止重排序优化来实现。

JMM中关于volatile的内存语意:

  • 当线程写入volatile变量值时就等价于线程退出synchronized同步块(对volatile变量写操作时,会在写操作后加入一条store屏障指令,将本地内存中的共享变量值刷新到主内存)。
  • 读取volatile变量值时就相当于进入到同步块(对volatile变量读操作,会在读操作前加入一条load屏障指令,从主内存中读取共享变量)。

当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器中,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值。

下面看一个volatile内存可见性的例子:

public class VolatileCanSeeTest {


    private static volatile boolean initFlag = false;


    public static void main(String[] args) throws InterruptedException {
        new Thread(() - > {
            log.info("init begin");
            while(!initFlag) {
            }


            // if(!initFlag) {while(true){}} // JIT
            log.info("===success===");
        }).start();


        Thread.sleep(1000);


        new Thread(() - > doSomething()).start();
    }


    public static void doSomething() {
        log.info("doSomething begin");
        initFlag = true;
        log.info("doSomething end");
    }
}

查看对应的汇编代码,可以看到使用volatile汇编指令会加上lock前缀指令:

寄存器

  • 【rsp】是寄存器的意思,在java内存模型一节中我们介绍了工作内存就是寄存器以及cpu告诉缓存等的一个抽象概念。
  • 这里值得一提的是,重排序只是编译器优化的一种表现,上面这段代码主要是编译器优化导致的。编译器会认为这段循环代码在单线程运行中,initFlag变量不会被改变,从而优化为:
if(!initFlag) {
    while(true){
    }
}

这里结合java内存模型对volatile底层原理进行说明:

寄存器

  • 这里添加lock前缀指令的意思是当cpu执行引擎处理完共享变量的计算后,通过asign指令将共享变量回写到工作内存中后会立即将该共享变量通过store和write指令回写到主内存,并且给这两个cpu指令加lock指令锁。
  • 同时,结合cpu缓存一致性协议,当共享变量回写主内存时,经过总线触发MESI协议,另其他包含了该共享变量的缓存行置为无效状态,所以其他线程需要从主内存中重新加载该共享变量到自己的工作内存,从而保证了共享变量的内存可见性。同时,结合[cpu缓存一致性协议,当共享变量回写主内存时,经过总线触发MESI协议,另其他包含了该共享变量的缓存行置为无效状态,所以其他线程需要从主内存中重新加载该共享变量到自己的工作内存,从而保证了共享变量的内存可见性。

3. 可见性-对比

  • synchronized:保证可见性和原子性,但可能会导致线程上下文切换和增加重新调度的开销。
  • volatile:只能保证共享变量的可见性,不能解决读-改-写等的原子性问题。
  • 关于共享内存可见性以及JMM详见:java内存模型

三、有序性

一个线程观察其他线程中的指令执行顺序,由于指令重排序的存在,该观察结果一般杂乱无序。

1. 有序性-happens-before原则

java内存模型中允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。可以通过volatile关键字来保证一定的有序性,可以通过synchronized、lock保证同一时刻线程顺序执行来保证有序性。另外,java内存模型具备先天的有序性,称为**happens-before **原则:

  • 程序次序规则(保证单线程的有序性,不保证多线程的有序性)

    一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。

  • 锁定规则

    一个unlock操作先行发生于对后面同一个锁的lock操作。

  • volatile变量规则

    对一个变量的写操作先行发生于后面对这个变量的读操作。

  • 传递规则

    如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。

  • 线程启动规则

    Thread对象的start()方法先行发生于此线程的每一个动作。

  • 线程中断规则

    对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

  • 线程终结规则

    线程中所有的操作都先行发生于线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。

  • 对象终结规则

    一个对象的初始化完成先行发生于他的finalize()方法的开始。

如果两个操作的执行顺序无法从happens-before原则推导出来,那么就无法保证他们的有序性,虚拟机可以随意的对他们重排序。

2. 有序性-synchronizes

首先,可以明确的一点是:synchronized是无法禁止指令重排和处理器优化的。那么他是如何保证的有序性呢?

synchronized保证的有序性是多个线程之间的有序性,即被加锁的内容要按照顺序被多个线程执行。但是其内部的同步代码还是会发生重排序,只不过由于编译器和处理器都遵循as-if-serial语义,所以我们可以认为这些重排序在单线程内部可忽略。

as-if-serial语义的意思指:不管怎么重排序,单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。简单说就是,as-if-serial语义保证了单线程中,不管指令怎么重排,最终的执行结果是不能被改变的。

3. 有序性-volatile

java内存模型允许编译器和处理器对指令重排序提高运行性能,并且只会对不存在数据依赖性的指令重排序。例:

int a = 1;
int b = 2;
int c = a + b;

变量c的值依赖a和b的值,所以重排序后能保证c的操作在a,b之后,但是a,b谁先执行就不一定,这在单线程下不存在问题。下面看一个多线程下指令重排序的例子:

public class VolatileSerialTest {


    private static int x = 0, y = 0;


    public static void main(String[] args) throws InterruptedException{
        Set< String > resultSet = new HashSet<  >();
        Map< String, Integer > resultMap = new HashMap<  >();


        for (int i = 0; i < 1000000; i++) {
            x = 0;
            y = 0;
            resultMap.clear();
            Thread one = new Thread(() - > {
                int a = y;
                x = 1;
                resultMap.put("a", a);
            });


            Thread two = new Thread(() - > {
                int b = x;
                y = 1;
                resultMap.put("b", b);
            });


            one.start();
            two.start();
            one.join();
            two.join();


            resultSet.add("a=" + resultMap.get("a") + "," + "b=" + resultMap.get("b"));
            log.info("ab结果:{}", resultSet);
        }
    }
}

由于指令重排序导致可能出现的结果有:

寄存器

volatile禁止指令重排序原理:

寄存器

volatile通过加入内存屏障禁止指令重排序。 编译器会根据volatile/synchronized/final等的语义,在特定的位置插入内存屏障。 当遇到特定的内存屏障指令时,处理器将禁止其对应的重排序,保证屏障前面的操作可以被后面的操作可见。

4. 有序性-对比

  • synchronized是一种锁机制,存在阻塞问题和性能问题,而volatile并不是锁,所以不存在阻塞和性能问题。
  • volatile借助了内存屏障来帮助其解决可见性和有序性问题,而内存屏障的使用还为其带来了一个禁止指令重排的附件功能,所以在有些场景中是可以避免发生指令重排的问题的。

结语

本文总结了线程安全的三大特性,同时文中几乎涉及到了所以之前总结过的知识,在阅读过程中可以参考之前的文章进行理解。至此,我们对并发包基础应该有了完整的认识。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分