并发编程(一)并发编程bug的源头:可见性、有序性、原子性

【关于作者】

关于作者,目前在蚂蚁金服搬砖任职,在支付宝营销投放领域工作了多年,目前在专注于内存数据库相关的应用学习,如果你有任何技术交流或大厂内推及面试咨询,都可以从我的个人博客(https://0522-isniceday.top/)联系我

1.并发编程的幕后

CPU、内存、磁盘IO的速度差异,CPU>内存>磁盘

程序中的一个操作,可能会访问内存也可能还要访问磁盘,根据木桶理论我们能知道此时的性能瓶颈在磁盘

为了平衡三者的差异并合理利用CPU,计算机体系结构、操作系统、编译程序都做了如下贡献:

  1. CPU增加了缓存(L1、L2、L3也称为寄存器,在JVM规范中也称为工作缓存),用于平衡与内存的速度差异
  2. 操作系统增加了进程、线程,得以分时复用CPU,进而均衡CPU和IO之间的差异
  3. 编译程序优化指令执行次序,使得缓存能够更加合理的使用

做出如上贡献的同时,也不可避免的带来了一些并发问题

2.问题一:缓存导致的可见性问题

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

单核时代,所有的线程都在一个CPU上执行,CPU缓存与内存一致性容易得到满足

多核时代,每颗CPU都有了自己的缓存,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存,此时就容易造成可见性问题,例如下图的CPU-1对于变量V的调整,线程B就无法及时看到

例:例如平常循环count+=1(读取-修改-写入)的操作,就是可见性的体现,同时也是原子性的体现,因为该自增操作不符合原子性

锁能够同时解决可见性和原子性的问题

image-20210719221817180

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

由于IO太慢,因此操作系统发明了多线程,即便在单CPU我们也能使用多线程,因为分时复用机制(时间片)

因为IO操作比较耗时,如果此时占用CPU的线程等待IO则会浪费CPU资源,此时可将该任务标记为“休眠”状态,此时CPU可以交由其他线程使用,早起操作系统是基于进程进行调度CPU的,由于进程不共享内存空间,所以切换任务会切换内存映射地址,而进程中的所有线程是共享内存空间的,基于线程的切换成本就很低了,所以现在操作系统都基于线程进行任务切换。

image-20210719222404098

Java并发程序是基于多线程的,自然也涉及到任务切换,而任务切换竟然也是并发编程里诡异 Bug 的源头之一。

仍然拿count+=1(读取-修改-写入,这里的写入可能是写入CPU的缓存而不是内存)举例,操作系统的切换,可以发生在任意一条指令执行完,因此此时就会导致线程A刚执行到读取就发生了线程切换,此时线程B执行完count+=1操作,再切换到线程A去执行修改与写入操作,此时可能导致最后的count值仍然为1,而不是预期中的2,因此,我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性,CPU能保证的原子操作是指令级别的,而不是语言层面的

image-20210719223415848

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

编译器有时候为了优化性能,有时候会改变程序中语句的先后执行顺序,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug

例如:Java单例模式中先检查-后执行的场景(这个场景也可能会发生原子性问题)

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

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

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

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

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

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

可以关注一下一个new操作所进过的阶段:

分配内存->初始化对象->内存地址赋值给引用

5.总结

上述所说的CPU缓存、CPU的线程切换、编译器的重排序等和我们写并发程序的目的一致,都是为了提高程序性能,合理利用系统资源。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

哈哈哈张大侠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值