同学,你对volatile熟悉么?

627 阅读14分钟

什么是volatile

volatile是Java中的一个关键字,是Java虚拟机提供的轻量级同步机制,作用是保证共享变量在多线程之间的可见性,确保所有线程在同一时刻读取到的共享变量的值是一样的。即在多线程环境下,一个线程对共享变量的修改,其他线程可以及时看到这个已经被修改的共享变量的最新值,因此在读取被volatile修饰的变量时总会返回最新写入的值。但是,我们必须要明确一个点就是volatile关键字是不能保证共享变量的原子性(原子性是指一个操作是不可中断的,即使是在多线程一起执行的时候,一个操作一旦开始,就不会被其他线程打断)。volatile对于i++(非原子操作)这种包含读-改-写三次的操作是无法保证原子性,这种情况下可以使用synchronized、Lock、或原子类来保证线程安全。

volatile的作用

(1) 保证共享变量可见性,不保证原子性

(2) 禁止指令重排

volatile与synchronized的区别

  • volatile只能修饰实例变量和类变量,而synchronized只能修饰方法和代码块。
  • volatile和synchronized都是Java中的关键字,而volatile是Java虚拟机提供的轻量级同步机制,可以看做是synchronized的轻量级同步实现。
  • volatile不保证原子性,而synchronized可以保证原子性。

如何使用volatile

  • 例子安排上
  1. 保证共享变量的可见性
public class VolatileDemo1 {
    //加了volatile关键字,当main线程将共享变量num修改后
    //会通知持有共享变量num的A线程,保证共享变量的可见性
    private volatile static int num = 0;
    public static void main(String[] args) throws InterruptedException {
        //main线程修改了共享变量num的值,
        //如果不将共享变量加volatile关键字,则此时的A线程不知道共享变量的值已经被改变了,此时的A线程会进入死循环
        new Thread(() -> {
            while (num == 0) {
    			//
            }
        },"A").start();
        TimeUnit.SECONDS.sleep(2);
        //main线程修改共享变量num的值,并写回了主存中
        num = 1;
        System.out.println(num);
    }
}
  1. 不保证原子性
/**
 * 不保证原子性
 */
public class VolatileDemo2 {
    //加了volatile也无法保证原子性
    private volatile static int num=0;							
   //加了synchronize或lock锁就可以保证原子性
    private static void add(){
        num++;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(()->{
                for (int i1 = 0; i1 < 1000; i1++) {
                    add();
                }
            }).start();
        }

        while(Thread.activeCount()>2){ //main线程、gc线程
            //只要还有除了main线程还有gc线程之外的线程在跑,主线程就让出CPU不继续往下执行
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName()+"->"+num);
    }
}

**如果以上例子不加 synchronize 或 lock 如何保证原子性?**
**使用原子类来解决原子性问题**
/*
 * volatile 不保证原子性
 * 解决办法:
 * (1)使用synchronize或lock锁
 * (2)使用java.util.concurrent.atomic下的原子类
 */
public class VolatileDemo2 {
    //加了volatile也无法保证原子性
    //private  static int num=0;

    //使用原子类即可以保证原子性
    private static AtomicInteger num1 = new AtomicInteger();

    /*private static ReentrantLock lock = new ReentrantLock();*/

    //加了synchronize或lock锁就可以保证原子性
    private static void add(){
        /*lock.lock();
        try{
            //num++ ;不是一个原子性操作
            num++ ;
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }*/
        //num++;
        num1.getAndIncrement(); //AtomicInteger原子类中的一个自增方法,底层是通过CAS实现的,利用操作系统实现的并发,效率极高

    }

    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(()->{
                for (int i1 = 0; i1 < 1000; i1++) {
                    add();
                }
            }).start();
        }

        while(Thread.activeCount()>2){ //main线程、gc线程
            //只要还有除了main线程还有gc线程之外的线程在跑,主线程就让出CPU不继续往下执行
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName()+"->"+num1);
    }
}

深入了解volatile关键字

了解完volatile的基本使用后,我们需要从底层出发,进一步认识volatile关键字。

硬件系统架构

