前言

CPU 、内存、I/O设备之间的速度差距十分大,为了提高CPU的利用率并且平衡它们的速度差异。计算机体系结构、操作系统和编译程序都做出了改进:

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

但是,每一种解决问题的技术出现都不可避免地带来一些其他问题。下面这三个问题也是常见并发程序出现诡异问题的根源。

  • 缓存——可见性问题
  • 线程切换——原子性问题
  • 编译优化——有序性问题

CPU缓存导致的可见性问题

可见性指一个线程对共享变量的修改,另外一个线程可以立刻看见修改后的结果。缓存导致的可见性问题即指一个线程对共享变量的修改,另外一个线程不能看见。

单核时代:所有线程都是在一颗CPU上运行,CPU缓存与内存数据一致性很容易解决。

多核时代:每颗CPU都有自己的缓存,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;
  }
}

最后执行的结果肯定不是20000,cal() 结果应该为10000到20000之间的一个随机数,因为一个线程改变了count的值,有缓存的原因所以另外一个线程不一定知道,于是就会使用旧值。这就是缓存导致的内存可见性问题。

线程切换带来的原子性问题

原子性指一个或多个操作在CPU执行的过程中不被中断的特性。

UNIX因支持时分复用而名噪天下,早期操作系统基于进程来调度CPU,不同进程之间是不共享内存空间的,所以进程要做任务切换就需要切换内存映射地址,但是这样代价高昂。而一个进程创建的所有线程都是在一个共享内存空间中,所以,使用线程做任务切换的代价会比较低。现在的OS都是线程调度,“任务切换”——“线程切换”。

Java的并发编程是基于多线程的。任务切换大多数是在时间片结束时。

时间片:操作系统将对CPU的使用权期限划分为一小段一小段时间,这个小段时间就是时间片。线程耗费完所分配的时间片后,就会进行任务切换。

高级语言的一句代码等价于多条CPU指令,而OS做任务切换可以发生在任何一条CPU指令执行完后,所以,一个连续的操作可能会因任务切换而被中断,即产生原子性问题。

例如:count+=1, 至少需要三条指令:

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

例如:

竞态条件

由于不恰当的执行时序而导致的不正确的结果,是一种非常严重的情况,我们称之为竞态条件(Race Condition)。

当某个计算的正确性取决于多个线程的交替执行时序时,那么就可能会发生竞态条件。最常见的会出现竞态条件的情况便是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。

例子:延迟初始化中的竞态条件

使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。

public class LazyInitRace{
private ExpensiveObject instance = null;
public ExpensiveObject getInstance(){
if(instance == null){
instance = new ExpensiveObject();
}
return instance;
}
}

以上代码便展示了延迟初始化的情况。getInstance()方法首先判断ExpensiveObject是否已经被初始化,如果已经初始化则返回现有的实例,否则,它将创建一个新的实例,并返回一个引用,从而在后来的调用中就无须再执行这段高开销的代码路径。

getInstance()方法中包含了一个竞态条件,这将会破坏类的正确性,即得到错误的结果。

假设线程A和线程B同时执行getInstace()方法,线程A检查到此时instance为空,因此要创建一个ExpensiveObject的实例。线程B也会判断instance是否为空,而此时instance是否为空则取决于不可预测的时序,包括线程的调度方式,以及线程A需要花费多长时间来初始化ExpensiveObject实例并设置instance。如果线程B检查到instance为空,那么两次调用getInstance()时可能会得到不同的结果,即使getInstance通常被认为是返回相同的实例。

竞态条件并不总是产生错误,还需要某种不恰当的执行时序。然而,竞态条件也可能会导致严重的问题。假设LazyInitRace被用于初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢掉部分注册信息,要么多个行为对同一组对象表现出不一致的视图。

要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或者之后读取和修改状态,而不是在修改状态的过程中。

编译优化带来的有序性问题

有序性是指程序按照代码的先后顺序执行。编译器以及解释器的优化,可能让代码产生意想不到的结果。

以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加锁,但是Java虚拟机保证只有一个线程可以加锁成功(假设为线程A),而另一个线程就会被阻塞处于等待状态(假设是线程B)。

线程A会创建一个Singleton实例,然后释放锁,锁释放后,线程B被唤醒,线程B再次尝试对Singleton.class加锁,此时可以加锁成功,然后检查instance==null时,发现对象已经被创建,于是线程B不会再创建Singleton实例。

但是,优化后new操作的指令,将会与我们理解的不一样:

我们的理解:

  1. 分配一块内存M;
  2. 在内存M上初始化Singleton对象;
  3. 然后将内存M的地址赋值给instance变量。

但是优化后的执行路径却是这样:

  1. 分配一块内存M;
  2. 将内存M的地址赋值给instance变量;
  3. 在内存M上初始化Singleton对象。

优化后将造成如下问题:

在如上的异常执行路径中,线程B执行第一个判断if(instance==null)时,会认为instance!=null,于是直接返回了instance。但是此时的instance是没有进行初始化的,这将导致空指针异常。

注意,线程执行synchronized同步块时,也可能被OS剥夺CPU的使用权,但是其他线程依旧是拿不到锁的。

解决如上问题的一个方案就是使用volatile关键字修饰共享变量instance。

public class Singleton {
  volatile static Singleton instance;    //加上volatile关键字修饰
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

目前可以简单地将volatile关键字的作用理解为:

  1. 禁用重排序,保证有序性;

