啃碎并发九内存模型之基础概述

46次阅读

共计 8942 个字符,预计需要花费 23 分钟才能阅读完成。

前言

在并发编程中,须要解决两个关键问题:

线程之间如何通信;

线程之间如何同步;

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

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。

在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送音讯来显式进行通信。

线程同步是指程序用于管制不同线程之间操作产生绝对程序的机制

在共享内存的并发模型里,同步是显式进行的。程序员必须显式指定某个办法或某段代码须要在线程之间互斥执行。

在消息传递的并发模型里,因为音讯的发送必须在音讯的接管之前,因而同步是隐式进行的。

Java 的并发采纳的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员齐全通明 — 隐式通信、显示同步 。如果你想设计体现良好的并发程序, 了解 Java 内存模型是十分重要的。Java 内存模型规定了如何和何时能够看到由其余线程批改过后的共享变量的值,以及在必须时如何同步的访问共享变量。


1 为什么要有内存模型

在介绍 Java 内存模型之前,先来看一下到底什么是计算机内存模型,而后再来看 Java 内存模型在计算机内存模型的根底上做了哪些事件。要说计算机的内存模型,就要说一下一段古老的历史,看一下为什么要有内存模型。

1.1 CPU 和缓存一致性

咱们应该都晓得,计算机在执行程序的时候,每条指令都是在 CPU 中执行的,而执行的时候,又免不了要和数据打交道。而计算机下面的数据,是寄存在主存当中的,也就是计算机的物理内存啦。

刚开始,还相安无事的,然而随着 CPU 技术的倒退,CPU 的执行速度越来越快。而因为内存的技术并没有太大的变动,所以从内存中读取和写入数据的过程和 CPU 的执行速度比起来差距就会越来越大,这就导致 CPU 每次操作内存都要消耗很多等待时间

这就像一家守业公司,刚开始,创始人和员工之间工作关系其乐融融,然而随着创始人的能力和野心越来越大,逐步和员工之间呈现了差距,普通员工原来越跟不上 CEO 的脚步。老板的每一个命令,传到到基层员工之后,因为基层员工的理解能力、执行能力的欠缺,就会消耗很多工夫。这也就无形中拖慢了整家公司的工作效率。

可是,不能因为内存的读写速度慢,就不倒退 CPU 技术了吧,总不能让内存成为计算机解决的瓶颈吧。

所以,人们想进去了一个好的方法,就是在 CPU 和内存之间减少高速缓存 。缓存的概念大家都晓得,就是保留一份数据拷贝。 它的特点是速度快,内存小,并且低廉

那么,程序的执行过程就变成了:当程序在运行过程中,会将运算须要的数据从主存复制一份到 CPU 的高速缓存当中,那么 CPU 进行计算时就能够间接从它的高速缓存读取数据和向其中写入数据,当运算完结之后,再将高速缓存中的数据刷新到主存当中

之后,这家公司开始设立中层管理人员,管理人员间接归 CEO 领导,领导有什么批示,间接通知管理人员,而后就能够去做本人的事件了。管理人员负责去协调底层员工的工作。因为管理人员是理解手下的人员以及本人负责的事件的。所以,大多数时候,公司的各种决策,告诉等,CEO 只有和管理人员之间沟通就够了。

而随着 CPU 能力的一直晋升,一层缓存就缓缓的无奈满足要求了,就逐步的衍生出多级缓存。

依照数据读取程序和与 CPU 联合的严密水平,CPU 缓存能够分为一级缓存(L1),二级缓存(L3),局部高端 CPU 还具备三级缓存(L3),每一级缓存中所贮存的全副数据都是下一级缓存的一部分。这三种缓存的 技术难度和制作老本是绝对递加的,所以其容量也是绝对递增的

那么,在有了多级缓存之后,程序的执行就变成了:当 CPU 要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找

随着公司越来越大,老板要管的事件越来越多,公司的治理部门开始改革,开始呈现高层,中层,底层等管理者。一级一级之间逐层治理。

单核 CPU 只含有一套 L1,L2,L3 缓存。如果 CPU 含有多个外围,即多核 CPU,则每个外围都含有一套 L1(甚至和 L2)缓存,而共享 L3(或者和 L2)缓存

公司也分很多种,有些公司只有一个大 Boss,他一个人说了算。然而有些公司有比方联席总经理、合伙人等机制。

单核 CPU 就像一家公司只有一个老板,所有命令都来自于他,那么就只须要一套治理班底就够了。