计算机在运行程序时,每条指令都是在CPU中执行的,在程序执行过程中必然会涉及数据的读写操作。CPU中程序运行的数据是存储在主内存中的,这时就可能出现以下问题:CPU中执行指令的速度比读取主内存中数据的速度要快得多,如果CPU中程序运行所用到的数据都需要与主内存打交道,那么CPU就会因为读取主内存数据的低效率而不能发挥其高效的执行效率。

  • 为了解决以上所述问题,就有了CPU高速缓存,每个高速缓存为每个CPU所独有,每个高速缓存存放的是对应CPU执行的指令和所需数据,这样CPU避免了直接与主内存打交道,而是与速度比主内存高得多的高速缓存打交道,从而充分发挥CPU的执行效率。

image.png

  • CPU为了提高访问数据的效率,在每个CPU核心上都会有多级小容量但速度极快的缓存。缓存中是以缓存行为单位存储的,当CPU执行一条读内存指令时,内存地址所在缓存行中的内容都会加载到CPU缓存中(一次加载整个缓存行中数据)

  • 高速缓存使的CPU的读取速度得到了极大的提高。由于高速缓存分布在不同的CPU中,因此必须保障某个CPU写入后的数据在各个CPU的高速缓存区存储的数据是保持一致的,有以下两种方式可以保证写入数据后的数据一致性:

(1)直写。透过本级缓存直接把数据写到下一级缓存或者主内存中。如果对应的数据被缓存了,则直接更新缓存中的内容,或者直接丢弃缓存中的内容。

(2)回写。缓存不会立即把写操作写到下一级缓存中,而是仅修改本级缓存中的数据,并把对应的缓存数据标记为“脏”数据。“脏”数据会触发回写,把里面的内容写到下一级缓存或者对应的主内存中,回写后就可以保证数据的一致性了。

Java内存模型(Java Memory Model)