  2. 保证程序的可见性(一个线程修改共享变量后,会立刻刷新内存中的共享变量值)。

小结

本篇博客介绍了导致并发编程bug出现的三个因素:可见性,有序性和原子性。本文仅限于引出这三个因素,后面将继续写文介绍如何来解决这些因素导致的问题。如有不足,还望各位看官指出,万分感谢。

参考:

[1]极客时间专栏王宝令《Java并发编程实战》

[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

【Java并发基础】并发编程bug源头:可见性、原子性和有序性的更多相关文章

  1. 【漫画】JAVA并发编程三大Bug源头(可见性、原子性、有序性)

    原创声明:本文转载自公众号[胖滚猪学编程]​ 某日,胖滚猪写的代码导致了一个生产bug,奋战到凌晨三点依旧没有解决问题.胖滚熊一看,只用了一个volatile就解决了.并告知胖滚猪,这是并发编程导致的 ...

  2. 并发编程Bug起源:可见性、有序性和原子性问题

    以前古老的DOS操作系统,是单进行的系统.系统每次只能做一件事情,完成了一个任务才能继续下一个任务.每次只能做一件事情,比如在听歌的时候不能打开网页.所有的任务操作都按照串行的方式依次执行. 这类服务 ...

  3. Java之基础(1) - 编程中“为了性能”尽量要做到的一些地方

    最近的机器内存又爆满了,除了新增机器内存外,还应该好好review一下我们的代码,有很多代码编写过于随意化,这些不好的习惯或对程序语言的不了解是应该好好打压打压了. 下面是参考网络资源总结的一些在Ja ...

  4. 01 | 可见性、原子性和有序性问题:并发编程Bug的源头

    由于CPU.内存.I/O 设备的速度差异,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构.操作系统.编译程序都做出以下处理: 1. CPU 增加了缓存,以均衡与内存的速度差异: ...

  5. Java并发编程实战 01并发编程的Bug源头

    摘要 编写正确的并发程序对我来说是一件极其困难的事情,由于知识不足,只知道synchronized这个修饰符进行同步. 本文为学习极客时间:Java并发编程实战 01的总结,文章取图也是来自于该文章 ...

  6. 【转】可见性、原子性和有序性问题:并发编程Bug的源头

    如果你细心观察的话,你会发现,不管是哪一门编程语言,并发类的知识都是在高级篇里.换句话说,这块知识点其实对于程序员来说,是比较进阶的知识.我自己这么多年学习过来,也确实觉得并发是比较难的,因为它会涉及 ...

  7. java并发编程实战《一》可见性、原子性和有序性

    可见性.原子性和有序性问题:并发编程Bug的源头 核心矛盾:CPU.IO.内存三者之间的速度差异. 为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构.操作系统.编译程序都做出了贡献 ...

  8. 【Java并发基础】并发编程领域的三个问题:分工、同步和互斥

    前言 可以将Java并发编程抽象为三个核心问题:分工.同步和互斥. 这三个问题的产生源自对性能的需求.最初时,为提高计算机的效率,当IO在等待时不让CPU空闲,于是就出现了分时操作系统也就出现了并发. ...

  9. 【Java并发基础】Java内存模型解决有序性和可见性

    前言 解决并发编程中的可见性和有序性问题最直接的方法就是禁用CPU缓存和编译器的优化.但是,禁用这两者又会影响程序性能.于是我们要做的是按需禁用CPU缓存和编译器的优化. 如何按需禁用CPU缓存和编译 ...

随机推荐

  1. 2018-12-25-SourceYard-制作源代码包

    title author date CreateTime categories SourceYard 制作源代码包 lindexi 2018-12-25 9:43:7 +0800 2018-12-09 ...

  2. navicat ssh通道受限问题处理

    navicat 链接数据库 使用navicat 的ssh通道连接数据库回遇到权限问题 错误代码如下: 80070007: SSH Tunnel: Server does not support dif ...

  3. [转]C#中 ??、 ?、 ?: 、?.、?[ ] 问号

    1. 可空类型修饰符(?) 引用类型可以使用空引用表示一个不存在的值,而值类型通常不能表示为空.例如:string str=null; 是正确的,int i=null; 编译器就会报错.为了使值类型也 ...

  4. Codeforces Round #200 (Div. 1 + Div. 2)

    A. Magnets 模拟. B. Simple Molecules 设12.13.23边的条数,列出三个等式,解即可. C. Rational Resistance 题目每次扩展的电阻之一是1Ω的, ...

  5. Java发送邮件Demo

    就是个Demo,有使用Spring的东西 package xxxxxxx.common.utils; import org.springframework.mail.javamail.JavaMail ...

  6. H3C RIPv1的缺点

  7. HDU 5912 Fraction(模拟)

    Problem Description Mr. Frog recently studied how to add two fractions up, and he came up with an ev ...

  8. Linux 内核提交和控制一个 urb

    当驱动有数据发送到 USB 设备(如同在驱动的 write 函数中发生的), 一个 urb 必须被 分配来传送数据到设备. urb = usb_alloc_urb(0, GFP_KERNEL); if ...

  9. video实现有声音自动播放

    video实现自动播放有声音 需求:老板见人家可以的,我们的也要可以!!! 前端:自动播放,简单... 要实现:鼠标移入视频播放同时有声音,移出让你暂停,,,,, 问题集合 1- 自动播放实现没有声音 ...

  10. MyBatis整合Spring MVC(易百教程)

    MyBatis是ibatis的升级版,作为hibernate的老对手,它是一个可以自定义SQL.存储过程和高级映射的持久层框架.与Hibernate 的主要区别就是 Mybatis 是半自动化的,而 ...