多核 CPU 就像一家公司是由多个合伙人独特开办的,那么,就须要给每个合伙人都设立一套供本人间接领导的高层管理人员,多个合伙人共享应用的是公司的底层员工。

还有的公司,一直壮大,开始差分出各个子公司。各个子公司就是多个 CPU 了,相互之前没有共用的资源。互不影响。

一个单 CPU 双核的缓存构造

随着计算机能力一直晋升,开始反对多线程。那么问题就来了。咱们别离来剖析下单线程、多线程在单核 CPU、多核 CPU 中的影响。

单线程:CPU 外围的缓存只被一个线程拜访。缓存独占,不会呈现拜访抵触等问题。

单核 CPU,多线程:过程中的多个线程会同时拜访过程中的共享数据,CPU 将某块内存加载到缓存后,不同线程在拜访雷同的物理地址的时候,都会映射到雷同的缓存地位,这样即便产生线程的切换,缓存依然不会生效。但因为任何时刻只能有一个线程在执行,因而不会呈现缓存拜访抵触。

多核 CPU,多线程 :每个核都至多有一个 L1 缓存。多个线程拜访过程中的某个共享内存,且这多个线程别离在不同的外围上执行,则每个外围都会在各自的 caehe 中保留一份共享内存的缓冲。 因为多核是能够并行的,可能会呈现多个线程同时写各自的缓存的状况,而各自的 cache 之间的数据就有可能不同

在 CPU 和主存之间减少缓存,在多线程场景下就可能存在缓存一致性问题 ,也就是说, 在多核 CPU 中,每个核的本人的缓存中,对于同一个数据的缓存内容可能不统一

如果这家公司的命令都是串行下发的话,那么就没有任何问题。

如果这家公司的命令都是并行下发的话,并且这些命令都是由同一个 CEO 下发的,这种机制是也没有什么问题。因为他的命令执行者只有一套管理体系。

如果这家公司的命令都是并行下发的话,并且这些命令是由多个合伙人下发的,这就有问题了。因为每个合伙人只会把命令下达给本人直属的管理人员,而多个管理人员治理的底层员工可能是专用的。

比方,合伙人 1 要解雇员工 a,合伙人 2 要给员工 a 升职,升职后的话他再被解雇须要多个合伙人散会决定。两个合伙人别离把命令下发给了本人的管理人员。合伙人 1 命令下达后,管理人员 a 在解雇了员工后,他就晓得这个员工被开革了。而合伙人 2 的管理人员 2 这时候在没失去音讯之前,还认为员工 a 是退职的,他就怅然的接管了合伙人给他的升职 a 的命令。

多核 CPU 多线程场景下缓存不统一问题

1.2 处理器优化和指令重排

下面提到在 CPU 和主存之间减少缓存,在多线程场景下会存在缓存一致性问题。除了这种状况,还有一种硬件问题也比拟重要。那就是为了使处理器外部的运算单元可能尽量的被充分利用,处理器可能会对输出代码进行乱序执行解决。这就是处理器优化

除了当初很多风行的处理器会对代码进行优化乱序解决,很多编程语言的编译器也会有相似的优化,比方:Java 虚拟机的即时编译器(JIT)也会做指令重排

可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题

对于员工组织调整的状况,如果容许人事部在接到多个命令后进行随便拆分乱序执行或者重排的话,那么对于这个员工以及这家公司的影响是十分大的。

1.3 并发编程的问题

后面说的和硬件无关的概念你可能听得有点蒙,还不晓得他到底和软件有啥关系。然而对于并发编程的问题你应该有所理解,比方:原子性问题,可见性问题和有序性问题

其实,原子性问题,可见性问题和有序性问题,是人们形象定义进去的 。而这个形象的底层问题就是后面提到的 缓存一致性问题、处理器优化问题和指令重排问题 等。缓存一致性问题其实就是可见性问题。而处理器优化是能够导致原子性问题的。指令重排即会导致有序性问题

原子性 是指在一个操作中就是 CPU 不能够在中途暂停而后再调度,既不被中断操作,要不执行实现,要不就不执行。

可见性 是指当多个线程拜访同一个变量时,一个线程批改了这个变量的值,其余线程可能立刻看失去批改的值。

有序性 是指程序执行的程序依照代码的先后顺序执行。

2 什么是内存模型

后面提到的,缓存一致性问题、处理器器优化的指令重排问题是硬件的一直降级导致的。那么,有没有什么机制能够很好的解决下面的这些问题呢?

最简略间接的做法就是破除处理器和处理器的优化技术、破除 CPU 缓存,让 CPU 间接和主存交互。然而,这么做尽管能够保障多线程下的并发问题。然而,这就有点因噎废食了。

