【多线程及高并发 一】内存模型及理论基础

发布时间:2023年12月18日

👏作者简介:大家好,我是若明天不见,BAT的Java高级开发工程师,CSDN博客专家,后端领域优质创作者
📕系列专栏:多线程及高并发系列
📕其他专栏:微服务框架系列MySQL系列Redis系列Leetcode算法系列GraphQL系列
📜如果感觉博主的文章还不错的话,请👍点赞收藏关注👍支持一下博主哦??
?时间是条环形跑道,万物终将归零,亦得以圆全完美


Java 内存模型

从 Java5 开始,Java 开始使用新的内存模型 JSR-133:Java MEMORY Model AND Thread Specification,避免在并发编程下 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现的问题。主要目的是为了简化多线程编程,增强程序可移植性

JMM 本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法

  • volatilesynchronizedfinalLock关键字
  • happens-before原则

Java 内存模型抽象

Java 内存模型是一种规范,定义了很多东西:

  • 所有的变量都存储在主内存
  • 每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的拷贝副本
  • 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存
  • 不同的线程之间无法直接访问对方本地内存中的变量

JDK 1.5 后,Java 内存模型抽象与CPU 缓存模型十分相似

线程通信

下图两个线程都对一个共享变量进行操作,共享变量初始值为 1,每个线程都变量进行加 1,预期共享变量的值为 3

从整体来看,这两个步骤实质上是两个线程间发送消息,而且这个通信过程必须要经过主内存

Java 内存区域和内存模型的区别

这 Java 内存区域和内存模型是完全不一样的两个东西:

  • JVM 内存结构/区域和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例
  • Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的

指令重排序

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

重排序分三种类型:

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

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题

  • 对于编译器重排序,JMM 的编译器重排序规则会禁止特定类型的编译器重排序
  • 对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障memory barriers)指令,通过内存屏障指令来禁止特定类型的处理器重排序

内存屏障

为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:

屏障类型指令示例说明
LoadLoad BarriersLoad1; LoadLoad; Load2确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore BarriersStore1; StoreStore; Store2确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore BarriersLoad1; LoadStore; Store2确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad BarriersStore1; StoreLoad; Load2确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。

StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)

happen-before

JSR-133 提出了happens-before的概念,happens-before的八项规则是用于定义多线程程序中操作之间的顺序关系,以确保程序的可见性和有序性。它是Java内存模型(Java MEMORY Model,JMM)的一部分

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系

happens-before的八项规则:

  1. 程序顺序规则(Program ORDER Rule): 在单个线程中,按照程序的顺序执行的操作具有happens-before关系。换句话说,前一个操作的结果对后续操作是可见的。
  2. 监视器锁规则(Monitor LOCK Rule): 一个解锁操作happens-before后续对同一个锁的加锁操作。这意味着在释放锁之前的所有操作对于获取同一个锁的线程来说都是可见的。
  3. volatile变量规则(Volatile Variable Rule): 对一个volatile变量的写操作happens-before后续对该变量的读操作。这保证了对volatile变量的写操作对于后续线程的读操作是可见的。
  4. 线程启动规则(Thread START Rule): 一个线程的启动操作happens-before于该线程中的任何操作。
  5. 线程终止规则(Thread Termination Rule): 一个线程中的任何操作happens-before于其他线程检测到该线程已经终止的操作。
  6. 线程中断规则(Thread Interruption Rule): 对线程的中断操作happens-before于被中断线程检测到中断的操作。
  7. 线程终结规则(Thread Finalization Rule): 一个对象的初始化完成(构造函数执行结束)happens-before于它的finalize()方法的开始
  8. 传递规则(Transitive Rule): 如果操作A happens-before操作B,且操作B happens-before操作C,则操作A happens-before操作C。这意味着如果存在操作顺序A -> B -> C,则操作A对操作C具有happens-before关系

as-if-serial

as-if-serial原则的含义是,虚拟机可以对代码进行各种优化和重排序,只要在单线程环境下,程序的执行结果与按照源代码顺序执行的结果一致,就不会违反Java内存模型

为了遵守as-if-serial原则,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

如上图所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。下图是该程序的两种执行顺序:

as-if-serial原则把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

多线程

提高处理器利用率,提高程序响应性,支持并发编程,支持后台任务执行,以及实现复杂的算法和数据结构,从而提高计算机系统的性能和效率,计算机引入了多线程:

  • CPU 增加了缓存,以均衡与内存的速度差异(导致可见性问题)
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异(导致原子性问题)
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用(导致有序性问题)

CPU Cache 缓存 的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存 的是硬盘数据用于解决硬盘访问速度过慢的问题

多线程三特性

  • 可见性:一个线程对共享变量的修改能够被其他线程及时地观察到
  • 原子性:操作要么完整地执行,要么不执行
  • 有序性:程序执行的结果符合预期的顺序

由于这三种特性,并发编程时需要结合synchronizedvolatileLock等功能,保证并发下的线程安全

可见性

由于CPU缓存,没有立即写入主内存中,可能会引起一个线程对共享变量的修改,另外一个线程不能够立刻看到,导致可见性问题

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值

原子性

由于CPU分时复用(线程切换),导致操作不一定是全部已执行完毕

int i = 1;

// 线程1执行
i++;

// 线程2执行
i++;

i++实际需要三条 CPU 指令:

  1. 将变量 i 从内存读取到 CPU寄存器
  2. 在CPU寄存器中执行 i + 1 操作
  3. 将最后的结果 i 写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)

由于线程切换,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3

有序性

由于编译器和处理器存在指令重排序,导致程序执行的顺序不一定按照代码的先后顺序执行

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,但在真正执行这段代码的时候可能会发生指令重排序(Instruction Reorder),导致会语句2在语句1前执行


参考资料:

  1. Java 并发编程实战
  2. Java 并发 - 理论基础
  3. 面试官:说说什么是Java内存模型?
文章来源:https://blog.csdn.net/why_still_confused/article/details/135037225
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。