Java并发编程问题出现的原因?

简介

从事java web后端开发,尤其是toc平台都必须要用到多线程并发,而能够高效地、正确使用并发编程也是一件比较有挑战的事情,也很能体现一个程序员的水平,同时去查找多线程并发问题,通常也是一件及其困难的事情,一些bug很诡异,通常并不能快速、重复的捕捉到,这就需要我们对并发的原理及本质有深入的了解,能够追本溯源。这里就介绍下并发出现的问题及原因。

并发背景

我们知道程序在运行,cpu需要从内存、磁盘读取程序及数据,尽管多年来硬件设备不断迭代更新,这三个模块处理速度始终存在无法跨越的速度鸿沟,cpu使用效率会受到内存和磁盘的制约,因此为了提高cpu的利用率,计算机系统结构、操作系统、编译系统等组件都进行了相应的工作,具体表现如下:

  • cpu增加高速缓存,均衡与内存之间速度差异
  • 编译程序对指令执行次序,使得缓存能够得到更加合理地利用
  • 操作系统增加了进程、线程,对cpu时间进行分片,进而可以均衡cpu与I/o设备的速度差异。
    这些发挥cpu利用率的做法,并发程序问题的根源也是来自于此。
    接下来讲下并发有序性、可见性、原子性问题

有序性问题

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,一眼看过去编译器调整了语句的执行顺序并不会影响程序的执行结果,如:
a=3;b=4,调整为b=4;a=3。但是在并发多线程情况下这种编译器改变程序的顺序会带来意想不到的结果。
java 领域一个经典的案例就是利用双重检查创建单例对象:

主要原因在new 操作上,正常 new 操作应该是:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。
    但是实际上优化后的可能执行路径是这样的:
  4. 分配一块内存 M;
  5. 将 M 的地址赋值给 instance 变量;
  6. 最后在内存 M 上初始化 Singleton 对象。
    这是后会带来如下问题:
    我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方 法,那么线程 B 会发现instance != null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空 指针异常。
    在这里插入图片描述

可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。在单核cpu情况下多个线程都是在同一个cpu缓存进行数据操作,所以一个线程对缓存的写,对另外一个线程来 说一定是可见的。当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存,这个时候两个线程可能在不同的核心上操作,同一个变量分别保存到不同的cpu缓存当中,线程 A 对变量 的操作对于线程 B 而言就不具备可见性了。

在这里插入图片描述

原子性问题

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。cpu能保证的原子操作是指令级别的,而不是高级语言中的一条语句,一条高级语言的语句会拆分成多条cpu指令执行,如count+1,实际上对应至少三条指令,如:
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条CPU 指令执行完,而不是整条java语句。如下图如果两个线程都同时去做count+=1操作如下情况可能会出现:
在这里插入图片描述
这种情况就导致并发bug问题。

总结

通过可见性性、原子性、有序性几个问题阐述:提到缓存导致的可见性问题,线程切换带来 的原子性问题,编译优化带来的有序性问题,其实这些缓存、线程、编译优化的目的都是提高程序性能。但是在使用过程中稍不注意会 带来另外一个问题,如果能够深刻理解可见性、 原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章