【Java 并发编程】 03 万恶的 Bug 起源—并发编程的三大特性

今天让我们一起走进并发编程中万恶的Bug起源—并发编程中三大特性。今天学习目标如下:

  • 并发编程的三大特性都要哪些 ?
  • 并发编程三大特性的由来?
  • 如何解决并发编程三大特性问题?

全文概览

基本概念

原子性:一组操作要么全部成功,要么全部失败。
可见性:一个线程对变量进行了修改,另外一个线程能够立刻读取到此变量的最新值。
有序性:代码在运行期间保证按照编写的顺序。

为什么会有并发编程的三大特性呢?

话说女娲补天,精卫填海 …一直到上世纪60年代英特尔创始人戈登·摩尔讲的:“集成电路上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍”,意思就是说每过18个月,电脑的CPU性能就能提升一倍,是不是说一年半我们就需要换一次电脑呢? CPU 的处理数据的能力翻了好多倍,不再是以前的单核处理器,而是变成了多核处理器。但是你又可能听说过这些道理,不怕神一样的对手,就怕猪一样的队友一个桶能装多少水完全取决于这个桶的短板, CPU 的性能是快了,内存的性能确没有提升,磁盘的I/O读写速度也没有提升,即使CPU运行的再快,最终被这些短板拖累着,也无济于事。 为了均衡 CPU与主存之间的速度的差异,均衡主存与磁盘读写速度的差异。CPU缓存的概念,以及程序编译优化的诞生了,由于这些高性能概念的诞生,CPU缓存导致软件代码代码程序运行出现了缓存不一致的问题,以及编译优化带来的有序性问题,线程并发切换带来的原子性问题等。

缓存一致性导致了可见性问题

可见性:一个线程修改了共享变量,另一个线程能立即看到。

以前电脑是单CPU,也就意味着有单个CPU缓存,随着CPU升级,目前大多数电脑是多核CPU,也就意味着CPU缓存不再只是单纯的一个,而是多个。
在这里插入图片描述

如上图,修改一个C变量的值大致分为以下三步:

  • 从内存中获取变量C的值,到CPU缓存中。
  • 在CPU缓存中对数据进行自增操作。
  • 将修改后的数据重新赋值到内存当中。

为提高程序运行效率,让两个线程同时启动,对默认值为0 的共享变量C进行自增加一操作。每个线程各自执行50次。正常来说,程序各自运行50次,C变量的值应为100,然而结果总是在50—100之间, 你知道是什么原因么。 我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 c+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2,长此以往,两个线程总是做着相同重复的工作。一个线程修改数据后,写入内存,对另一个线程是不可见的。导致两者缓存中数据不一致。

线程切换带来原子性问题

原子性:一组操作要么全部执行成功,要么全部失败。

多线程的存在,时间片轮转,发生线程的切换,导致一组操作被中断
在这里插入图片描述
修改C变量的一个线程执行过程中分为三步,时间片轮转,造成线程被中断,导致内存中的值没有发生变化。

  • 从内存中获取变量C的值,到CPU缓存中。
  • 在CPU缓存中对数据进行自增操作。
  • 将修改后的数据重新赋值到内存当中。

编译优化带来有序性问题

有序性:代码在运行期间保证按照编写的顺序。
有一个去超时购物的例子,有一天自己想去元辰超市买点鸡腿,此时你老婆说家里的平底锅坏了,你买一个回来,你懂得。你儿子又说,老爸,我想吃奶油蛋糕,如果奶油蛋糕没有,就买旺仔牛奶吧。此时你妈又说,家里没有土豆了,顺便你买点土豆回来。于是你列好了一个清单,清单如下:

  • 鸡腿
  • 平底锅
  • 如果没有奶油蛋糕,就买旺仔牛奶(这个顺序是不变的)
  • 土豆

以上清单就是我们写代码的顺序。

可是一进超市你最先看到的就是蔬菜区,按理说为了提高代码执行效率,你应该先去买土豆,因为土豆离你最近。最后再去二楼买平底锅。CPU也不傻,为了提高代码的执行效率,肯定是执行买土豆的代码,再去执行买平底锅的代码。无论如何执行,买平底锅的和买土豆两者是互不影响的。但是如果你先看到的是卖旺仔牛奶的货架,后看到的是奶油蛋糕的货架,CPU为提高代码执行效率,就会判断此时有没有买奶油蛋糕,发现没有,于是买了旺仔牛奶,程序继续执行,走到奶油蛋糕的货架,又买了旺仔牛奶。最后既买了旺仔牛奶,又买了奶油蛋糕,这显然是不正确的。

CPU指令重排序(土豆,鸡腿、平底锅、谁先后执行并不影响结果,但是买奶油蛋糕和买旺仔牛奶的顺序是万万不能变的。)

  • 土豆
  • 鸡腿
  • 旺仔牛奶
  • 奶油蛋糕
  • 平底锅
    在这里插入图片描述

经典的单例模式:单例模式,每次只能实例化一个对象

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

我们看getInstance() 方法,对单例对象进行了两次判空,第一次在 synchronized 代码块之外,第二次在 synchronized 内部,为什么要进行两次判空呢?

首先我们要明确创建对象的步骤是如何进行的?

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

CPU 优化编译后,执行代码的顺序如下,我们发现,第二步和第三步执行顺序发生了变化。

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

如果CPU的执行顺序发生了变化,当线程A执行完第二步,即M 的地址赋值给 instance 变量,此时发生了线程切换,线程 B 也执行 getInstance() 方法,在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

在这里插入图片描述

总结

线程切换带来了原子性问题缓存一致性带来了可见性问题CPU程序编译优化带来了有序性问题。那我们如何解决呢? 不用多线程,不用缓存,不让CPU进行编译优化,这显示是不可能的,因为这些是高性能的保证。那我们该如何解决这些问题呢?下一篇我们将介绍Java 内存模型(Java Memory Model)。看 Java 内存模型是如何解决多线程有序性和可见性问题的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值