出现并发问题背后的原因

众所周知,CPU、内存、I/O设备的速度差异特别的大,而根据木桶理论,程序整体的性能取决于最慢的操作——读写I/O设备,为了合理利用计算机的高性能,平衡三者的速度差异,做了以下三个优化。

  • 计算机体系结构在CPU添加了缓存,以均衡与内存的速度差异(导致可见性问题)。
  • 操作系统添加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异(导致原子性问题)。
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理的利用(导致有序性问题)。

tip:指令优化是编译器优化能力的一种,指令一般都是由两个部分组成,操作码和操作地址吗,计算机大量的指令中存在这“二八”定则,20%的指令在80%的时间里重复使用着,80%的指令在20%的时间在使用着,为了提高计算机的工作效率,在指令的调用上,要想办法将那20%的指令尽可能放在近的地方,而那剩下的指令可以放在稍微远一些的地方,因此,哈夫曼编码就出现了。

缓存导致的可见性问题

单核时代,所有线程都在一个CPU执行,CPU缓存与内存的数据一致性容易解决,因为所有线程都是操作同一个CPU缓存,一个线程对缓存的写,对另外一个线程都是可见的。一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

多核时代,每个CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没那么容易解决,当多个线程在不同的CPU上执行,这些线程操作不同的CPU缓存,就感知不到彼此的改变了,因此就不具备可见性。

下面看一下这个例子:

public class Test {
private static 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,但实际上执行的结果是10000到20000之间的随机数,我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

这里建议自己去实践一遍,然后将循环一万次换成循环一亿次,或者说换成循环1000次,你会发现当循环一亿次时,结果更接近一个亿,循环1000次结果更接近2000。那么这是为什么呢。

在主线程中,这两个线程启动有先后顺序,如果只循环一千次的话,一个线程可能没来得及切换就执行完了并写回缓存中,那么另一个线程就会读到一个接近一次的值并加到2000,如果循环一亿次,两个线程接近并行,即各自循环一亿次,所以接近一个亿。

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

首先,这里的原子性 必须是CPU的单条指令才是原子性,并不是高级语言中的一条语句,count +=1 并不是原子性,实际上是三条指令。

接下来进入正题,由于I/O太慢,早期的操作系统发明了多进程,操作系统允许某个进程执行一小段时间就切换到另外一个进程来执行,这里面的一小段时间就称为“时间片”

在一个时间片内,如果一个进程进行一个IO操作,例如读文件,这个时候该进程可以把自己标为”休眠状态“让出CPU的使用权,待文件读进内存,操作系统将此进程再次唤醒,之后申请获取CPU使用权,这个进程就得以继续工作。

这里的进程在进行IO操作之所以会释放CPU使用权,是为了让CPU在这段等待时间里可以做 别的事情,这样一来CPU使用率就大大提升,此外,如果这时有另外一个进程也要读文件,那么就需要排队(因为磁盘驱动不会像CPU一样切换进程任务,而是执行完一个之后再执行另外一个。),磁盘驱动再完成一个进程的读操作后,发现有人排队,就立即启动下一个读操作,这样IO使用率也上来了。

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

Java并发程序都是基于多线程的,自然也会涉及到线程切换,线程切换所带来的问题也是我们一直头疼的,我们现在基本使用的是高级语言编程(如Java),高级语言中一条语句往往需要多条CPU指令完成,例如count+=1,至少需要三条CPU指令:

  • 指令1:首先,需要把变量count从内存加载到CPU寄存器
  • 指令2:之后,在寄存器中执行+1操作
  • 指令3:最后,将结果写入内存或缓存

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

tip:在这张图里面可能有些人看从”count=1写入内存“到”count+1=1“这一步不太懂,count+1=1是在寄存器进行操作,而寄存器中的count是为0的。

我们把一个或多个操作在CPU执行的过程中不被中断的特性称为原子性,CPU能保证的原子操作是CPU指令级别的,不是高级语言的操作符,因此,我们需要在高级语言层面保证操作的原子性。

tip:硬件支持 & 多核原子操作:软件级别的原子操作是依赖于硬件支持的。 在x86体系中,CPU提供了HLOCK pin引线,允许CPU在执行某一个指令(仅仅是一个指令)时拉低HLOCK pin引线的电位,直到这个指令执行完毕才放开。从而锁住了总线,如此在同一总线的CPU就暂时无法通过总线访问内存了,这样就保证了多核处理器的原子性

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

编译器为了优化性能,有时候会改变程序中语句的先后顺序,在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。

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 实例。

看起来是不是无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

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

但是实际上优化后的执行路径确实这样的;

  • 分配一块内存M
  • 将M的地址赋值给instance变量
  • 最后在内存M上初始化Singleton对象

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

tip:这时候Java你可以用Volatile禁止指令重排。

总结

本文章为笔者的并发编程学习日记,以及一些思考,然后这也是我学习并发编程的第一篇吧,接下来我也会继续将我的学习日记分享到我的博客中。

出现并发问题背后的原因相关推荐

  1. 达达O2O后台架构演进实践:从0到4000高并发请求背后的努力

