zoukankan      html  css  js  c++  java
  • 你还不懂可见性、有序性和原子性?

     

    前言

    今天开始,王子准备开始一个新的专栏:并发编程专栏

    并发编程无论在哪门语言里,都属于高级篇,面试中也尝尝会被问到。想要深入理解并发编程机制确实不是一件容易的事,因为它涉及到计算机底层和操作系统的相关知识,如果对这部分知识不是很清楚可能会导致理解困难。

    在这个专栏里,王子会尽量以白话和图片的方式剖析并发编程本质,希望可以让大家更容易理解。

    今天我们就来谈一谈可见性、有序性和原子性都是什么东西。

    并发编程的幕后

    进入主题之前,我们先来了解一下并发编程的幕后。

    随着CPU、内存和I/O设备的不断升级,它们之间一直存在着一个矛盾,就是速度不一致问题。CPU的速度高于内存,内存的速度又高于I/O设备。

    我们写的代码中大多数内容都会经过内存处理,有些内容会去读写I/O设备,根据木桶理论,整体的性能取决于最慢的操作,就是I/O设备,所以单单提升CPU的性能是不够的。

    为了最大化体现出CPU的性能,计算机底层主要做了三部分优化:

    1.CPU增加了缓存,比内存速度更快,平衡内存的速度

    2.操作系统增加了进程和线程,可以对CPU分时复用

    3.编译程序会进行指令的重排,使缓存更好的发挥性能

    我们平时的工作中其实一直都享受着这些优化后的成果,但同时他们也会导致一些很难找到原因的BUG。

    什么是可见性

    首先我们就来看看什么是可见性。

    一个线程对共享变量的修改,另一个线程可以感知到,我们称其为可见性

    在单核时代,其实是不存在可见性问题的,因为所有的线程都是在一个CPU中工作的,一个线程的写操作对于其他的线程一定是可见的。

    但是多核CPU出现后,每个CPU都有自己的缓存,多个线程在不同的CPU中处理数据就会导致不可见问题。

    假设变量v的值是1, 两个线程同时执行了v++操作,首先会从内存中读取变量v的数据到各自的CPU缓存中,这个时候两个CPU缓存中的v都是1,执行v++后,两个变量v都变成了2,然后再写回内存,内存中的变量v就变成了2。

    但其实我们想看到的结果v最终应该是3才对。

    在CPU1缓存中执行v++后,CPU2缓存无法感知的到,这就是可见性问题。而由于可见性问题导致的最终数据不正确,就是线程安全问题。

    什么是原子性

    由于I/O的速度太慢,早期的操作系统发明了多进程,就是允许某个进程执行一小段时间后,重新选择一个进程来执行,这个过程叫做任务切换,而这一小段的时间我们称其为时间片。

    现在操作系统的任务切换一般指的是更轻量级的线程切换,java的并发编程是基于多线程的,自然也会存在线程切换。

    一般会在时间片结束的时候进行线程切换,java语言中执行的一段简单的代码往往需要多条CPU的指令实现,比如count++这部分代码,至少需要三条CPU指令:

    1.首先把count从内存中读取到CPU的寄存器中

    2.在寄存器中执行+1操作

    3.最后将count的值写入内存中(可能写入到CPU的缓存中)

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

    这就是线程切换导致的数据错误问题,我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性,CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

    什么是有序性

    有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“x=1;y=2;”编译器优化后可能变成“y=2;x=1;”。

    在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候调整了语句的顺序可能导致意想不到的 Bug。

    在 Java 领域一个经典的案例就是利用双重检查创建单例对象,代码如下:

    public class Singleton {
      static Singleton instance;
      static Singleton getInstance(){
        if (instance == null) {
          synchronized(Singleton.class) {
            if (instance == null)
              instance = new Singleton();
            }
        }
        return instance;
      }
    }

    假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

    这个过程看上去是不是无懈可击,没有漏洞?

    答案是否定的,问题就出在了new操作上,我们以为的new操作是这样的:

    1.分配一块内存空间

    2.在这块内存空间上初始化Singleton实例对象

    3.把这个对象的内存地址赋值给instance变量

    但实际上由于指令重排,优化后的过程是这样的:

    1.分配一块内存空间

    2.把这快内存空间的内存地址赋值给instance变量

    3.在这块内存空间上初始化Singleton实例对象 

    那么这样调换顺序后会发生什么呢?

    我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

    总结

    使用并发编程开发,往往会出现很多难以找到原因的BUG,通过对可见性、有序性和原子性的分析,可以为我们排查并发导致的BUG提供一些思路。

    CPU缓存会导致可见性

    指令重排会导致有序性

    线程切换会导致原子性

    以上就是本篇文章的三个核心内容,那我们下篇文章继续。

    往期文章推荐:

    JVM专栏

    消息中间件专栏

  • 相关阅读:
    CF1137C Museums Tour(tarjan+DP)
    Educational Codeforces Round 65 (Rated for Div. 2)
    Codeforces Round #559(Div.1)
    委托
    类库
    is 和 as 运算符
    面向对象 接口
    抽象类
    面向对象 多态
    访问修饰符 程序集 静态方法
  • 原文地址:https://www.cnblogs.com/lm970585581/p/13977897.html
Copyright © 2011-2022 走看看