《菜鸟读并发》java内存模型之volatile关键字初识

在阅读本文前,请思考以下的面试题?

  • volatile是什么?
  • volatile的特性
  • volatile是如何保证可见性的?
  • volatile是如何保证有序性的?
  • volatile可以保证原子性吗?
  • 使用volatile变量的条件是什么?
  • volatile和synchronized的区别
  • volatile和atomic原子类的区别是什么?

这一章主要是讲解volatile的原理,在开始本文前,我们来看一张volatile的思维导图,先有个直观的认识。

并发线程不安全源头

  • 源头1:缓存导致的可见性问题
  • 源头2:线程切换带来的原子性问题
  • 源头3:编译优化带来的有序性问题

什么是volatile

目前的操作系统大多数都是多CPU,当多线程对一个共享变量进行操作时,会出现数据一致性问题

Java编程语言允许线程访问共享变量,那么为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量,或者把这个变量声明成volatile,可以理解volatile是轻量级的synchronized

使用volatile可以在Java线程内存模型确保所有线程看到这个变量的值是一致的,在多个处理器中保证了共享变量的“可见性”

volatile两个核心,三大性质

两大核心:JMM内存模型(主内存和工作内存)以及happens-before

三条性质:原子性,可见性,有序性

volatile性质

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性
  2. 禁止进行指令重排序。(实现有序性
  3. 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。(不能实现原子性
  4. volatile不会引起上下文的切换和调度

总结:volatile保证了可见性和有序性,同时可以保证单次读/写的原子性

相关的Cpu术语说明

什么是可见性?

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

我们先来一个12306抢票的例子.

public class Ticket {
    private long sum = 20000;
    public void sendTicket(){
        for(int i = 0;i<10000;i++){
            //卖出一张票
            sum-=1;
        }
    }

    public static void main(String[] args) throws Exception{
        Ticket ticket = new Ticket();
        //创建售票窗线程1
        Thread thread1 = new Thread(new Work(ticket));
        thread1.setName("售票窗线程-" + 1);
        //创建售票窗口线程2
        Thread thread2 = new Thread(new Work(ticket));
        thread2.setName("售票窗线程-" + 2);

        thread1.start();
        thread2.start();
        // 等待两个线程执行结束
        thread1.join();
        thread2.join();
        System.out.println("sum="+ticket.sum);
    }

}


class Work implements Runnable {
    
    private final Ticket ticket;
    public Work( Ticket ticket) {
        this.ticket = ticket;
    }

    @Override
    public void run() {
        try {
            ticket.sendTicket();
            System.out.println(Thread.currentThread().getName() + "启动时间是" + System.currentTimeMillis());
        } catch (Exception e1) {
            e1.printStackTrace();
        }
    }
}

第一次执行结果:

售票窗线程-2启动时间是1574601485313

售票窗线程-1启动时间是1574601485317

sum=2396
再执行一个结果:

售票窗线程-2启动时间是1574601475353

售票窗线程-1启动时间是1574601475355

sum=8029

直觉告诉我们应该是0,因为两个售票窗都卖了10000张票,sum 的值就是0,但实际上的执行结果是个0 到 1000之间的随机数。啥情况啊这是?为啥和我们想象中的不太一样呢?

我们假设线程A和线程B同时开始执行,那么第一次都会将 sum=2000读到各自的CPU缓存里,执行完sum-=1之后,各自CPU缓存里的值都是1,同时写入内存后,我们会发内存中是19999,而不是我们期望的19998。

之后由于各自的CPU缓存里都有了sum的值,两个线程都是基于 CPU缓存里的sum值来计算。这就是缓存的可见性问题。

这里可能又同学会提出疑问。那如果是都使用的上面的缓存中的值,那应该最后的结果也应该是10000才对呀。没错,理想状态下是这样的。但是我们知道两个线程不是同时启动的,有一个时差。

循环10000 次 sum-=1操作如果改为循环1亿张,总票数改为2亿张,并且使用CyclicBarrier让线程能在同一时刻触发,你会发现效果更明显,最终sum的值接近1亿,而不是 0。

改造之后的代码:


public class Ticket {
    private long sum = 200000000;
    public void sendTicket(){
        for(int i = 0;i<100000000;i++){
            //卖出一张票
            sum-=1;
        }
    }

    public static void main(String[] args) throws Exception{
        Ticket ticket = new Ticket();
        //创建线程1抢票
        CyclicBarrier cyclicBarrier = new CyclicBarrier(2);
        //创建售票窗线程1
        Thread thread1 = new Thread(new Work(cyclicBarrier,ticket));
        thread1.setName("售票窗线程-" + 1);
        //创建售票窗口线程2
        Thread thread2 = new Thread(new Work(cyclicBarrier,ticket));
        thread2.setName("售票窗线程-" + 2);

        thread1.start();
        thread2.start();
        // 等待两个线程执行结束
        thread1.join();
        thread2.join();
        System.out.println("sum="+ticket.sum);
    }

}


class Work implements Runnable {

    private final CyclicBarrier cyclicBarrier;
    private final Ticket ticket;
    public Work(CyclicBarrier cyclicBarrier, Ticket ticket) {
        this.cyclicBarrier = cyclicBarrier;
        this.ticket = ticket;
    }

    @Override
    public void run() {
        try {
            /**
             * CyclicBarrier类的await()方法对当前线程(运行cyclicBarrier.await()代码的线程)进行加锁,然后进入await状态;
             * 当进入CyclicBarrier类的线程数(也就是调用cyclicBarrier.await()方法的线程)等于初始化CyclicBarrier类时配置的线程数时;
             * 然后通过signalAll()方法唤醒所有的线程。
             */
            cyclicBarrier.await();
            ticket.sendTicket();
            System.out.println(Thread.currentThread().getName() + "启动时间是" + System.currentTimeMillis());
        } catch (InterruptedException | BrokenBarrierException e1) {
            e1.printStackTrace();
        }
    }
}

执行结果:

线程-2启动时间是1574601120948

线程-1启动时间是1574601120948

sum=99637289

在单核cpu的石器时代,我们所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

例如在下面的图中,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量a的值,那么线程B之后再访问变量 a,得到的一定是 a 的最新值(线程 A 写过的值)。

file

在多核CPU的时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。比如下图中,线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存,很明显,这个时候线程A对变量a的操作对于线程B而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的值读到内部缓存(L1,L2或者其他)后再进行操作,但是操作完不知道何时再写回内存。

file

从上面的分析,我们可以知道,多核的CPU缓存会导致的可见性问题。

volatile是如何保证可见性的

instance = new Singleton();//instance是volatile变量

让我们来看看在处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作的时候,cpu会做什么事?

转换成汇编代码如下:

file

有volatile修饰的共享变量进行写操作的时候会多出第二行汇编代码,也就是jvm会向处理器发送一条Lock前缀的指令,Lock前缀的指令在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存
  2. 这个写回内存的操作会使在其他CPU缓存了该内存地址的数据无效,保证各个处理器的缓存是一致的 (通过一致性协议来实现的)

一致性协:每个处理器通过嗅探在总线上传播的数据来检查自己的缓存的值是否过期了,当处理器发现自己的缓存行对应的内存过期,在下次访问相同内存地址时,强制执行缓存填充,从系统内存中读取。

简单理解:volatile在其修饰的变量被线程修改时,会强制其他线程在下一次访问该变量时刷新缓存区。

volatile的两条实现原则
  1. Look 前缀指令会引起处理器缓存回写到内存。Lock 前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大,对干intel486和Pentiuln处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。 IA-32 处理器和 Iniel 64 处理器使用 MESI (修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候, IA-32和Intel64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在 Pentium 和 P6famaly 处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处干共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充

小结

  1. Lock前缀的指令会引起处理器缓存写回内存;
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

volatile是如何保证有序性

问题:代码编译优化之后会发生什么?

有序性指的是程序按照代码的先后顺序执行,和某一行代码执行的指令的顺序。

我们平时都以为代码都是从上到下一条一条执行的,然后再计算机实际的执行过程中不是这样子的,编译器为了优化性能,有时候会改变程序中语句的先后顺序或者是指令的顺序

在解释有序性前,我们先来看看什么是指令重排?

导致程序有序性的原因是编译优化,指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。但是在多线程环境下,有些代码的顺序改变,有可能引发逻辑上的不正确。有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们的程序的性能就堪忧了,所以合理的方案是按需禁用缓存或者编译优化。

接下来我们来看一个著名的单例模式双重检查锁的实现

单例模式的特点

  • 一个类只允许产生一个实例化对象。
  • 单例类构造方法私有化,不允许外部创建对象。
  • 单例类向外提供静态方法,调用方法返回内部创建的实例化对象。

单例模式有9种实现模式,因为本编文章主要是介绍编译优化带来的有序性问题,我们这里举一个懒汉式单例模式的例子来说明,如果不知道其他几种方式的话请查查详解9种Java单例模式的文章

代码示例:

class Singleton {
    private static Singleton instance = null;
    private Singleton() {
    }
    public static Singleton getInstance() {
        if (instance == null) {                    //步骤1
            synchronized (Singleton.class) {
                if (instance == null)              //步骤2
                    instance = new Singleton();    //步骤3
            }
        }
        return instance;
    }
}

在以上代码中,instance不用volatile修饰时,输出的结果会是什么呢?我们的预期中代码是这样子执行的:线程A和B同时在调用getInstance()方法,线程A执行步骤1,发现instance为 null,然后同步锁住Singleton类,接着执行步骤2再次判断instance是否为null,发现仍然是null,然后执行步骤3,开始实例化Singleton。这样看好像没啥毛病,可是仔细一想,发现事情并不简单。
这时候,我们来我们先了解一下对象是怎么初始化的?

  • 对象在初始化的时候分三个步骤
m = allocate();   //1、分配对象的内存空间
ctorInstance(m);  //2、初始化对象
instance = m;     //3、使instance指向对象的内存空间

程序为了优化性能,会将2和3进行重排序,此时执行的顺序是1、3、2,在单线程中,对结果是不会有影响的,可是在多线程程序下,问题就暴露出来了。这时候我们回到刚刚的单例模式中,在实例化的过程中,线程B走到步骤1,发现instance不为空,但是有可能因为指令重排了,导致instance还没有完全初始化,程序就出问题了

为了禁止实例化过程中的重排序,我们用volatile对instance修饰

下面我们来改进一下,使用懒汉式双重检查终极版(面试手写推荐)
与第六种方法不同的是,此方法给singleton的声明上加了关键字volatile,进而解决了低概率的线程不安全问题。

volatile 关键字,有两个作用,一是可以禁止指令重排序,二是保证了程序的可见性,在它赋值完成之前,就不会调用读操作(singleton==null)。

class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {
    }
    public static Singleton getInstance() {
        if (instance == null) {                    //步骤1
            synchronized (Singleton.class) {
                if (instance == null)              //步骤2
                    instance = new Singleton();    //步骤3
            }
        }
        return instance;
    }
}

volatile内存语义如何实现

对于一般的变量则会被重排序(重排序分析编译器重排序和处理器重排序),而对于volatile则不能,这样会影响其内存语义,所以为了实现volatile的内存语义JMM会限制重排序

其重排序规则如下:

  1. 如果第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前,其前面的所有普通写操作都已经刷新到主内存中;

  2. 如果第一个操作volatile写,不管第二个操作是volatile读/写,禁止重排序。

  3. 如果第二个操作为volatile写时,则不管第一个操作是啥,都不能重排序。这个操作确保volatile写之前的操作不会被编译器重排序到volatile写之后;

  4. 如果第二个操作为volatile读时,不管第二个操作是volatile读/写,禁止重排序

volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:

  1. 在每一个volatile读操作后面插入一个LoadLoad屏障,用来禁止处理器把上面的volatile读与后面任意操作重排序

  2. 在每一个volatile写操作前面插入一个StoreStore屏障,用来禁止volatile写与前面任意操作重排序

  3. 在每一个volatile写操作后面插入一个StoreLoad屏障,用来禁止volatile写与后面可能有的volatile读/写操作重排序

  4. 在每一个volatile读操作前面插入一个LoadStore屏障,用来禁止volatile写与后面可能有的volatile读/写操作重排序

保守策略下,volatile的写插入屏障后生成的指令示意图:

Storestore 屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了,Storestore 屏障将保障上面所有的普通写在volatile写之前刷新到主内存。

这里比较有意思的是, volatite 写后面的 StoreLoad 屏障的作用是避免volatile写与后面可能有的volatile 读/写操作重排序。

因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障。为保证能正确实现volatile的内存语义,JMM在采取了保守策略,在每个volatile写的后面,或者在每个 volatile读的前面插入一个StoreLoad屏障。

保守策略下,volatile的读插入屏障后生成的指令示意图:

上面的内存屏障插入策略非常保守,在实际执行中,只要不改变volatile写-读的内存语义,编译器可根据情况省略不必要的屏障

举个例子:

public class Test {
    int a ;
    volatile int v1 = 1;
    volatile int v2 = 2;
    public  void readWrite(){
        int i = v1;//第一个volatile读
        int j = v2;//第二个volatile读
        a = i+j://普通读
        v1 = i+1;//第一个volatile写
        v2 =j+2;//第二个volatile写
    }

    public synchronized void read(){
        if(flag){
            System.out.println("---i = " + i); 
        }
    }}

针对readWrite方法,编译器在生成字节码的时候可以做到如下的优化:

注意:最后一个storeLoad屏障不能省略。因为第二个volatile写之后,方法立即return,此时编译器无法精准判断后面是否会有vaolatile读或者写。

什么是原子性?

我们知道在物理世界中,原子是不可以再分的,那么在mydql中也有事务原子性的概念,说的是一个事务里面的所有操作要么全部执行,要么全部不执行,他们是一个整体,不存在只执行了一部分这种情况。

我们知道IO和CPU的速度的差异是指数级别的,所以早期的操作系统就发明了多进程,用来减小这两者之间速度的巨大差异,所以我们在单核的CPU上我们也可以一边听着歌,一边写 Bug,这个就是多进程的功劳。

在操作系统中,通常会允许某个进程执行一小段时间,例如 50 毫秒,过了50毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个50毫秒称为“时间片”。

线程切换示意图

在一个时间片内,如果一个进程进行一个IO操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让CPU的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。

这里的进程在等待IO时之所以会释放CPU使用权,是为了让CPU在这段等待时间里可以做别的事情,这样一来CPU的使用率就上来了。

此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了。

但是,虽然看似简单,支持多进程分时复用在操作系统的发展史上却具有里程碑意义,Unix就是因为解决了这个问题而名噪天下的。早期的操作系统基于进程来调度CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。

Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存);

操作系统做任务切换,可以发生在任何一条CPU指令执行完,是的,是CPU指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的 2,而是1。

我们潜意识里面觉得count+=1这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在count+=1之前,也可以发生在count+=1之后,但就是不会发生在中间。我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

线程切换示意图

如何正确使用volatile变量

在某些情况下,如果读操作远远大于写操作,volatile 变量可以提供优于锁的性能优势。

可是volatile变量不是说用就能用的,它必须满足两个约束条件:

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。

第一个条件的限制使volatile变量不能用作线程安全计数器。虽然 i++ 看上去类似一个单独操作,实际上它是一个读取-修改-写入三个步骤的组合操作,必须以原子方式执行,而 volatile不能保证这种情况下的原子操作。正确的操作需要使i的值在操作期间保持不变,而volatile 变量无法做到这一点。

volatile和synchronized区别

  1. volatile比synchronized执行成本更低,因为它不会引起线程上下文的切换和调度
  2. volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  3. volatile只能用来修饰变量,而synchronized可以用来修饰变量、方法、和类。
  4. volatile可以实现变量的可见性,禁止重排序和单次读/写的原子性;而synchronized则可以变量的可见性,禁止重排序和原子性。
  5. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  6. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

volatile和atomic原子类区别

  1. Volatile变量可以确保先行关系,即写操作会发生在后续的读操作之前

  2. 但是Volatile对复合操作不能保证原子性。例如用volatile修饰i变量,那么i++操作就不是原子性的。

  3. atomic原子类提供的atomic方法可以让i++这种操作具有原子性,如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作,但是atomic原子类一次只能操作一个共享变量,不能同时操作多个共享变量

总结

总结一下volatile的特性``

  • volatile可见性;对一个volatile的读,总可以看到对这个变量最终的写
  • volatile有序性;JVM底层采用“内存屏障”来实现volatile语义
  • volatile原子性;volatile对单个读/写具有原子性(32位Long、Double),但是复合操作除外,例如i++

如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。 感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

原创不易,欢迎转发,关注公众号“码农进阶之路”,获取更多面试题,源码解读资料!
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值