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

本篇文章会从并发编程的三大问题可见性、原子性、有序性的起源开始介绍,然后介绍可见性在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并行执行时,就可能出现可见性问题:

揭开并大三大问题之可见性问题的神秘面纱_第1张图片
如上图,目前有俩个CPU,他们都各自将内存中的X读取到自己的缓存中。接下来CPU1修改了X的值:

揭开并大三大问题之可见性问题的神秘面纱_第2张图片
发现了没有?虽然X的值已经被修改了,但CPU2缓存中的值依旧是5,缓存不一致了,也就是说线程1的修改对线程2来说不可见,这就是可见性问题。

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

原子性问题源头

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

揭开并大三大问题之可见性问题的神秘面纱_第3张图片
在一个时间片内,如果一个进程在进行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缓存而不是内存)

也就是说,任务切换可能发生在上面三条指令任意一条,那这会带来什么问题呢?
揭开并大三大问题之可见性问题的神秘面纱_第4张图片
在上图中线程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;

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

int a = 10;int c = a + 1;int b = 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();
    }
}

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

从JAVA内存模型(JMM)来解释一下可见性问题:
揭开并大三大问题之可见性问题的神秘面纱_第5张图片
在JAVA内存模型中,每个线程都会有自己的工作内存,工作内存是私有的,对于其他线程不可见的。线程要读写一个变量,必须先将变量从主内存中读取到工作内存中,然后在工作内存中再进行操作,操作完后再写回主内存。也就是说,线程1需要先将flag读到自己的工作内存中。

揭开并大三大问题之可见性问题的神秘面纱_第6张图片
接下来,启动线程2,线程将flag读取到进行的工作内存后,修改,并写回主内存中:
揭开并大三大问题之可见性问题的神秘面纱_第7张图片
此时主内存中的flag已经被修改成true了,但由于线程1的工作内存flag还是false,因此线程1会一直死循环下去。

如何解决可见性问题

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

private volatile boolean flag = false;

当然,加锁,比如使用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,表示独占

揭开并大三大问题之可见性问题的神秘面纱_第8张图片

2、线程2也将缓存行读到自己得到工作内存中,此时缓存行在线程1和线程2中都存在,缓存行的状态会被修改为S共享状态
揭开并大三大问题之可见性问题的神秘面纱_第9张图片

3、线程1修改flag值,缓存行被修改,缓存行状态变为M,此时有个叫总线的东西会感知到这个变化,
将其他线程中的缓存行状态改为I
揭开并大三大问题之可见性问题的神秘面纱_第10张图片

4、线程1将缓存行写回主内存中,缓存行的状态重新变为独占
揭开并大三大问题之可见性问题的神秘面纱_第11张图片

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协议

你可能感兴趣的:(并发编程,java,开发语言,并发)