前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >从硬件角度看 Java 高并发编程bug的源头

从硬件角度看 Java 高并发编程bug的源头

作者头像
kk大数据
发布2020-02-14 15:31:33
7040
发布2020-02-14 15:31:33
举报
文章被收录于专栏:kk大数据kk大数据

Part 1

你现在所处的位置

Part 2

高并发编程一直是 Java 领域的高阶内容,有时候 bug 诡异的出现,又诡异的消失,很难重现,很难追踪,让人抓狂。

多线程问题的直接原因想必大家都知道,就是虚拟机 主内存 和 线程工作内存的交互引起的。

首先一个线程是无法看到另一个线程的工作内存的,其次所有线程共享的变量都在主内存中,当线程需要操作某些变量时,不能直接读写主内存,而是要经过如下的步骤:

  • 从主内存复制变量到工作内存;
  • 执行代码,改变变量值;
  • 用工作内存数据刷新主内存内容。

然后后续就是 synchronize、volatile、wait、notify、Java 锁 相关的知识。

但今天我们换个角度,尝试从计算机硬件的角度来谈谈这个问题。

Part 3

从第一台计算出现,到今天我们在使用的多核高速计算机,CPU、内存、I/O 设备一直在迭代改进,但即时这三者的速度优化到极致,他们的矛盾始终存在:速度差异。CPU 和 内存的速度,可以形象的理解为,CPU 一天,内存一年;内存 和 I/O 设备的速度,可以形象的理解为,内存一天,I/O 设备 10 年。

为了平衡这种差异,计算机体系结构、操作系统、编译程序都做出了相应的贡献:

  • CPU 新增了缓存,以均衡内存的速度差异
  • 操作系统增加了进程、线程,以分时复用 CPU,均衡 CPU 和 I/O 设备的速度差异
  • 编译程序优化指令执行顺序,使得缓存更加充分合理的运用

那么有利必有弊,万物都在平衡中发展,天下没有免费的午餐,很多并发编程 bug 的根源就在这里:

源头之一 :缓存导致的可见性问题

在单核时代,所有的线程都是在一颗 CPU 上运行,CPU 缓存和内存的一致性很容易解决,因为所有线程都是在操作一个 CPU,一个线程对缓存的写,对另一个线程一定是可见的。

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性

但在多核时代,问题就没那么简单了,每颗 CPU 都有自己的缓存,当多个线程在 不同 CPU 上运行时,这些线程操作的是不同的 CPU 缓存,比如下图

线程 A 和 线程 B 操作的是不同 CPU 的缓存,这个时候,线程 A 对变量 V 的操作,就对 线程 B 不可见了。

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

由于 I/O 太慢,早期的操作系统就发明了多进程,即时在单核 CPU 上我们也能一边看电影,一边玩游戏。这就是多进程的功劳。

这其中的原理是,操作系统把各个进程的操作分成多个“时间片”,每隔一小段时间,比如50微秒,让一个进程执行一小段时间,过了50微秒,操作系统就让另一个进程继续执行一段时间。

现代操作系统是基于多线程的,因为线程共享一个内存空间,所以切换线程的成本比切换进程的成本低的多。

我们的 Java 虚拟机的任务切换,自然也是基于多线程的。但可能你想不到,任务切换竟然也是 高并发 bug 的源头。

比如 高级语言的 count += 1 这一条指令,我们很容易就会误认为,是一次性执行完的,但其实是三条 CPU 指令:

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

操作系统做任务切换,可能是发生在任何一条 CPU 指令执行完,注意这里是 CPU 指令而不是一条语句。假设现在有两个线程,他们按照下面的顺序执行,最终的结果是1,而不是2:

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

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

有序性指的是,程序要按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,如,“c1 = 1;c2 = 2”,编译器可能会优化为 “c2 = 2;c1 = 1”,但不影响最终结果。

但有时候,编译器和解释器的优化可能会触发意想不到的 bug,比如最经典的创建单例对象,如下:

代码语言:javascript
复制
public class Singleton {
   static Singleton instance;
   static Singleton getInstance(){
    if (instance == null) {
        synchronized(Singleton.class) {
            if (instance == null)
                  instance = new Singleton();
            }
    }
    return instance;
  }
}

假设两个线程同时调用 getInstance 方法,他们会同时发现 instance == null,于是尝试加锁,虚拟机可以保证只有一个线程(A)加锁成功,另一个线程(B)处于等待状态。线程 A 会创建一个 instance 实例,释放锁,然后 B 获得锁,线程 B 发现 install != null 于是返回。

这一切看上去都无懈可击,非常完美,但问题可能会出现在我们意想不到的地方。出在 new 操作上,我们以为的 new 可能是:

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

但实际上优化后是这样的:

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

会导致线程 A 在执行 new 的第二条指令时,发生了线程切换,线程 B 发现 instance 不为 null ,于是直接返回,但这个时候,instance 变量是还未初始化的,于是返回了 null ,可能会触发程序的空指针异常。

Part 4

只要能深刻理解 可见性、原子性、有序性在并发场景下的原理,再联系Java 高并发编程的相关的知识点,理解 Java 为什么会这么设计,解决了什么问题,那么一些 bug 就会逐个击破了。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-01-14,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 KK架构 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档