• 揭开并大三大问题之可见性问题的神秘面纱


    本篇文章会从并发编程的三大问题可见性、原子性、有序性的起源开始介绍,然后介绍可见性在JAVA中的体现,以及如何解决,介绍volatile的原理。

    并发编程三大问题的源头

    在并发编程中,一直存在三大问题可见性、原子性、有序性,只有理解了他们,我们才能具备分析并发编程中诡异BUG的能力。那这些问题最初是怎么诞生的呢?

    其实,虽然这些年CPU、内存、IO设备都在不断迭代,速度越来越快,但他们之间有一个核心矛盾一直存在,那就是三者的速度差异。我们可以形象的描述为CPU上的一天,等于内存上的一年(假设CPU执行一条普通指令需要一年,那么CPU读写内存需要等待一年的时间),等于I/O设备的十年。

    在程序中大部分语句都要访问内存,有些还要访问I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体性能取决于最慢的操作,也就是读写I/O设备,因此单方面提升CPU性能是无效的。

    为了更合理的利用CPU的高性能,平衡三者之间的速度差异,计算机体系结构、操作系统、编译程序都做出了共享,主要体现为:

    1. CPU增加了缓存,以平衡CPU和内存之间的速度差异
    2. 操作系统增加了进程、线程,以分时复用CPU,进而平衡CPU和I/O设备的速度差异
    3. 编译程序优化指令执行顺序,使得缓存能够得到更合理的利用

    现在几乎我们所有的程序都在默默享受着这些成功,但天下没有免费的午餐,它也给我们带来了很多并发编程中诡异的问题。

    可见性问题源头

    CPU为了平衡与内存直接的速度差异,增加了缓存。当多个线程使用的是同一个CPU时,使用的是同一个缓存,缓存一致,因此不存在可见性问题。但是,但我们多核CPU并行执行时,就可能出现可见性问题:

    在这里插入图片描述
    如上图,目前有俩个CPU,他们都各自将内存中的X读取到自己的缓存中。接下来CPU1修改了X的值:

    在这里插入图片描述
    发现了没有?虽然X的值已经被修改了,但CPU2缓存中的值依旧是5,缓存不一致了,也就是说线程1的修改对线程2来说不可见,这就是可见性问题。

    总结一句话:缓存的加入导致可见性问题的产生

    原子性问题源头

    为了平衡CPU和I/O的速度差异,操作系统引入了进程、线程,以及CPU的分时复用。分时复用指的是每个线程每次只会被分配一个CPU时间片,当时间片到了,线程就需要让出CPU,让其他线程可以获取到CPU执行。

    在这里插入图片描述
    在一个时间片内,如果一个进程在进行IO操作,如读写文件,这时候进程可以把自己标记为"休眠"状态,并让出CPU使用权,等文件读入内存后,操作系统会把这个休眠的进程唤醒,唤醒后进程就有机会重新获取CPU的使用权了。

    进程在等待IO时让出CPU使用权,这样可以让CPU在这段时间内去做一些其他的事情,从而提高了CPU的使用率。此外,如果此时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成第一个进程的读操作后,发现有排队的任务,会立即启动下一个读操作,这样I/O的使用率也提升了。

    不过,虽然多进程的分时复用提升了CPU利用率,也给我们带来了原子性问题。原因是CPU的任务切换可以发生在任意一条CPU指令执行完。但高级语言中的一条语句,往往需要多条CPU指令才能够完成,如count += 1,就需要至少三条CPU指令

    1. 指令1:将变量count从内存加载到CPU寄存器中
    2. 指令2:在寄存器中执行+1操作
    3. 指令3:将结果写回内存中(缓存机制可能导致写入的是CPU缓存而不是内存)

    也就是说,任务切换可能发生在上面三条指令任意一条,那这会带来什么问题呢?
    在这里插入图片描述
    在上图中线程1在做count+1后还未将结果写回内存中,此时发生了任务切换。线程2从内存中加载count的值,此时还是0,因此线程2就在0的基础上+1,最终将count=1写回内存中。线程1重新执行,将之前计算的结果count=1写回内存中。

    发现了没有?俩个线程都进行了一次+1操作,但最终的结果确实count=1,这是因为任务切换破坏了我们的原子性。

    有序性问题源头

    编译器为了能够更好的利用CPU缓存,会对我们的代码进行"合理"的重排序。也就是说,最终代码的执行顺序可能与我们的代码编写顺序不同。

    int a = 10;int b = 3;int c = a + 1;
    • 1
    • 2
    • 3

    比如上面的代码,最终的执行顺序可能是①③②

    int a = 10;int c = a + 1;int b = 3;
    • 1
    • 2
    • 3

    这是因为在单线程环境下,②和③哪个先执行,最终的结果都是一样的。但因为①已经将a加载到CPU中了,如果此时能够先执行②的话,就可以省去再次从内存中读取a的步骤,而是直接从缓存中读取a,这样就能够更好的利用缓存。

    但重排序只能保证在单线程环境下,最终的结果是正确的,如果是在多线程环境下,就可能发生意想不到的后果,这就是编译器优化带来的的可见性问题。

    JAVA中的可见性问题

    前面我们介绍了并发编程三大问题的源头,接下来我们介绍一下可见性问题在JAVA中的体现。首先,我们会先看一个有可见性问题的代码,然后从JAVA内存模型层面解释可见性问题的原因。

    public class VisibilityTest {
        private boolean flag = false;
        private int count = 0;
    
        public void refresh() {
            flag = false;
            System.out.println(Thread.currentThread().getName() + "修改flag:" + flag);
        }
    
        public void load() {
            System.out.println(Thread.currentThread().getName() + "开始执行.....");
            while (!flag) {
                //TODO  业务逻辑
                count++;
            }
            System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
        }
    
        public static void main(String[] args) throws InterruptedException {
            VisibilityTest test = new VisibilityTest();
    
            // 线程threadA模拟数据加载场景
            Thread threadA = new Thread(() -> test.load(), "threadA");
            threadA.start();
    
            // 让threadA执行一会儿
            Thread.sleep(1000);
            // 线程threadB通过flag控制threadA的执行时间
            Thread threadB = new Thread(() -> test.refresh(), "threadB");
            threadB.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    在上面的代码中,我们先启动了一个线程一直进行count++操作,直到flag被修改为false跳出循环。过了一秒后,我们启动第二个线程,将flag改为false,照理说线程1应该跳出循环,但实际上线程1并没有跳出循环,换句话说就是线程2对flag的修改对线程1不可见,也就是出现了可见性问题。

    从JAVA内存模型(JMM)来解释一下可见性问题:
    在这里插入图片描述
    在JAVA内存模型中,每个线程都会有自己的工作内存,工作内存是私有的,对于其他线程不可见的。线程要读写一个变量,必须先将变量从主内存中读取到工作内存中,然后在工作内存中再进行操作,操作完后再写回主内存。也就是说,线程1需要先将flag读到自己的工作内存中。

    在这里插入图片描述
    接下来,启动线程2,线程将flag读取到进行的工作内存后,修改,并写回主内存中:
    在这里插入图片描述
    此时主内存中的flag已经被修改成true了,但由于线程1的工作内存flag还是false,因此线程1会一直死循环下去。

    如何解决可见性问题

    解决可见性问题很简单,只需要使用volatile关键字就可以了。比如上面的代码,我们只需要在flag前面加一个volatile关键字,就可以保证可见性,线程1最后会成功的跳出循环。

    private volatile boolean flag = false;
    
    • 1

    当然,加锁,比如使用synchronized也可以保证可见性,毕竟synchronized会保证在任意时刻都只能一个线程访问共享资源,单线程自然也就没有可见性问题了,不过这样对我们的性能影响就大了,因此如果只是想保证可见性问题,使用volatile就够了。

    为什么volatile能够解决可见性问题

    为什么volatile能保证可见性?这还是要从Java内存模型说起,首先对volatile修饰的变量写操作会被立马写回出内存。但写回主内存还不够,因为其他线程的工作内存中可能也存在这个变量的副本,那么他就不会从主内存中读取最新的内容。因此volatile还有另外一层作用,就是让其他线程工作内存中的缓存失效,这样其他线程就会到主内存中读取最新的值,也就解决了可见性问题。

    MSEI协议

    让其他线程的缓存失效是怎么做到的?这就要说到我们的MSEI协议了,MSEI协议是四个单词的缩写:

    1. M:修改modify,指的是缓存行被修改过,与主内存中的值不相同,需要被写回主内存中
    2. S:共享Share,缓存行在其他缓存中也存在
    3. E:独占Exclusive,缓存行只在当前缓存中存在
    4. I:无效Invalid,当前缓存行是无效的,需要重新从主内存中读取。
      缓存行:CPU数据的读写是以缓存行为单位的,一个缓存行默认是16kb

    仅仅看概念是很难理解的,接下来我们演示一下这四种状态是如何转换的。我们还是以上面的JAVA内存模型为例:

    1、首先线程1将缓存行读取到自己的工作内存中。此时因为缓存行只在线程1的工作内存中存在,因此缓存行的状态为E,表示独占

    在这里插入图片描述

    2、线程2也将缓存行读到自己得到工作内存中,此时缓存行在线程1和线程2中都存在,缓存行的状态会被修改为S共享状态
    在这里插入图片描述

    3、线程1修改flag值,缓存行被修改,缓存行状态变为M,此时有个叫总线的东西会感知到这个变化,
    将其他线程中的缓存行状态改为I
    在这里插入图片描述

    4、线程1将缓存行写回主内存中,缓存行的状态重新变为独占
    在这里插入图片描述

    5、线程2读取工作内存的缓存行,发现缓存行的状态为Invalid,重新从主内存中读取最新的值

    总结

    最后小结一下本篇文章的内容

    1. 首先我们介绍了并发编程三大问题的起源------为解决CPU、内存、I/O三者的速度差异
      a. CPU增加缓存,以平衡CPU和内存的速度差异(导致了可见性问题的产生)
      b. 操作系统增加了进程、线程以及CPU分时复用(导致了原子性问题的产生)
      c. 编译器为了更好的利用CPU缓存,引入了指令重排(导致了有序性问题的产生)
    2. 接下来我们以一个代码例子介绍了JAVA中的可见性问题
    3. 然后通过JAVA内存模型再次解释可见性问题的产生
    4. 接着介绍可见性问题的解决方案volatile、synchronized。其中volatile是我们的重点
    5. 以及volatile为什么能解决可见性问题?
      a. 能够将修改的值立马写回主内存中
      b. 能够让其他线程的缓存行失效
    6. 最后关于缓存行失效,我们又介绍了MSEI协议
  • 相关阅读:
    Anaconda常用命令
    JeecgBoot 3.4.0 版本发布,微服务重构版本
    详解Al作画算法原理
    软件设计模式系列之二十二——状态模式
    【强化学习笔记】强化学习中的常见符号
    使用Python随机生成数据的一些方法
    ANR系列之八:疑难ANR问题处理记录
    MyBatis入门案例
    STM8应用笔记5.8位定时器应用之一
    案例:Ajax实现省市联动,选择省后动态显示市和区
  • 原文地址:https://blog.csdn.net/weixin_44335140/article/details/127603885