美团面试官:小伙子,说一下volatile关键字原理吧?

前言

在讲述Volatile关键字之前,我们先大概讲一下cpu多核并发缓存架构,再到JMM,即java内存模型,最后到volatile关键字。

JMM(Java内存模型)

多核并发缓存架构的引入

为了解决CPU和主内存速度交互的不匹配问题,计算机在设计的时候在中间加几级缓存(一般放在CPU内部的,这里是为了好看画到中间了),高速缓存读取速度非常快,CPU和高速缓存交互,程序结束后,会把缓存中的数据同步到主内存再回写到硬盘。


而Java线程的内存模型和CPU缓存模型是类似的,是基于CPU缓存模型建立起来的。Java线程的内存模型是标准化的,屏蔽掉了底层不同计算机的区别。如下图显示:


和CPU一样,线程A为了解决跟主内存速度不匹配问题,会把这个共享变量copy到线程的工作内存。线程读取共享变量数据是和工作内存中变量的副本做交互。这里的工作内存类似于缓存。

JMM数据原子操作

JMM有8个原子操作,按照使用流程来排序,分别如下:


在这里介绍java的数据原子操作,是为了更好的为下面的问题铺垫。

CPU缓存不一致问题

对于多核CPU而言,当共享变量同时被加载到缓存中并在多个核心中都同时进行操作时,当核心A修改了变量a后,核心B不知道a已经做了修改,继续推进核心B的线程工作,这样子,程序就会出现问题,因此就存在了缓存不一致问题。

为了解决CPU缓存不一致问题,工程师主要使用了两种方式。早期主要使用总线加锁方式。

总线加锁:即cpu从主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其他cpu核心没有办法去读或者写这个数据,直到这个cpu使用完数据并释放锁之后,其他cpu核心才能读取该数据。该方式可以用java内存模型和java数据原子操作来体现,如下图:


当一个线程读取主内存中某个变量的时候,就会对这个变量加锁,其他CPU(线程)想从主内存读这个变量的数据是读不到的,直到这把锁释放了才能读取到该变量的值,并在其他CPU中做运算。在read之前会执行lock操作,标识为线程独占状态,在write写回主内存的时候会做个unlock操作,解锁后其他线程可以锁定该变量。早期的CPU为了解决可见性,一致性问题,把一个并行执行的程序最终变成串行执行。

显然该方案不可行,后来,工程师使用了MESI缓存一致性协议来解决该问题

MESI缓存一致性协议:多个cpu从主内存读取同一个数据到各自的高速缓存中,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其他cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效.
如下图:


CPU和内存之间通过总线相连接。各个线程都从主内存中读数据,实现了并行。当线程2修改initFlag变量后,执行store操作时,此时会把这个工作内存中修改的数据initFlag=true变量的值回写到主内存中,最后执行write替换主内存中的值。一旦执行store此原子操作,该数据会通过总线回写到主内存,MESI缓存一致性协议有个CPU总线嗅探机制(通过硬件实现):当其中某个线程(这里是线程2)把修改的变量的值从工作内存往主内存回写的时候,只要数据通过总线,其他的CPU(这里是线程1)会对这个总线做一个监听,对总线中感兴趣的变量不断监听数据流动,发现有其他CPU(这里是线程1)感兴趣的变量的时候,MESI缓存一致性协议就会通过总线嗅探机制把这个其他CPU(这里是线程1)中工作内存中的同一个变量的值置为无效。然后线程1再执行循环操作的时候,发现initFlag失效了,就重新从主内存去readinitFlag,而此时主内存中的initFlag已经被修改过了(为true),线程1就能拿到最新的值了。就能通过MESI缓存一致性协议和总线嗅探机制可以让程序达到缓存一致性。

java代码演示不可见性

说完了CPU缓存不一致解决方案,接下来,我们通过java代码演示一下多线程下缓存不一致性的问题,也称为不可见性。

public class JMM {
    private static  boolean initFlag = false;

    public static void main(String[] args) throws InterruptedException {

        new Thread(() -> {
            while (!initFlag) {
            }
            System.out.println("hello...");

        }).start();

        TimeUnit.SECONDS.sleep(2);
        new Thread(() -> {
            System.out.println("......");
            initFlag = true;
            System.out.println("修改成功....");
        }).start();
    }
}

查看输出结果:代码运行后结果只输出线程二的信息。主要原因在于两个核CPU不可见性。



可以看出,多线程情况下,java代码的共享变量initFlag也存在不可见性,那么,java是怎么解决缓存不一致问题的呢?引入了Volatile关键字

Volatile的作用

我们通过使用volatile修饰变量initFlag,查看代码运行状态。

public class JMM {
    private static volatile boolean initFlag = false;

    public static void main(String[] args) throws InterruptedException {

        new Thread(() -> {
            while (!initFlag) {
            }
            System.out.println("hello...");

        }).start();

        TimeUnit.SECONDS.sleep(2);
        new Thread(() -> {
            System.out.println("......");
            initFlag = true;
            System.out.println("修改成功....");
        }).start();
    }
}

线程2对initFlag的修改,线程1中的initFlag是可以感知到的,即java的关键字volatile可以解决缓存一致性问题。


那volatile是如何解决缓存一致性问题的呢?

Volatile缓存可见性实现原理:
底层实现主要是通过汇编lock前缀指令,该指令会锁定这块内存区域的缓存(缓存行锁定)并写回主内存。