在简单了解现代计算机的硬件系统架构后,我们还需要了解一下——Java内存模型(Java Memory Model),简称JMM。JMM是一种概念、约定,并不存在于现实中。

  • 线程的工作内存,主内存 在Java中,不同线程拥有各自的私有的工作内存,当线程需要读取或修改某个变量时,不能直接去操作主内存中的变量,而是需要将这个变量读取到线程的工作内存中,而这个读取到的内存就是主内存中的一个变量副本,当该线程修改其变量副本的值后,其它线程并不能立刻读取到新值,需要将修改后的值刷新到主内存中,其它线程才能从主内存读取到修改后的值,这就是保证内存可见性。到这里你可能会问,那么其他线程是怎么知道主内存的值已经被修改了呢?后面介绍

  • 8种内存交互操作(⚠️Java内存模型只要求下述操作必须按顺序执行,而没有保证必须是连续执行,因此没办法保证像i++这种非原子操作的线程安全性

  1. lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态。
  2. unlock(解锁):作用于主内存的变量,释放一个处于锁定状态的变量,释放后的变量才可以被其他线程锁定。
  3. read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用。
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中获取的变量放入工作内存中.
  5. use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到的变量的值,就会使用到这个指令。
  6. assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中。
  7. store(存储):作用于主内存中的变量,它把工作内存中一个变量的值传送到主内存中,以便后续的write使用。
  8. write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中.
  • 简单图解

image.png

缓存一致性问题

下面介绍一个经典的缓存一致性问题——i++,相信大家对i++为什么会产生缓存一致性问题有过些许了解。

  • i++出现缓存一致性问题的原因:i++不是一个原子操作,包含读改写三次的操作,在多线程下会产生线程安全问题。

  • i++的执行过程:

    (1)线程首先从主内存中获取变量i的值

    (2)将变量i复制到CPU的高速缓存中

    (3)CPU执行+1操作

    (4)将CPU的执行结果写入到高速缓存中

    (5)将执行结果保存到主内存中

    以上第四步最容易出现缓存不一致的问题,因为当Cpu将修改结果写入到缓存后,缓存中的最新数据什么时候刷新到主内存并不知道,此时其他CPU从主内存拿到的数据并不是最新的,这就出现了缓存不一致的情况。

  • 从以上i++的执行过程可以举例出多线程下i++操作出现的问题:

    如有A、B两个线程分别执行i++操作,CPU1和CPU2分别读取变量i的初始值1,并将初始值存储到各自的高速缓存中。CPU1执行线程A,将i进行+1操作,并将执行结果写回主内存中,CPU2执行线程B,也对i进行+1操作,也将执行结果写回主内存中,线程A、B执行结束后,i的值变为2,而不是我们所期待的3,这就出现了缓存一致性问题。

缓存一致性协议

缓存一致性协议是多核CPU保证缓存一致性的一种方法。

  • 解决缓存一致性问题的解决方法:
  1. 通过在总线上加锁的方式来解决。

    总线加锁是通过独占的方式实现的,同一时刻只能只有一个CPU能够运行,其余CPU必须阻塞,效率非常低。

  2. 通过缓存一致性协议(MESI协议)解决

    • 缓存一致性协议是一种高速缓存一致性协议,并且支持回写高速缓存。MESI协议和窥探技术的出现就是为了解决当代多核CPU的缓存一致性问题,volatile就是该协议的实现。

    • 窥探技术的核心思想是所有数据传输都发生在一条共享的总线上,所有的CPU都能看到这条总线。高速缓存是独立的、而主内存是共享的,所有的内存访问都需要经过仲裁,同一个指令周期中,只有一个高速缓存可以读写内存。高速缓存不只是在做内存传输的时候和CPU打交道,而是不停地在窥探总线上发生的数据交换监听其他高速缓存做了什么操作。所以,当一个高速缓存代表他所属的CPU去读写内存时,其他CPU都会得到通知,以此来使自己的高速缓存保持同步。只要某个CPU写入内存,其他CPU马上就会知道这块内存在它们自己的高速缓存中对应的缓存行已经失效。

  • 缓存系统操作的最小单位是缓存行,任何多核系统中的缓存行都处于这4种状态之一:
  1. Modifield(已修改状态),表示该缓存行的数据已经被所属的CPU修改了,如果一个缓存行处于M状态,那么它在其他CPU缓存中的副本马上会变成I无效状态。

  2. Exclusive(独占状态),如果一个CPU持有某个E状态的缓存行,那其他CPU就不能同时持有该内容的缓存行,所以叫独占。这意味这,如果其他CPU原本也持有同一缓存行,那么它会马上变成I失效状态。

  3. Shared(共享状态),缓存行的内容是与主内存内容保持一致的一份拷贝,在S状态下的缓存行只能被读取,不能被写入,表示该数据是多个CPU核心共享的。

  4. Invalid(无效状态),该CPU缓存中无该缓存行,或缓存中的缓存失效。

当CPU想写某个缓存时,如果CPU没有独占权,那么它必须先发一条“我要独占权”的请求给总线,这时会通知其他CPU,把他们拥有的同一缓存行的拷贝置为I状态。只有在获取独占权后,CPU才能开始修改数据,并且此时这个CPU知道,这个缓存行只有一份拷贝在自己的缓存中,所以不会发生冲突。反之,如果有其他CPU想读取这个缓存行,必须等待独占或已修改的缓存行回到S状态。

volatile为什么无法保证原子性

参考文章:[为什么volatile不能保证原子性而Atomic可以?]

什么是内存屏障(Memory Barrier)?

  • 内存屏障(memory barrier)是一个CPU指令。
    • 确保一些特定操作执行的顺序;
    • 影响一些数据的可见性(可能是某些指令执行后的结果)。 编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制将对缓存的修改操作立即写入主存中。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。
  • volatile的禁止指令重排序就是通过内存屏障来实现的。

内存屏障(memory barrier)和volatile什么关系? 上面的虚拟机指令里面有提到,如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。这意味着如果你对一个volatile字段进行写操作,你必须知道:1、一旦你完成写入,任何访问这个字段的线程将会得到最新的值。2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。

明白了内存屏障(memory barrier)这个CPU指令,回到前面的JVM指令:从load到store到内存屏障,一共4步,其中最后一步jvm让这个最新的变量的值在所有线程可见,也就是最后一步让所有的CPU内核都获得了最新的值,但中间的几步(从Load到Store) 是不安全的,中间如果其他的CPU修改了值将会丢失。

volatile i++ 线程安全问题图解

  • volatile修饰的变量,在多线程环境下,随时获取的都是最新的值。而它只是针对值的获取,而i++却是包括了值的获取、值的计算、值的赋值这三个关键步骤。当 i 加了 volatile 关键字,我们确实能够获取到最新值。但如果这个线程获取到了最新值,突然其它的线程抢占到了时间片,也是获取到最新值,因此最终两个线程执行i++最终的值是重复的。

image.png

参考书籍《Java面试一战到底-基础卷》