volatile与Java内存模型

Mr.LR2022年5月12日
大约 17 分钟

volatile与Java内存模型

volatile关键字虽然从字面上理解起来比较简单,但是要用好不是一件容易的事情。由于volatile关键字是与Java的内存模型有关的,因此在讲述volatile关键之前,我们先来了解一下与内存模型相关的概念和知识,然后分析了volatile关键字的实现原理,最后给出了几个使用volatile关键字的场景。

Java内存模型之JMM

什么是JMM

内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象描述,不同架构下的物理机拥有不一样的内存模型,Java虚拟机是一个实现了跨平台的虚拟系统,因此它也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)。

因此它不是对物理内存的规范,而是在虚拟机基础上进行的规范从而实现平台一致性,以达到Java程序能够“一次编写,到处运行”。

究竟什么是内存模型?

内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节

原则: JMM的关键技术点都是围绕多线程的原子性、可见性和有序性展开的

主内存和本地内存结构

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。本地内存它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化之后的一个数据存放位置 image-20220512160728566

image-20220512160906701

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:

image-20220512160958051

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

JMM规范下,三大特性

可见性

是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更 ,JMM规定了所有的变量都存储在主内存中。

Java内存模型是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。

无论是普通变量还是volatile变量都是如此,区别在于:volatile的特殊规则保证了volatile变量值修改后的新值立刻同步到主内存,每次使用volatile变量前立即从主内存中刷新,因此volatile保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点(Java中普通的共享变量不保证可见性)。

除了volatile关键字能实现可见性之外,还有synchronized,Lock,final也是可以的。

原子性

一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。

有序性

对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。这么说不能说完全不对,在单线程程序里,确实会这样执行;但是在多线程并发时,程序的执行就有可能出现乱序。用一句话可以总结为:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。

一个最经典的例子就是银行汇款问题,一个银行账户存款100,这时一个人从该账户取10元,同时另一个人向该账户汇10元,那么余额应该还是100。那么此时可能发生这种情况,A线程负责取款,B线程负责汇款,A从主内存读到100,B从主内存读到100,A执行减10操作,并将数据刷新到主内存,这时主内存数据100-10=90,而B内存执行加10操作,并将数据刷新到主内存,最后主内存数据100+10=110,显然这是一个严重的问题,我们要保证A线程和B线程有序执行,先取款后汇款或者先汇款后取款,此为有序性。

Java提供了两个关键字volatile和synchronized来保证多线程之间操作的有序性,volatile关键字本身通过加入内存屏障来禁止指令的重排序,而synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现,

在单线程程序中,不会发生“指令重排”和“工作内存和主内存同步延迟”现象,只在多线程程序中出现。 指令重排意义:处理器在不影响最终计算结果的情况下,尽可能提高计算效率。因此指令重排对单线程没影响。

多线程先行发生原则之happens-before

在JMM中,如果一个操作执行的结果需要对另一个操作可见性,或者 代码重排序,那么这两个操作之间必须存在happens-before关系。

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点。我们没有时时、处处、次次,添加volatile和synchronized来完成程序,这是因为Java语言中JMM原则下有一个“先行发生”(Happens-Before)的原则限制和规矩。

它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的底层编译原理之中。

注意:不同操作时间先后顺序与先行发生原则之间没有关系,二者不能相互推断,衡量并发安全问题不能受到时间顺序的干扰,一切都要以happens-before原则为准

下面是Java内存模型下一些”天然的“happens-before关系,这些happens-before关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。

  1. 次序规则:一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作;即前面的操作把变量a赋值为1,则后面的操作肯定知道变量a=1
  2. 锁定规则:一个unLock操作先行发生于后面((这里的“后面”是指时间上的先后))对同一个锁的lock操作;
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的“后面”同样是指时间上的先后。
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作
  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
  8. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。即对象没有完成初始化之前,是不能调用finalized()方法的

volatile

被volatile修饰的变量有2大特点

可见性、有序性

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
  • 所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。

内存屏障

什么是内存屏障?

内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。

  • 内存屏障之前的所有写操作都要回写到主内存,
  • 内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。

对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读,也叫写后读。

四大屏障

image-20220512215700650

happens-before 之 volatile 变量规则

image-20220512215919551

  • 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
  • 当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后。
  • 当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。

总结

  • 在每个 volatile 写操作的前⾯插⼊⼀个 StoreStore 屏障
  • 在每个 volatile 写操作的后⾯插⼊⼀个 StoreLoad 屏障

  • 在每个 volatile 读操作的后⾯插⼊⼀个 LoadLoad 屏障
  • 在每个 volatile 读操作的后⾯插⼊⼀个 LoadStore 屏障

保持可见性

保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见