IA-32架构软件开发者手册对lock指令的解释:
1)会将当前处理器缓存行的数据立即写回系统内存
2)这个写回内存的操作会引起其他CPU里缓存了该内存地址的数据无效(MESI)

看不懂上面在说什么?没关系,记住3点一共:

1)将当前处理器缓存行的数据立即写回系统内存
2)这个写回内存的操作会引起其他CPU里缓存了该内存地址的数据无效(MESI)
3)在store前加锁lock,write后unlock

通过对上面的Java程序转为汇编代码查看(之前看b站的老师转过,具体我也没转,挺麻烦的,这里保留了他的截图)


Java实现缓存一致性问题

由Java内存模型可以看到,参考了CPU的缓存模型,因此多核多线程情况下存在缓存一致性问题。由第5点可知,java在处理缓存一致性问题的时候,使用了volatile关键字进行处理。那么,java是如何通过实现volatile解决缓存不一致问题呢?java参考了CPU解决思路,同时把总线加锁和MESI缓存一致性协议进行了结合, 结合MESI缓存一致性协议的加锁的实现 = volatile,也就解决了缓存一致性问题。
具体如下:

线程1和线程2可以同时从主内存中读取共享变量initFlag到各自工作内存,然后调用各种执行引擎处理该变量,而对该共享变量加上volatile指令后,在线程二执行initFlag= true的时候,会加上lock的前缀汇编指令,该指令使得CPU底层会把修改的工作内存副本变量的值立即写回系统内存。而且这个数据经过总线时,让CPU总线上MESI缓存一致性协议以及结合CPU总线嗅探机制让其他CPU缓存里面那个相同的副本变量失效,同时会锁定这块内存区域的缓存(也就是即将store到内存区域的时候先锁一下), 在store回主内存的时候,会先做个lock操作,然后回写完了后,做一个unlock(write后)操作。 这样子,就可以解决缓存一致性问题了。

和总线加锁的区别

volatile的底层实现是:结合MESI缓存一致性协议的加锁的实现,该实现和总线加锁的区别在哪里?

volatile把这个锁的密度大大减小,性能非常高,一开始read的时候各个CPU都能read,但是在回写主内存的时候其他CPU没法运算。若volatile不加lock操作和unlock操作的话,只使用缓存一致性协议和总线嗅探机制,是否有问题??

不加lock,数据刚往总线这边同步(即刚刚回写主内存),这个数据还没写到主内存中的变量中(即这个变量initFlag还没改为true),而其他CPU通过MESI缓存一致性协议里面的总线嗅探机制监听到这个initFlag的值的变动,马上把其他线程中的工作内存的值失效。而其他CPU(线程1)还在持续执行while操作,发现initFlag失效,就马上从主内存中读initFlag,这个线程2还没马上把initFlag修改过的值写到主内存,因此此时其他CPU(线程1)读的还是原来的老数据。所以lock前缀指令必须对store之前加一把锁,在真正write到主内存后,再去把这把锁释放掉,就是为了防止数据还是有些误读(时间差的问题),这个锁的密度非常小,只是对主内存赋一个值,对内存操作,速度得多块,内存级别的并发量至少每秒几十万上百万的操作。只是做变量地址的赋值操作,在这么一个短时间内加一把锁非常快!!!

volatile不保证原子性

讲到这了,大家应该都清楚并发编程三大特性:可见性、原子性、有序性
而Volatile可以保证可见性和有序性,但是不能保证原子性,原子性可以借助synchronized的锁机制或者并发包下的原子类进行处理,这个原子性下一篇博客会进行总结。
代码演示一下volatile不保证原子性。

public class VolatileAtomicTest {
    public static volatile int num = 0;

    public static void increase() {
        num++;// num = num + 1
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 100000; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) {
            t.join();
        }
        System.out.println(num) ;// 结果是小于或等于1000000
    }
}

不保证原子性原因

线程1做完++操作,然后一旦做完assign操作后,就会写主内存。但是出现一种情况,当线程1做完++后,刚assign值的时候,这个回写操作还没做的时候,线程2 也做了num++了,同时也assign结束了,两个线程就同时向主内存回写。谁先回写的(哪个线程的数据先到达总线),那个线程就会通过volatile关键字给该数据加一把锁,后到达的回写的操作看到该数据有锁之后,就不能加锁了,同时线程1加锁成功了后,执行store的时候数据经过总线,MESI缓存一致性协议结合CPU总线嗅探机制把线程2的工作内存的值失效掉。那么线程2做的num++的操作已经没有意义了(丢失了),下次线程2再做num++的时候,重新从主内存中read到这个线程1写回的值。这个时候上一次线程2做的num++的操作丢失了,也就丢失了一次加1操作。
网络上有些博客说是因为i++不是一个原子操作,但是我更觉得这种方式才是解释为什么不保证原子性的根本原因。


volatile保证有序性

volatile主要是通过内存屏障来防止指令重排达到解决有序性问题!

最后

文章的最后为大家准备了一些Java架构学习资料,学习技术内容包含有:Spring,Dubbo,MyBatis, RPC, 源码分析,高并发、高性能、分布式,性能优化,微服务 高级架构开发等等,祝大家都能拿到心仪的offer!欢迎大家关注公众号:前程有光,自行领取!

你可能感兴趣的:(美团面试官:小伙子,说一下volatile关键字原理吧?)