彻底理解Java并发:Java内存模型

2022-12-01 21:34:15 浏览数 (1)

本篇内容包括:进程与线程&并行与并发的基本概念,Java内存模型中的内存划分、内存交互、内存交互,以及JMM的相关概念,包括了 CPU 和缓存一致性、重排序、处理器重排序与内存屏障指令、JMM 的重排序屏障、数据依赖性、as-if-serial 语句、happens-before 规则,还有JMM三大特征(原子性、可见性、有序性)。

一、基本概念

1、进程与线程

进程是静态的概念,进程是资源(CPU、内存等)分配和调度的基本单位,它拥有自己的资源空间,每启动一个进程,系统就会为它分配地址空间;

线程是动态的概念,线程是程序执行的基本单位,它既可以由操作系统内核来控制调度,也可以由用户程序进行控制调度;

线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如:程序计数器,一组寄存器和栈),多个线程共享同一进程内的资源,使用相同的地址空间。

进程线程区别:

  1. 进程是资源(CPU、内存等)分配的基本单位;线程是程序执行的基本单位;
  2. 进程拥有自己的资源空间,没启动一个进程,系统就会为它分配地址空间;而线程与CPU资源分配无关,多个线程共享同一进程内的资源,使用相同的地址空间;
  3. 一个进程可以包含若干个线程。
2、并行与并发

并行(parallel)指在同一时刻,有多条指令在多个处理器上同时执行,偏重点在于"同时执行",是物理上的同时发生

并发(concurrency)指在同一时段,有多条指令在多个处理器上同时执行,偏重点在于"多个任务交替执行",是逻辑上的同时发生(simultaneous),而多个任务之间有可能还是串行的

并行并发区别:

  • 并行:是指真正意义上的任务同时运行,在一个时间点,多个任务同时执行;
  • 并发:是指任务间交替执行,达到同时执行的效果,在时间段上看起来是同时执行,但是在时间点上不是。

二、Java 内存模型

JMM(Java Memory Model)即 Java 内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果,JMM 规范了Java 虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

JMM 定义了程序中各种变量的访问规则。其规定所有变量都存储在主内存,线程均有自己的工作内存。工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。操作完成后,线程的工作内存通过缓存一致性协议将操作完的数据刷回主存。

我们常说的 JVM 内存模式指的是 JVM 的内存分区;而 Java 内存模型(JMM)是一种虚拟机规范。

原始的 Java 内存模型存在一些不足,因此 Java 内存模型在 Java1.5 时被重新修订。这个版本的 Java 内存模型在 Java8 中仍然在使用。

1、内存划分

JMM 的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与 Java 编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。

JMM 规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟 JVM 内存划分(堆、栈、方法区)是在不同的层次上进行的。如果非要对应起来,主内存对应的是 Java 堆中的对象实例部分,工作内存对应的是栈中的部分区域。从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。

JMM 在设计时候考虑到,如果 Java 线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存。JMM 中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面讲的处理器的高速缓存类比)。线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。

但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因此 JMM 制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。

2、内存交互

线程、主内存和工作内存的交互关系:

JMM 中规定内存交互操作有 8 种,每种操作都有自己作用的的区域,具体操作如下:

  1. lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态。
  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read 读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  4. load(载入):作用于工作内存的变量,它把 read 操作从主存中变量放入工作内存中。
  5. use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令。
  6. assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中。
  7. store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用。
  8. write(写入):作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
3、线程通信

JMM 中的 8 种操作规定了线程对主内存的操作过程,隐式的规定:线程之间要通信必须通过主内存,JMM 的线程通信如下:

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面 2 个步骤:

  1. 首先,线程A把本地内存A 中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。

Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的。

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

  1. 不允许 read 和 load、store 和 write 操作之一单独出现。
  2. 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  3. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了assign 和 load 操作。
  5. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,lock 和 unlock 必须成对出现
  6. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
  7. 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
  8. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

三、JMM相关概念

