啃碎并发10内存模型之内部原理

2次阅读

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

前言


如上一篇文章所述,Java 内存模型标准了 Java 虚拟机与计算机内存是如何协同工作的。Java 虚拟机是一个残缺计算机的模型,因而,这个模型天然会蕴含一个内存模型—又称为 Java 内存模型。

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

1 Java 内存模型

咱们先来看看 Java 线程运行内存示意图,如下图所示:

Java 线程运行内存示意图

这张图通知咱们在线程运行的时候有一个内存专用的一小块内存,当 Java 程序会将变量同步到线程所在的内存,这时候会操作工作内存中的变量,而线程中变量的值何时同步回主内存是不可预期的

因而,根据下面图的线程运行内存示意图,Java 内存模型在 JVM 外部形象划分为线程栈和堆。如下图所示

JMM 划分为线程栈和堆

1.1 线程栈与堆

每一个运行在 Java 虚拟机里的线程都领有本人的线程栈 。这个线程栈蕴含了  线程调用的办法以后执行点相干的信息,同时线程栈具备如下个性:

即便两个线程执行同样的代码,这两个线程任然在在本人的线程栈中的代码来创立本地变量。因而,每个线程领有每个本地变量的独有版本

所有原始类型的本地变量都寄存在线程栈上,因而对其它线程不可见。一个线程可能向另一个线程传递一个原始类型变量的拷贝,然而它不能共享这个原始类型变量本身。

堆上蕴含在 Java 程序中创立的所有对象,无论是哪一个对象创立的 。这包含原始类型的对象版本。 如果一个对象被创立而后赋值给一个局部变量,或者用来作为另一个对象的成员变量,这个对象任然是寄存在堆上

所以,调用栈和本地变量寄存在线程栈上,对象寄存在堆上,如下图所示:

线程栈与堆 & 变量、对象、调用栈

寄存在堆上的对象能够被所有持有对这个对象援用的线程拜访。当一个线程能够拜访一个对象时,它也能够拜访这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个办法,它们将会都拜访这个对象的成员变量,然而每一个线程都领有这个本地变量的公有拷贝。

下面说到的几点,如下图所示:

栈、堆 & 本地变量、动态变量

1.2 CPU 与内存

家喻户晓,CPU 是计算机的大脑,它负责执行程序的指令。内存负责存数据,包含程序本身数据 。同样大家都晓得,内存比 CPU 慢很多, 当初获取内存中的一条数据大略须要 200 多个 CPU 周期(CPU cycles),而 CPU 寄存器个别状况下 1 个 CPU 周期就够了。上面是 CPU Cache 的简略示意图:

CPU Cache 示意图

随着多核的倒退,CPU Cache 分成了三个级别:L1,L2,L3。级别越小越靠近 CPU,所以速度也更快,同时也代表着容量越小。

在 Linux 上面用 cat /proc/cpuinfo,或 Ubuntu 下 lscpu 看看本人机器的缓存状况,更细的能够通过以下命令看看:

就像数据库 cache 一样,获取数据时首先会在最快的 cache 中找数据,如果没有命中(Cache miss) 则往下一级找,直到三层 Cache 都找不到, 那只有向内存要数据了。一次次地未命中,代表获取数据耗费的工夫越长。

同时,为了高效地存取缓存,不是简略随便地将单条数据写入缓存的。缓存是由缓存行组成的,典型的一行是 64 字节。能够通过上面的 shell 命令,查看 cherency_line_size 就晓得晓得机器的缓存行是多大:

CPU 存取缓存都是以“行”为最小单位操作的。比方:一个 Java long 型占 8 字节,所以从一条缓存行上你能够获取到 8 个 long 型变量。所以如果你拜访一个 long 型数组,当有一个 long 被加载到 cache 中, 你将无耗费地加载了另外 7 个。所以你能够十分快地遍历数组。

2 缓存一致性

因为 CPU 和主存的处理速度上存在肯定差异,为了匹配这种差距,晋升计算机能力,人们在 CPU 和主存之间减少了多层高速缓存。每个 CPU 会有 L1、L2 甚至 L3 缓存,在多核计算机中会有多个 CPU,那么就会存在多套缓存,在这多套缓存之间的数据就可能呈现不统一的景象。为了解决这个问题,有了内存模型。内存模型定义了共享内存零碎中多线程程序读写操作行为的标准。通过这些规定来标准对内存的读写操作,从而保障指令执行的正确性。

其实 Java 内存模型通知咱们通过应用关键词“synchronized”或“volatile”能够让 Java 保障某些束缚:

通过以上形容咱们就能够写出线程平安的 Java 程序,JDK 也同时帮咱们屏蔽了很多底层的货色。

所以,在编译器各种优化及多种类型的微架构平台上,Java 语言标准制定者试图创立一个虚构的概念并传递到 Java 程序员,让他们可能在这个虚构的概念上写出线程平安的程序来,而编译器实现者会依据 Java 语言标准中的各种束缚在不同的平台上达到 Java 程序员所须要的线程平安这个目标

那么,在多种类型微架构平台上,又是如何解决缓存不一致性问题的呢?这是泛滥 CPU 厂商必须解决的问题。为了解决后面提到的缓存数据不统一的问题,人们提出过很多计划,通常来说有以下 2 种计划:

