并发编程所面对的问题

博主的个人博客:文客
在CSDN发布的文章都会提前在个人博客上发布,想一起交流和学习的小伙伴可以关注一下呀!感谢大家的支持!
本文摘抄了《Java并发编程之美》中的部分内容。

前言

在学校学了多年的专业课,不管学到了哪门语言,并发的知识都只会出现在书上而不是课堂上。换句话说,并发编程对于开发人员或者是学生来说,是比较高级、比较进阶的知识。我自己学习的过程中,也发现并发是比较难的,它需要你对OS、语言有一定的理解,如果你的语言基础薄弱或者不了解操作系统的话,那么学习起来是比较费力的,甚至无法学习下去。而且在学习并发编程的过程中,需要不断地去神入理解,我从去年开始读《Java并发编程的艺术》这本书,书中的很多地方我读了数十遍,每次读都会加深印象并且有新的收获。学习这类知识,大家要沉下心来,不要浮躁。

编写正确的并发程序是一件极困难的事情,并发程序的 Bug 往往会诡异地出现,然后又诡异地消失,很难重现,也很难追踪,很多时候都让人很抓狂。但要快速而又精准地解决“并发”类的疑难杂症,你就要理解这件事情的本质,追本溯源,深入分析这些 Bug 的源头在哪里。

那为什么并发编程容易出问题呢?它是怎么出问题的?今天我们就重点聊聊这些 Bug 的源头。

并发编程模型的两个关键问题

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步。

通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

共享内存的模型中,线程之间共享程序的公共状态,通过读写公共内存来进行通信,这种通信是隐式的。

消息传递的模型中,线程之间没有公共状态,线程间必须通过显示的发送消息来进行通信。

那么Java属于哪种机制呢?

学习过操作系统的都知道,进程是系统运行程序的基本单位,我们运行一个Java程序,实际上是开启了一个进程。在OS中,还有一个比进程更小的单位:线程,一个进程中可以有多个线程,而且由于线程之间的切换负担比较小,所以线程也被成为轻量级进程。在Java程序中,多个线程共享进程的堆和方法区(JDK8的元空间),每个线程又有自己的程序计数器、虚拟机栈和本地方法栈。所以不难看出,Java采用的是共享内存模型。

在共享内存的模型中,通信是隐式进行的,同步是显示进行的,程序员必须显示地指定某个方法或代码块需要在线程之间互斥执行。如果编写多线程的Java程序员不知道隐式进行的线程之间通信的工作机制,很可能会遇到各种可见性和有序性问题。

缓存导致的可见性问题

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

我们先来看一下Java内存模型:

image-20220303131320174

从抽象的角度来看,Java内存模型定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存涵盖了缓存、写缓冲区、寄存器以及其它的硬件和编译器优化。其实在很多资料中,这个本地内存也被叫做缓存,它们表达的意思都是一样的,我个人比较倾向于缓存的叫法,因为从操作系统的角度来看,为了解决速度不匹配的问题大量引入了缓存机制,Java内存模型也是引用的缓存的思想。

那么可见性问题是如何造成的呢?比如内存中有一个共享变量x,线程A操作的是本地内存A中的变量x,而线程B操作的是本地内存B中的变量x,很明显,这个时候线程A对变量x的操作对于线程B而言就不具备可见性了。这个就属于Java内存模型给软件程序员挖的“坑”。

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

Java并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异Bug的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成,例如我们平时代码中的count += 1,至少需要三条 CPU 指令。

  • 指令 1:首先,需要把变量count从内存加载到缓存;
  • 指令 2:之后,在寄存器中执行+1操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。

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

image-20220303134419394

我们潜意识里面觉得count+=1这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在count+=1之前,也可以发生在count+=1之后,但就是不会发生在中间。我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

重排序带来的有序性问题

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型。

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  2. 指令集并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

Java的源代码需要经过一次编译器的重排序和两次处理器的重排序,才能形成最终的执行代码。这些重排序也可能会导致内存可见性问题,同时也会导致一些有序性问题的bug。以著名的单例模式为例,来看下面的代码。

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操作应该是:

  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的成员变量就可能触发空指针异常。

总结

要写好并发程序,首先要知道并发程序的问题在哪里,只有确定了“靶子”,才有可能把问题解决,毕竟所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去非常无厘头,但是深究的话,无外乎就是直觉欺骗了我们,只要我们深刻理解Java内存模型,理解可见性、有序性、原子性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的

博客原文地址
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

九天漩女

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

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

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

打赏作者

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

抵扣说明:

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

余额充值