Java多线程并发【1】并发基础和内存模型

计算机中,CPU、内存和 I/O 设备的运算速度是有差异的,为了更高效地利用 CPU 的性能,平衡三者的速度差异,计算机系统从各个层面进行了优化

  • CPU 有单独的缓存区,用来均衡与内存的速度差异。
  • 操作系统中设计了进程、线程的概念,以分时复用 CPU 线程,用来均衡 CPU 与 I/O 设备的速度差异。
  • 编译程序优化了代码指令的顺序,使得 CPU 的缓存能够更高效的使用。

以上三个优化,都存在自己的缺陷:

  1. CPU 缓存区与内存交互:存在数据同步不及时的情况,即可见性问题。
  2. 操作系统的 CPU 分时复用:当一个任务获取 CPU 执行到一半时,时间片到期该释放 CPU 资源了,这种情况下任务还没执行完,即原子性问题。
  3. 编译程序对代码指令的优化:编译器会对代码进行优化,导致指令重排,即有序性问题。

为了处理上面3的问题,Java 为我们提供了 Java 内存模型的概念。

先从问题说起。

并发问题的根源

可见性问题

可见性问题是因为 CPU 在执行计算后,将计算结果存储到缓存空间,但并没有立刻把计算结果同步到内存中。为了理解这个问题,我们先从 CPU 的工作原理说起。

CPU 的核心是由运算器、控制器、寄存器三部分组成。运算器是用来计算的;控制器是用来控制指令信息的;寄存器是用来保存运算结果或指令的。

单核 CPU 就是继承了一个运算核心;多核 CPU 相当于集成了多个 CPU 核心,这些核心可以同时工作。对于操作系统而言,也就是一个核心在同一时刻只能执行一个任务,为了能够同时处理更多的任务,一个策略就是是让 CPU 核心具有同时执行多个线程的能力。

线程可以理解为一组执行集合的执行器,执行指令会产生一些运算结果和暂时保存指令的场景,这些就是在寄存器中保存的。那么如果总是操作同一个地址中的数据,寄存器就会反复去从内存中查找,这样会影响执行速度。所以 CPU 提供了缓存的概念。

CPU 缓存就是 CPU 寄存器与内存之间的一个中间缓存空间。能够大大提高 CPU 的运算速度(不必每次都去内存中找)。

我们在前面说到了 CPU 为了解决和内存、I/O 设备速度的一致性,增加了缓存的概念,这个缓存就是下面图中的高速缓存,而这些缓存又都与内存进行交互,这个内存就是图中的主内存。当多个处理器的计算都涉及同一块内存区域中的内容时,会导致高速内存中的数据不一致,为了解决这个问题,在高速缓存与主内存之间多了一层缓存一致性协议,在读写时要根据协议来进行操作。

Java多线程并发【1】并发基础和内存模型
img

原子性问题

操作系统对 CPU 采取分时复用的机制,CPU 每个时间片分配给不同的线程执行。这就会产生一个任务还没有全部执行完成就要被打断的情况。原子性的含义就是一个或多个操作,要么这个操作全部执行完成且执行过程中不会被任何因素打断;要么全部不执行。

有序性问题

因为编译器为了提高性能,会对代码在编译期间进行优化,对指令进行重排序。生成的指令可能会在执行顺序上与我们代码想要的顺序不同,这就是指令重排,它有三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  2. 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统的重排序。由于 CPU 使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

源码 -> 编译器优化重排序 -> 指令级并行重排序 -> 内存系统重排序 -> 最终的指令序列

这三种重排序都可能导致多线程程序出现内存可见性问题。对于编译器层面,Java 提供了防止重排的能力;而对于指令级重排序和 CPU 内存导致的重排序,Java 对于 CPU 重排序规则会要求在 Java 编译器生成指令序列时,插入特定类型的内存屏障指令,通过这个指令来禁止特定类型的重排序。

那么 Java 是如何解决这些问题的呢?这要从 Java 的内存模型开始说起。

Java 内存模型

在 Java 虚拟机原理中我们知道,JVM 的运行时区域是如图所示的:

Java多线程并发【1】并发基础和内存模型
img

但是在计算机的组成结构中,并没有区分堆和栈的概念,对象在硬件层面是会存在于 CPU 寄存器、CPU 的高速缓存和内存中的。