    1.引言 达达创立于2014年5月,业务覆盖全国37个城市,拥有130万注册众包配送员,日均配送百万单,是全国领先的最后三公里物流配送平台. 达达的业务模式与滴滴以及Uber很相似,以众包的方式利用社 ...

  2. 做出的C++选择以及背后的原因

    要让出资人明白你做出的C++选择以及背后的原因.也许出资人会有更容易操作.更快实现的好主意.3.为你提供的日期说明信心范围.很可能管理层不明白你的估算意味着什么,而且你也有可能不理解他们所要的东西. ...

  3. custompage.width 不能小数吗_基金净值暴涨暴跌,背后的原因你清楚吗?

    基金净值即基金的单位净值,开放式基金每个交易日都会公布上一个交易日的基金净值,而基金净值暴涨暴跌的黑天鹅事件也是偶有发生,基金净值的波动直接影响了投资者的收益,但是单位净值暴涨暴跌背后的原因你清楚吗? ...

  4. 魔兽世界服务器显示新,《魔兽世界》怀旧服再开新服,背后的原因竟然是!

    原标题:<魔兽世界>怀旧服再开新服,背后的原因竟然是! <魔兽世界>怀旧服宣布加开一组全新PVP服务器"维克托".到了2月13日,官方继儿又宣布开放了两组新 ...

  5. 《大数据时代》读书笔记——知道“是什么”就够了,没必要知道“为什么”。我们不必非得知道现象背后的原因,而是要让数据自己“发声”

    引言--一场生活.工作与思维的大变革 今天,一种可能的方式,亦是本书采取的方式,认为大数据是人们在大规模数据的基础上可以做到的事情,而这些事情在小规模数据的基础上是无法完成的.大数据是人们获得新的认知 ...

  6. java无法从静态上下文_java - “非静态方法无法从静态上下文中引用”背后的原因是什么?...

    java - "非静态方法无法从静态上下文中引用"背后的原因是什么? 这个问题在这里已有答案: 无法从静态上下文引用非静态变量                            ...

  7. 百度 谷歌分页_微信无力、多闪随后、百度依旧,背后的原因原来如此

    这是一篇「人机协作」的文章, 初稿由darksee.ai「智能写手」生成, darksee.ai阅读了全网数据. 欢迎在MixLab讨论相关内容.技术实现, MixLab是一所面向未来的实验室 起因是 ...

  8. ChatGPT爆火背后的原因:透过现象看本质

    ChatGPT爆火背后的原因:透过现象看本质 随着人工智能技术的快速发展,我们已经在许多领域看到了AI的身影.在最近的一段时间里,ChatGPT成为了一个引起广泛关注的现象.ChatGPT以其强大的自 ...

  9. java 并发问题存在的原因 解决方案

    java 并发问题存在的原因 & 解决方案 基于jdk1.8 参考<深入理解JVM> <java并发实践> <Linux内核设计与实现>等 并发存在的原因 ...

最新文章

  1. 实施自动化测试的六个目标和意义
  2. 生信分析平台方案推介,助力科研
  3. vue 循环 递归组件_Vue一个案例引发的递归组件的使用
  4. 「LibreOJ NOI Round #2」不等关系 (dp+NTT分治)
  5. 面试题12:打印1到最大的n位数
  6. 10个好用的Web日志安全分析工具
  7. 单机俄罗斯方块游戏制作心得(四)
  8. 自定义android时间表盘选择器
  9. 我没见过凌晨四点的洛杉矶,但想带你聆听每个都市夜归人的故事
  10. python wx包_今天玩点啥:python真香系列之利用wxpy包写一个微信消息自动回复插件...
  11. 【教程】百度地图AK申请指南(PM2.5指导版)
  12. 【Educoder】Python学习记录(二)
  13. linux 查看开放的端口以及开放端口并且永久开放端口的方法
  14. ET200SP 3964-R通讯协议 Euchner安士能CIT3SX感应识别系统
  15. Qt编程之Xml文件的读取
  16. 字节跳动应用性能监控帮助客户Java OOM崩溃率下降80%
  17. 【JY】浅谈混凝土损伤模型及Abaqus中CDP的应用
  18. 《行为经济学》北京大学 孟涓涓 第二章
  19. Wondow10 编译 Wireshark 源码(Windows10 + Vs2019 +Qt5.12)
  20. kangle 支持ssi最后的版本

热门文章

  1. The Sandbox 与 艺术电影公司 MK2 达成合作
  2. 面对低电压我们该怎么测|掌握低电压测量
  3. 软件测试工作一周的感触
  4. [地心游记]探讨以大客户为核心的增值体系
  5. 达梦DCA培训 考试培训总结(部分内容)
  6. DFS——深度优先搜索的简单易懂入门心得
  7. ABLIC 推出 S-82M1A/S-82N1A/S-82N1B 系列单节电池保护 IC ,工作状态下消耗电流为全球最低 (*1),仅为 990nA (最大值) (*2)
  8. 卷积神经网络——目标检测之Faster R-CNN论文翻译
  9. hello.world程序的编写和运行
  10. 120D02s调机帖[转]