public class test {
    //static  boolean flag = true; //如果不用volatile关键字没有可见性,程序无法停止
    static volatile boolean flag = true;//加了volatile,保证可见性,程序可以停止

    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            while (flag) {

            }
            System.out.println(Thread.currentThread().getName() + "\t flag被修改为false,退出.....");
        }, "t1").start();

        //暂停2秒钟后让main线程修改flag值
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = false;
        System.out.println("main线程修改完成");
    }

}

volatile变量的读写过程

Java内存模型中定义的8种工作内存与主内存之间的原子操作

read(读取)→load(加载)→use(使用)→assign(赋值)→store(存储)→write(写入)→lock(锁定)→unlock(解锁)

image-20220512221444302

  • read: 作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
  • load: 作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
  • use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
  • assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
  • store: 作用于工作内存,将赋值完毕的工作变量的值写回给主内存
  • write: 作用于主内存,将store传输过来的变量值赋值给主内存中的变量

由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令:

  • lock: 作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
  • unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用

指令禁重排

前面JMM规范的有序性提出,多线程中会发生“指令重排”和“工作内存和主内存同步延迟”现象,因此在多线程场景下需要使用volatile禁止指令重排

volatile的底层实现是通过内存屏障实现指令禁重排

  • 在每一个volatile写操作前面插入一个StoreStore屏障
    • StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
  • 在每一个volatile写操作后面插入一个StoreLoad屏障
    • StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
  • 在每一个volatile读操作后面插入一个LoadLoad屏障
    • LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
  • 在每一个volatile读操作后面插入一个LoadStore屏障
    • LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

示例

//假设线程A执行write方法,线程B执行read方法
public class VolatileTest {
    int i = 0;
    volatile boolean flag = false;
    public void write(){
        i = 2;     // 1 线程A修改共享变量
        flag = true;  // 2 线程A写volatile变量
    }
    public void read(){
        if(flag){                               // 3 线程B读同一个volatile变量
            System.out.println("---i = " + i);      // 4 线程B读共享变量
        }
    }
}

image-20220512223244901

没有原子性

volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。

volatile变量的复合操作(如i++)不具有原子性

/**
 *   volatile i++为什么没有原子性
 * @Author LR
 * @Date 2022/5/11 11:38
 **/
public class volatileii {
    public static void main(String[] args) throws InterruptedException
    {
        MyNumber myNumber = new MyNumber();

        for (int i = 1; i <=10; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    myNumber.addPlusPlus();
                }
            },String.valueOf(i)).start();
        }

        //暂停几秒钟线程
        try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
        System.out.println(Thread.currentThread().getName() + "\t" + myNumber.number);
    }
}
class MyNumber
{
    volatile int number = 0;

    public  void addPlusPlus()
    {
        number++;
    }
}

运行结果一般会小于10000

原因:

  • 首先i++是一个复合操作,读取i的值、对i+1、将i的值写会主存,分3步完成。
  • 如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值, 并执行相同值的加1操作,这也就造成了线程安全失败。

image-20220512225714645

  • 多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,即操作非原子。若数据在加载之后,若主内存count变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致
  • 对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。
  • 由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步

volatile的应用场景

使用 volatile 必须具备的条件

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。
  • 只有在状态真正独立于程序内其他内容时才能使用 volatile。

场景一:状态标志

判断业务是否结束

public class test {
    static volatile boolean flag = true;

    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            while (flag) {

            }
            System.out.println(Thread.currentThread().getName() + "\t flag被修改为false,退出.....");
        }, "t1").start();

        //暂停2秒钟后让main线程修改flag值
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = false;
        System.out.println("main线程修改完成");
    }
}

场景二:开销低的读-写锁策略

虽然volatile 不能实现value++的操作,但是当读操作远多于写时,可结合synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果写操作很少时,该方法能实现很好的性能。

/**
 * 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
 *
 * @Author LR
 * @Date 2022/5/12 23:06
 */
public class UseVolatileDemo {
    private volatile int value;

    public int getValue() {
        return value;   //利用volatile保证读取操作的可见性
    }

    public synchronized int increment() {
        return value++; //利用synchronized保证复合操作的原子性
    }
}

场景三:双重检查(双端锁)

/**
 * 单例模式 双重检查
 *
 * @Author LR
 * @Date 2022/5/12 23:13
 */
public class SafeDoubleCheckSingleton {
    //通过volatile声明,实现线程安全的延迟初始化。
    private volatile static SafeDoubleCheckSingleton singleton;
    //私有化构造方法
    private SafeDoubleCheckSingleton() {
    }
    //双重锁设计
    public static SafeDoubleCheckSingleton getInstance() {
        if (singleton == null) {
            //1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
            synchronized (SafeDoubleCheckSingleton.class) {
                if (singleton == null) {
                    //隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
                    //原理:利用volatile,禁止 "初始化对象"(2) 和 "设置singleton指向内存空间"(3) 的重排序
                    singleton = new SafeDoubleCheckSingleton();
                }
            }
        }
        //2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
        return singleton;
    }
}

参考

上次编辑于: 2022/5/12 23:25:20
贡献者: liurui_60837