为了解决硬件层面的缓存一致性问题,Java 虚拟机规范中试图定义一种 Java 内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,跨平台会出现不同的问题。

Java 内存模型规定了所有的变量(这个概念与 Java 中的变量不同,指共享区域的对象)都存储在主内存中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)。

每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量在主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。

不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图所示:

Java多线程并发【1】并发基础和内存模型
img

主内存与工作内存之间的互操作

主内存和工作内存之间存在和硬件结构图中与缓存一致性协议一样的协议,包括以下操作:

  • lock:加锁,作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • unlock:解锁,作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • read:读取,作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  • load:载入,作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use:使用,作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

  • assign:赋值,作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store:存储,作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。

  • write:写入,作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

上述操作存在一些需要保证顺序操作的场景:

  1. 变量从主内存复制到工作内存:顺序执行 read 和 load 。
  2. 从工作内存把变量同步回主内存:顺序执行 store 和 write 。

注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。

除了上述规定外,Jav a内存模型还规定了在执行上述 8 种基本操作时必须满足如下规则:

  • read 和 load 、store 和 write 操作需要保证成组出现,不能单独出现其中一个操作。

  • 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。

  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化( load 或 assign )的变量,换句话说,就是对一个变量实施 use、store 操作之前,必须先执行过了 assign 和 load 操作。

  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。

  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。

  • 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。

  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)。

这8种内存访问操作以及上述规则限定,再加上 volatile 关键字的特殊规定,就已经能够完全确定了 Java 程序中哪些内存访问操作在并发下是安全的。

但这些理解起来(背起来)可能有一些困难,有一个等效的判断原则更方便我们理解上面的规则 — 先行发生原则。

先行发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

举例说明:

// A 线程中执行
i = 1
// B 线程中执行
j = i
// C 线程中执行
i = 2

假设 A 中的操作先行发生于 B 中的操作(不考虑 C ) ,那么 B 中 j 一定等于 1 。这是因为:

  • 根据先行发生原则,B 操作发生在 A 之后, A 中发生的变化 B 可以观测到。

假设 C 出现在 A 操作和 B 操作之间,但是 C 与 B 没有先行发生关系,那么 B 中 j 的值是不确定的。这是因为:

  • C 与 B 没有先行发生关系,执行的时机是不确定的。

这种情况下,B 存在读取到的数据已过期的风险,不具备线程安全。

JMM 中存在一些天然的先行发生关系,这些关系无需做同步处理就已天然存在。如果不存在这种先行发生关系,就没有顺序保证,虚拟机就可以对其进行任意的冲排序。

JMM 中的先行发生关系包括:

  • 程序次序规则:代码按顺序执行。

  • 管程锁定规则:一个 unlock 操作先行发生于后面对于同一个锁的 lock 操作。

  • volatile 变量规则:对于 volatile 关键字修饰的变量的写操作先行发生于后续对这个变量的读操作。

  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作。

  • 线程终止规则:线程中所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行。

  • 线程中断规则:对于线程的 interrupt() 方法的调用,先行发生于被中断线程的代码检测到中断事件的发生。可以通过 Thread.interrupted() 方法检测到是否有中断发生。

  • 对象终结规则::一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize() 方法的开始。

  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

举例说明:

var value = 0

假设存在线程 A 和 B ,线程 A 先调用 value = 1(这里只是时间上的先后,不是先行发生),然后线程 B 执行 res = value,那么 B 的 res 最终结果会是什么呢?

通过上面的八个规则逐个分析:

  1. 程序次序规则:不是在同一个线程执行的代码,不适用。
  2. 管程锁定规则:全程没有加锁,不适用。
  3. volatile 变量规则:没有加 volatile 关键字,不适用。
  4. 线程启动:不涉及启动,不适用。
  5. 线程终止:不涉及,不适用。
  6. 线程中断:不涉及,不适用。
  7. 对象终结:不涉及,不适用。
  8. 传递性:不适用。

结论:这里的 B 的操作不是线程安全的。

总结

  • 并发问题,根源来自于硬件层面,JVM 为我们提供内存模型规范,能够从代码层面分析并发问题。

  • 并发安全的本质是遵守先行发生规则的。分析实际的并发场景,可以通过对先行发生规则的检查来确定是否存在并发问题。


原文始发于微信公众号(八千里路山与海):Java多线程并发【1】并发基础和内存模型

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/85143.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!