2.1 总线的概念

首先,下面的两种计划,其实都波及到了总线的概念,那到底什么是总线呢?总线是处理器与主存以及处理器与处理器之间进行通信的媒介,有两种根本的互联构造:SMP(symmetric multiprocessing 对称多解决)和 NUMA(nonuniform memory access 非统一内存拜访)

SMP(对称多解决)和 NUMA(非统一内存拜访)

SMP 系统结构十分一般,因为它们最容易构建,很多小型服务器采纳这种构造 。处理器和存储器之间采纳总线互联,处理器和存储器都有负责发送和监听总线播送的信息的总线管制单元。 然而同一时刻只能有一个处理器(或存储控制器)在总线上播送,所有的处理器都能够监听。很容易看出,对总线的应用是 SMP 构造的瓶颈。

NUMP 系统结构中,一系列节点通过点对点网络互联,像一个小型互联网,每个节点蕴含一个或多个处理器和一个本地存储器 。一个节点的本地存储对于其余节点是可见的,所有节点的本地存储一起造成了一个能够被所有处理器共享的全局存储器。 能够看出,NUMP 的本地存储是共享的,而不是公有的,这点和 SMP 是不同的。NUMP 的问题是网络比总线复制,须要更加简单的协定,处理器拜访本人节点的存储器速度快于拜访其余节点的存储器。NUMP 的扩展性很好,所以目前很多大中型的服务器在采纳 NUMP 构造

对于下层程序员来说,最须要了解的是总线线是一种重要的资源,应用的好坏会间接影响程序的执行性能

2.2 总线加 Lock

在晚期的 CPU 当中,是能够通过在总线上加 LOCK#锁的模式来解决缓存不统一的问题。因为 CPU 和其余部件进行通信都是通过总线来进行的,如果对总线加 LOCK#锁的话,也就是说阻塞了其余 CPU 对其余部件拜访(如内存),从而使得只能有一个 CPU 能应用这个变量的内存。在总线上收回了 LCOK# 锁的信号,那么只有期待这段代码齐全执行结束之后,其余 CPU 能力从其内存读取变量,而后进行相应的操作。这样就解决了缓存不统一的问题。

然而因为在锁住总线期间,其余 CPU 无法访问内存,会导致效率低下。因而呈现了第二种解决方案,通过缓存一致性协定来解决缓存一致性问题。

2.3 缓存一致性协定

一致性要求是指,若 cache 中某个字段被批改,那么在主存(以及更高层次)上,该字段的正本必须立刻或最初加以批改,并确保它者援用主存上该字内容的正确性。

当代多处理器零碎中,每个处理器大都有本人的 cache。同一主存块的拷贝能同时存于不同 cache 中,若容许处理器各自独立地批改本人的 cache,就会呈现不统一问题。解决此问题有软件方法和硬件方法。硬件方法能动静地辨认出不统一产生的条件并予以及时处理,从而使 cache 的应用有很高的效率。并且此方法对程序员和系统软件开发人员是通明的,加重了软件研制累赘,从而广泛被采纳。

软件方法最闻名的就是 Intel 的 MESI 协定,MESI 协定保障了每个缓存中应用的共享变量的正本是统一的 。MESI 协定是一种采纳写 – 有效形式的监听协定。 它要求每个 cache 行有两个状态位,用于形容该行以后是处于批改态(M)、专有态(E)、共享态(S)或者有效态(I)中的哪种状态,从而决定它的读 / 写操作行为。这四种状态的定义是:

MESI 协定适宜以总线为互连机构的多处理器零碎 各 cache 控制器除负责响应本人 CPU 的内存读写操作(包含读 / 写命中与未命中)外,还要负责监听总线上的其它 CPU 的内存读写流动(包含读监听命中与写监听命中)并对本人的 cache 予以相应解决 所有这些处理过程要保护 cache 一致性,必须合乎 MESI 协定状态转换规则

MESI 的总线监听与状态转换

上面由图的四个顶点登程,介绍总线监听与状态转换规则:

上述剖析能够看出,尽管各 cache 控制器随时都在监听系统总线,但能监听到的只有读未命中、写未命中以及共享行写命中三种状况。总线监控逻辑并不简单,削减的系统总线传输开销也不大,MESI 协定却无力地保障了主存块脏拷贝在多 cache 中的唯一性,并能及时写回,保障 cache 主存存取的正确性。

然而,值得注意的是,传统的 MESI 协定中有两个行为的执行老本比拟大。一个是将某个 Cache Line 标记为 Invalid 状态,另一个是当某 Cache Line 以后状态为 Invalid 时写入新的数据。所以 CPU 通过 Store Buffer 和 Invalidate Queue 组件来升高这类操作的延时。如下图所示:

CPU 通过 Store Buffer 和 Invalidate Queue 组件来升高这类操作的延时

所以,MESI 协定,能够保障缓存的一致性,然而无奈保障实时性,可能会有极短时间的脏读问题

其实,并非所有状况都会应用缓存一致性的,如:被操作的数据不能被缓存在 CPU 外部或操作数据逾越多个缓存行(状态无奈标识),则处理器会调用总线锁定;另外当 CPU 不反对缓存锁定时,天然也只能用总线锁定了,比如说奔流 486 以及更老的 CPU。

正文完
 0