所以,为了保障并发编程中能够满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型,定义了共享内存零碎中多线程程序读写操作行为的标准

通过这些规定来标准对内存的读写操作,从而保障指令执行的正确性。它与处理器无关、与缓存无关、与并发无关、与编译器也无关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存拜访问题,保障了并发场景下的一致性、原子性和有序性。

内存模型解决并发问题次要采纳两种形式:限度处理器优化和应用内存屏障

3 什么是 Java 内存模型

后面介绍过了计算机内存模型,这是解决多线程场景下并发问题的一个重要标准。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同

咱们晓得,Java 程序是须要运行在 Java 虚拟机下面的,Java 内存模型(Java Memory Model ,JMM)就是一种合乎内存模型标准的,屏蔽了各种硬件和操作系统的拜访差别的,保障了 Java 程序在各种平台下对内存的拜访都能保障成果统一的机制及标准

Java 内存模型规定了 所有的变量都存储在主内存中,每条线程还有本人的工作内存 ,线程的工作内存中保留了该线程中用到的变量的主内存正本拷贝, 线程对变量的所有操作都必须在工作内存中进行,而不能间接读写主内存。不同的线程之间也无奈间接拜访对方工作内存中的变量,线程间变量的传递均须要本人的工作内存和主存之间进行数据同步进行。

而 JMM 就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步

主内存与工作内存交互示意

特地须要留神的是,主内存和工作内存与 JVM 内存构造中的 Java 堆、栈、办法区等并不是同一个档次的内存划分,无奈间接类比

再来总结下,JMM 是一种标准,标准了 Java 虚拟机与计算机内存是如何协同工作的,目标是解决因为多线程通过共享内存进行通信时,存在的本地内存数据不统一、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目标是保障并发编程场景中的原子性、可见性和有序性。

所以,如果你想设计体现良好的并发程序,了解 Java 内存模型是十分重要的。Java 内存模型规定了如何和何时能够看到由其余线程批改过后的共享变量的值,以及在必须时如何同步的访问共享变量

3.1 Java 内存模型形象

在 Java 中,所有实例域、动态域和数组元素存储在堆内存中,堆内存在线程之间共享 局部变量(Local variables),办法定义参数(formal method parameters)和异样处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java 线程之间的通信由 Java 内存模型(JMM)管制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从形象的角度来看,JMM 定义了线程和主内存之间的形象关系:

线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个公有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的正本。本地内存是 JMM 的一个抽象概念,并不实在存在。它涵盖了缓存,写缓冲区,寄存器以及其余的硬件和编译器优化

Java 内存模型形象示意图

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

首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去;

而后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量;

上面通过示意图来阐明这两个步骤:

线程 A 与线程 B 之间通信

如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的正本。假如初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假如值为 1)长期寄存在本人的本地内存 A 中。当线程 A 和线程 B 须要通信时,线程 A 首先会把本人本地内存中批改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1

从整体来看,这两个步骤本质上是线程 A 在向线程 B 发送音讯,而且这个通信过程必须要通过主内存。JMM 通过管制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保障

3.2 重排序

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

1. 编译器优化的重排序。编译器在不扭转单线程程序语义的前提下,能够重新安排语句的执行程序。

2. 指令级并行的重排序 。古代处理器采纳了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。 如果不存在数据依赖性,处理器能够扭转语句对应机器指令的执行程序

3. 内存零碎的重排序。因为处理器应用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 Java 源代码到最终理论执行的指令序列,会别离经验上面三种重排序:

三种重排序

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

对于编译器,JMM 的编译器重排序规定会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

对于处理器重排序 ,JMM 的处理器重排序规定会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令, 通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供统一的内存可见性保障。

3.3 处理器重排序

古代的处理器应用 写缓冲区 来长期保留向内存写入的数据。写缓冲区能够保障指令流水线继续运行,它能够防止因为处理器停顿下来期待向内存写入数据而产生的提早 。同时,通过以批处理的形式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的屡次写, 能够缩小对内存总线的占用 。尽管写缓冲区有这么多益处, 但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个个性会对内存操作的执行程序产生重要的影响:

处理器对内存的读 / 写操作的执行程序,不肯定与内存理论产生的读 / 写操作程序统一!

两个处理器示例

假如处理器 A 和处理器 B 按程序的程序并行执行内存拜访,最终却可能失去 x = y = 0。具体的起因如下图所示

处理器 A 和处理器 B 并行执行程序

