并发编程 原子性、可见性、有序性

我们都知道编写并发编程是一件很困哪的事情,并发编程的Bug往往会很诡异地出现,然后又诡异的消失,很难重现也很难追踪,很多时候让人抓狂,但有快速有精准的解决这些Bug,就需要理解这些事情本质,追本溯源,深入分析这些Bug的源头在哪。

虽然我们的CPU、内存、I/O设备不断的在迭代,但他们三者的速度差异始终存在。CPU和内存的速度差异,内存和I/O设备的速度差异。我们的程序不仅需要访问内存还需要访问I/O设备。根据木桶理论,程序的整体性能取决于最慢的操作,即读写I/O设备,也就是单方面提升CPU的速度是无效的。为了合理利用CPU提高性能,平衡这三者之间的差异,计算机体系结构、操作系统、编译程序都作出了贡献,主要为:

  • 内存的速度相对于CPU慢很多,于是CPU增加了缓存,用来均衡与内存的速度差异
  • 操作系统增加了进程和线程,以分时复用CPU,进而均衡CPU和I/O设备的速度差异
  • 编译程序优化程序的执行指令,使得缓存能够得到更合理的使用
    我们的程序享受着这些背后优化的成果,但同时也能够引发一些问题,并发编程问题的根源就来于上面几个点。

源头一:缓存导致程序可见性

可见性:指的是一个线程对一个变量的更新,另一个线程立即能够读取到最新的值。

在单核CPU时代,所有的程序都在一颗CPU上执行,CPU与内存数据的一致性能够得到解决。因为所有的线程都是操作同一个CPU的缓存,一个线程对缓存的写,另一个线程一定能够可见。下图线程A更新了变量V的值,线程B也能够得到最新V的值。


CPU 缓存与内存的关系图

在多核CPU时代,每颗CPU都有自己的缓存。线程A更新的是CPU-1上的变量V的值,线程B更新的CPU-2上变量V的值。很明显,此时这时候变量V的值对于线程A和线程B已经不具备可见性了。因为把CPU缓存更新的值刷新到内存有一定的时间间隔,这段期间其他线程访问变量V的值就不是拿到最新更新的值。

多核 CPU 的缓存与内存关系图

测试代码:

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行 add() 操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}

单线程两次调用add10K()方法返回的值是20000。但两个线程同时执行得到的结果却是10000-20000之间的数。这是为什么呢?
我们假设线程A和B同时执行,那么第一次都会将count=0读到自己的CPU缓存里,执行完cout+=1后更新CPU缓存的值,各自的CPU缓存的值就都是1,同时写入内存后内存值是1,而不是我们期望值2。之后各自CPU缓存里面都缓存了count的值,都是基于各自的count缓存值来计算,所以导致最终count的值是小于20000的,这就是缓存可见性问题。

变量 count 在 CPU 缓存和内存的分布图

源头之二:线程切换带来的原子性问题

由于IO太慢,于是早期的操作系统发明了多进程,这样在单核CPU上也能够同时运行多个任务。操作系统允许某个线程执行一小段时间,例如50ms,过了50ms操作系统就会重新选择一个进程来执行(即任务切换),这个50ms称为"时间片"。


线程切换示意图

在一个时间片内,如果一个进程在进行一个IO操作,例如读取文件,这个时刻进程可以把自己标记位休眠状态并让出CPU的使用权,待文件读进内存,操作系统系统会把这个休眠的线程唤醒,唤醒后就有机会重新获得CPU的使用权了。这里进程在等待IO时之所以会释放CPU使用权,是为了让CPU在这段时间可以做别的事情,这样一来CPU的利用率就上来了。此外,如果这时有另外一个进程也在读文件,读文件的操作就会排队,磁盘驱动完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO利用率也上来了。

早起的操作系统都是基于进程来调度CPU,不同的进程是不共享内存空间的,所以进程切换要做任务切换的就是切换内存映射地址。而进程创建的线程都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都是基于更轻量级的线程来做调度,现在我们提到的“任务切换”都是指的"线程切换"。

java的并发都是基于多线程的,自然也会涉及到任务切换,任务切换的时机大多数是在时间片结束的时候,我们现在都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成。例如count +=1,至少需要三条CPU指令。

  • 指令1:首先,需要把count的值从内存加载到CPU的寄存器
  • 指令2:之后,在寄存器执行+1操作
  • 指令3:最后将结果写入内存,缓存机制导致可能写入的是CPU缓存而不是内存。

操作系统做任务切换,可以发生在任何一条CPU指令,我们假设count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的2,而是1。


非原子操作的执行路径示意图

我们潜意识觉得count+=1这个操作是一个不可分割的整体,就想一个院子一样,线程的切换可以发生在count+=1之前,也可能在之后,而不会发生在之间。我们把一个或者多个操作在CPU的执行过程中不被终端的特性称为原子性。CPU保证的是原子操作是CPU指令级别的,而不是高级语言操作符,这是违背我们直觉的地方,因此,很多时候我们需要再高级语言层面保证操作的原子性。

源头之三:编译优化带来的有序性问题

有序性值的是按照代码先后顺序执行。编译器为了优化性能,有的时候就会改变程序的执行顺序。例如"a=6;b=7"编译器优化后就可能变成"a=7;b=6"这个例子,编译器调整了顺序,但是不影响程序的最终执行结果。不过编译器的优化可能导致意想不到的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;
  }
}

上面的代码看上去没有问题,但是getInstance()方法并不完美。问题出现在new操作上,我们认为的new操作应该是:
1.分配一块内存
2.在内存M上初始化Singleton对象
3.然后M的地址赋值给instance变量

但是实际上可能是这样的:
1.分配一块内存M
2.将M的地址赋值给instance变量
3.最后在内存M上初始化Singleton对象

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

双重检查创建单例的异常执行路径

java里面解决办法:

  • 使用volatile保证内存可见性,每次对变量的写操作都从CPU缓存刷新到内存里面,这样每次读取变量的值一定是最新更新的。变量声明如下:
  static volatile Singleton instance;
  • final关键字修饰。当一个对象包含final修饰的字段时,其他线程能够看到已经初始化的final实例字段,这是线程安全的。
final static Singleton instance;
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容

  • 原子性 synchironized:不可中断锁,适合竞争不激烈,可读性好,依赖JVM。 Lock:可中断锁,多样化...
    BzCoder阅读 954评论 0 3
  • 本文基于:java并发编程实战 极客专栏 王宝令 如果你细心观察的话,你会发现,不管是哪一门编程语言,并发类的知识...
    Charon笔记阅读 671评论 0 1
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,034评论 1 32
  • 必备的理论基础 1.操作系统作用: 隐藏丑陋复杂的硬件接口,提供良好的抽象接口。 管理调度进程,并将多个进程对硬件...
    drfung阅读 3,451评论 0 5
  • 以上代码会重复运行 , 不会停止。 JMM(java内存模型) 若想学习好多线程, 那么必须了解一下JMM Jav...
    尼尔君阅读 1,728评论 0 2