1、CPU 和缓存一致性
  1. 现代计算机系统在存储设备与处理器之间加了一层读写速度尽可能解决处理器运算速度的高速缓存来作为内存与处理器之间的缓冲: 将运算需要使用到的数据复制到缓存中, 让运算能快速进行, 当运算结束后再从缓存同步回内存之中, 这样处理器就无须等待缓慢的内存读写。
  2. 缓存一致性问题:在多处理器系统中, 每个处理器都有自己的高速缓存, 而它们又共享同一组内存, 当多个处理器的运算任务都涉及到同一个块主内存区域时, 将可能导致各自的缓存数据不一致。
2、重排序

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

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

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

源代码>>编译器优化冲排序>>指令级并行的重排序>>内存系统的重排序>>最终执行指令序列
3、处理器重排序与内存屏障指令

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。

同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。

这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!

例子:假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 a=b=0 的结果。具体的原因如下图所示:

这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 a=b=0 的结果。

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器A的内存操作顺序被重排序了。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作重排序。

4、JMM 的重排序屏障

从 Java 源代码到最终实际执行的指令序列,会经过三种重排序。但是,为了保证内存的可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

  1. 编译器重排序:JMM 会根据重排序规则禁止特定类型的编译器重排序;
  2. 处理器重排序:JMM 会插入特定类型的内存屏障,通过内存的屏障指令禁止特定类型的处理器重排序。
5、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称

代码示例

说明

写后读

a = 1;b = a;

写一个变量后,再读这个位置

写后写

a = 1;b = 2;

写一个变量后,再写这个变量

读后写

a = b;b = 1;

读一个变量后,再写这个变量

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

5、as-if-serial 语句

重排序也不能毫无规则,否则语义就变得不可读, as-if-serial 语句 给重排序戴上紧箍咒,起到约束作用。

as-if-serial语句规定重排序要满足以下两个规则:

  • 在单线程环境下不能改变程序执行的结果;
  • 存在数据依赖关系代码(指令)片段的不允许重排序。

as-if-serial 语句下重排序既没有改变单线程下程序运行的结果,又没有对存在依赖关系的指令进行重排序。

6、happens-before 规则

从 Jdk5 开始,Java 使用新的 JSR-133 内存模型。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。换句话说,操作1 happens-before 操作2,那么 操作1 的结果是对 操作2 可见的。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。如果不满足这个要求那就不允许这两个操作进行重排序。

happens-before 规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。
  3. volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  4. 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  5. 线程启动(start) 规则:如果 线程A 执行操作 ThreadB.start()(启动线程B),那么 线程A 的 ThreadB.start() 操作 happens-before 于 线程B 中的任意操作
  6. 线程终结(join)规则:如果 线程A 执行操作 ThreadB.join() 并成功返回,那么 线程B 中的任意操作 happens-before 于 线程A 从 ThreadB.join() 操作成功返回。

Ps:JSR-133 规则中只有以上 6 条,但是网上目前流传最多的则是 8 条的版本,即包括下面 2 条:

  1. 线程中断操作:对线程 interrupt() 方法的调用,happens-before 于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到线程是否有中断发生。
  2. 对象终结规则:一个对象的初始化完成,happens-before 于这个对象的 finalize() 方法的开始。

happens-before 与 JMM的关系如下图所示:


四、JMM三大特征

在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurrent包等解决原子性、有序性和可见性三大问题。

其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。

在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。

1、原子性

线程切换带来的原子性问题:我们把一个或者多个操作在CPU执行的过程中不能被中断的特性称之为原子性,这里说的是CPU指令级别的原子性。

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。这两个字节码,在Java中对应的关键字就是synchronized。

因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

2、可见性

缓存导致的可见性问题:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称之为可见性。

JMM 是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的 volatile 关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 volatile 来保证多线程操作时变量的可见性。

除了 volatile,Java 中的 synchronized 和 final 两个关键字也可以实现可见性。

3、有序性

编译优化带来的有序性问题:有序性指的是程序要按照代码的先后顺序执行,编译器为了优化性能,有时候会改变程序中语句的先后顺序。

在 Java 中,可以使用 synchronized 和 volatile 来保证多线程之间操作的有序性。实现方式有所区别:

volatile 关键字会禁止指令重排。synchronized 关键字保证同一时刻只允许一条线程操作。

好了,这里简单的介绍完了 Java 并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized 关键字是万能的,它可以同时满足以上三种特性,这其实也是很多人滥用 synchronized 的原因。

但是 synchronized 是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

0 人点赞