处理器 A 和 B 同时把共享变量写入在写缓冲区中(A1、B1),而后再从内存中读取另一个共享变量(A2、B2),最初才把本人写缓冲区中保留的脏数据刷新到内存中(A3、B3)。当以这种时序执行时,程序就能够失去 x = y = 0 的后果

从内存操作理论产生的程序来看,直到处理器 A 执行 A3 来刷新本人的写缓存区,写操作 A1 才算真正执行了。尽管处理器 A 执行内存操作的程序为:A1 -> A2,但内存操作理论产生的程序却是:A2 -> A1。此时,处理器 A 的内存操作程序被重排序了

这里的要害是,因为写缓冲区仅对本人的处理器可见,它会导致处理器执行内存操作的程序可能会与内存理论的操作执行程序不统一。因为古代的处理器都会应用写缓冲区,因而古代的处理器都会容许对内存写 - 读操作重排序

3.4 内存屏障指令

上面是常见处理器容许的重排序类型的列表:

常见处理器容许的重排序类型

上表单元格中的“N”示意处理器不容许两个操作重排序,“Y”示意容许重排序。从上表咱们能够看出:常见的处理器都容许 Store-Load 重排序;常见的处理器都不容许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 领有绝对较强的处理器内存模型,它们仅容许对写 - 读操作做重排序(因为它们都应用了写缓冲区)。

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

内存屏障指令

3.5 happens-before

JSR-133 内存模型应用 happens-before 的概念来论述操作之间的内存可见性。在 JMM 中,如果一个操作执行的后果须要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既能够是在一个线程之内,也能够是在不同线程之间。

与程序员密切相关的 happens-before 规定如下:

程序程序规定:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。

监视器锁规定:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。

volatile 变量规定:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。

传递性规定:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

留神,两个操作之间具备 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的后果)对后一个操作可见,且前一个操作按程序排在第二个操作之前(the first is visible to and ordered before the second)

happens-before 与 JMM 的关系

如上图所示,一个 happens-before 规定通常对应于多个编译器和处理器重排序规定。对于 Java 程序员来说,happens-before 规定简略易懂,它防止 java 程序员为了了解 JMM 提供的内存可见性保障而去学习简单的重排序规定以及这些规定的具体实现。

3.6 数据依赖性

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

三种类型数据依赖

下面三种状况,只有重排序两个操作的执行程序,程序的执行后果将会被扭转。

后面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会恪守数据依赖性,编译器和处理器不会扭转存在数据依赖关系的两个操作的执行程序

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

3.7 as-if-serial 语义

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了进步并行度),(单线程)程序的执行后果不能被扭转。编译器,runtime 和处理器都必须恪守 as-if-serial 语义。

为了恪守 as-if-serial 编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会扭转执行后果。然而如果操作之间没有数据依赖关系,这些操作就可能被编译器和处理器重排序。

举个例子:

下面三个操作的数据依赖关系如下图所示:

三个操作的数据依赖关系

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

两种可能的执行程序

在计算机中,软件技术和硬件技术有一个独特的指标:在不扭转程序执行后果的前提下,尽可能的开发并行度 。编译器和处理器听从这一指标,从happens-before 的定义咱们能够看出,JMM 同样听从这一指标。

4 Java 内存模型实现

理解 Java 多线程的敌人都晓得,在 Java 中提供了一系列和并发解决相干的关键字,比方:volatilesynchronizedfinalconcurrent包等。其实这些就是Java 内存模型封装了底层的实现后提供给程序员应用的一些关键字

在开发多线程的代码的时候,咱们能够间接应用 synchronized 等关键字来管制并发,素来就不须要关怀底层的编译器优化、缓存一致性等问题。所以,Java 内存模型,除了定义了一套标准,还提供了一系列原语,封装了底层实现后,供开发者间接应用

4.1 原子性

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

因而,在 Java 中能够应用 synchronized 来保障办法和代码块内的操作是原子性的。

4.2 可见性

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

Java 中的 volatile 关键字提供了一个性能,那就是被其润饰的变量在被批改后能够立刻同步到主内存,被其润饰的变量在每次是用之前都从主内存刷新。因而,能够应用 volatile 来保障多线程操作时变量的可见性

除了 volatile,Java 中的synchronizedfinal两个关键字也能够实现可见性。只不过实现形式不同,这里不再开展了。

4.3 有序性

在 Java 中,能够应用 synchronizedvolatile来保障多线程之间操作的有序性。实现形式有所区别:

volatile 关键字会禁止指令重排;

synchronized 关键字保障同一时刻只容许一条线程操作;

正文完
 0