内存模型

内存模型

如前文所述,现代计算机通常有两个或者更多的 CPU,一些 CPU 还有多个核;其允许多个线程同时运行,每个 CPU 在某个时间片内运行其中的一个线程。在存储管理 https://parg.co/Z47 一节中我们介绍了计算机系统中的不同的存储类别:

image

每个 CPU 包含多个寄存器,这些寄存器本质上就是 CPU 内存;CPU 在寄存器中执行操作的速度会比在主内存中操作快非常多。每个 CPU 可能还拥有 CPU 缓存层,CPU 访问缓存层的速度比访问主内存块很多,但是却比访问寄存器要慢。计算机还包括主内存(RAM),所有的 CPU 都可以访问这个主内存,主内存一般都比 CPU 缓存大很多,但速度要比 CPU 缓存慢。当一个 CPU 需要访问主内存的时候,会把主内存中的部分数据读取到 CPU 缓存,甚至进一步把缓存中的部分数据读取到内部的寄存器,然后对其进行操作。当 CPU 需要向主内存写数据的时候,会将寄存器中的数据写入缓存,某些时候会将数据从缓存刷入主内存。无论从缓存读还是写数据,都没有必要一次性全部读出或者写入,而是仅对部分数据进行操作。

并发编程中的问题,往往源于缓存导致的可见性问题、线程切换导致的原子性问题以及编译优化带来的有序性问题。以 Java 虚拟机为例,每个线程都拥有一个属于自己的线程栈(调用栈),随着线程代码的执行,调用栈会随之改变。线程栈中包含每个正在执行的方法的局部变量。每个线程只能访问属于自己的栈。调用栈中的局部变量,只有创建这个栈的线程才可以访问,其他线程都不能访问。即使两个线程在执行一段相同的代码,这两个线程也会在属于各自的线程栈中创建局部变量。因此,每个线程拥有属于自己的局部变量。所有基本类型的局部变量全部存放在线程栈中,对其他线程不可见。一个线程可以把基本类型拷贝到其他线程,但是不能共享给其他线程,而无论哪个线程创建的对象都存放在堆中。

可见性

所谓的可见性,即是一个线程对共享变量的修改,另外一个线程能够立刻看到。单核时代,所有的线程都是直接操作单个 CPU 的数据,某个线程对缓存的写对另外一个线程来说一定是可见的;譬如下图中,如果线程 B 在线程 A 更新了变量值之后进行访问,那么获得的肯定是变量 V 的最新值。多核时代,每颗 CPU 都有自己的缓存,共享变量存储在主内存。运行在某个 CPU 中的线程将共享变量读取到自己的 CPU 缓存。在 CPU 缓存中,修改了共享对象的值,由于 CPU 并未将缓存中的数据刷回主内存,导致对共享变量的修改对于在另一个 CPU 中运行的线程而言是不可见的。这样每个线程都会拥有一份属于自己的共享变量的拷贝,分别存于各自对应的 CPU 缓存中。

可见性问题最经典的案例即是并发加操作,如下两个线程同时在更新变量 test 的 count 属性域的值,第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。

Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 每个线程中对相同对象执行加操作
count += 1;

在 Java 中,如果多个线程共享一个对象,并且没有合理的使用 volatile 声明和线程同步,一个线程更新共享对象后,另一个线程可能无法取到对象的最新值。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

原子性

所谓的原子性,就是一个或者多个操作在 CPU 执行的过程中不被中断的特性,CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。我们在编程语言中部分看似原子操作的指令,在被编译到汇编之后往往会变成多个操作:

i++
# 编译成汇编之后就是:
# 读取当前变量 i 并把它赋值给一个临时寄存器;
movl i(%rip), %eax
# 给临时寄存器+1;
addl $1, %eax
# 把 eax 的新值写回内存
movl %eax, i(%rip)

我们可以清楚看到 C 代码只需要一句,但编译成汇编却需要三步(这里不考虑编译器优化,实际上通过编译器优化可以将这三条汇编指令合并成一条)。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。按照原子操作解决同步问题方式:依靠处理器原语支持把上述三条指令合三为一,当做一条指令来执行,保证在执行过程中不会被打断并且多线程并发也不会受到干扰。这样同步问题迎刃而解,这也就是所谓的原子操作。但处理器没有义务为任意代码片段提供原子性操作,尤其是我们的临界区资源十分庞大甚至大小不确定,处理器没有必要或是很难提供原子性支持,此时往往需要依赖于锁来保证原子性。

对应原子操作/事务在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。Java 内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

有序性

顾名思义,有序性指的是程序按照代码的先后顺序执行。代码重排是指编译器对用户代码进行优化以提高代码的执行效率,优化前提是不改变代码的结果,即优化前后代码执行结果必须相同。

譬如:

int a = 1, b = 2, c = 3;
void test() {
a = b + 1;
b = c + 1;
c = a + b;
}

在 gcc 下的汇编代码 test 函数体代码如下,其中编译参数: -O0

movl b(%rip), %eax
addl $1, %eax
movl %eax, a(%rip)
movl c(%rip), %eax
addl $1, %eax
movl %eax, b(%rip)
movl a(%rip), %edx
movl b(%rip), %eax
addl %edx, %eax
movl %eax, c(%rip)

编译参数:-O3

movl b(%rip), %eax ;将b读入eax寄存器
leal 1(%rax), %edx ;将b+1写入edx寄存器
movl c(%rip), %eax ;将c读入eax
movl %edx, a(%rip) ;将edx写入a
addl $1, %eax ;将eax+1
movl %eax, b(%rip) ;将eax写入b
addl %edx, %eax ;将eax+edx
movl %eax, c(%rip) ;将eax写入c

在 Java 中与有序性相关的经典问题就是单例模式,譬如我们会采用静态函数来获取某个对象的实例,并且使用 synchronized 加锁来保证只有单线程能够触发创建,其他线程则是直接获取到实例对象。

if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}

不过虽然我们期望的对象创建的过程是:内存分配、初始化对象、将对象引用赋值给成员变量,但是实际情况下经过优化的代码往往会首先进行变量赋值,而后进行对象初始化。假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

内存屏障

多处理器同时访问共享主存,每个处理器都要对读写进行重新排序,一旦数据更新,就需要同步更新到主存上 (这里并不要求处理器缓存更新之后立刻更新主存)。在这种情况下,代码和指令重排,再加上缓存延迟指令结果输出导致共享变量被修改的顺序发生了变化,使得程序的行为变得无法预测。为了解决这种不可预测的行为,处理器提供一组机器指令来确保指令的顺序要求,它告诉处理器在继续执行前提交所有尚未处理的载入和存储指令。同样的也可以要求编译器不要对给定点以及周围指令序列进行重排。这些确保顺序的指令称为内存屏障。具体的确保措施在程序语言级别的体现就是内存模型的定义。

POSIX、C++、Java 都有各自的共享内存模型,实现上并没有什么差异,只是在一些细节上稍有不同。这里所说的内存模型并非是指内存布 局,特指内存、Cache、CPU、写缓冲区、寄存器以及其他的硬件和编译器优化的交互时对读写指令操作提供保护手段以确保读写序。将这些繁杂因素可以笼统的归纳为两个方面:重排和缓存,即上文所说的代码重排、指令重排和 CPU Cache。简单的说内存屏障做了两件事情:拒绝重排,更新缓存

C++11 提供一组用户 API std::memory_order 来指导处理器读写顺序。Java 使用 happens-before 规则来屏蔽具体细节保证,指导 JVM 在指令生成的过程中穿插屏障指令。内存屏障也可以在编译期间指示对指令或者包括周围指令序列不进行优化,称之为编译器屏障,相当于轻量级内存屏障,它的工作同样重要,因为它在编译期指导编译器优化。屏障的实现稍微复杂一些,我们使用一组抽象的假想指令来描述内存屏障的工作原理。使用 MB_R、MB_W、MB 来抽象处理器指令为宏:

  • MB_R 代表读内存屏障,它保证读取操作不会重排到该指令调用之后。

  • MB_W 代表写内存屏障,它保证写入操作不会重排到该指令调用之后。

  • MB 代表读写内存屏障,可保证之前的指令不会重排到该指令调用之后。

这些屏障指令在单核处理器上同样有效,因为单处理器虽不涉及多处理器间数据同步问题,但指令重排和缓存仍然影响数据的正确同步。指令重排是非常底层的且实 现效果差异非常大,尤其是不同体系架构对内存屏障的支持程度,甚至在不支持指令重排的体系架构中根本不必使用屏障指令。具体如何使用这些屏障指令是支持的 平台、编译器或虚拟机要实现的,我们只需要使用这些实现的 API(指的是各种并发关键字、锁、以及重入性等,下节详细介绍)。这里的目的只是为了帮助更好 的理解内存屏障的工作原理。

内存屏障的意义重大,是确保正确并发的关键。通过正确的设置内存屏障可以确保指令按照我们期望的顺序执行。这里需要注意的是内存屏蔽只应该作用于需要同步的指令或者还可以包含周围指令的片段。如果用来同步所有指令,目前绝大多数处理器架构的设计就会毫无意义。

Java 内存模型(Java Memory Model, JMM)

Java 内存模型着眼于描述 Java 中的线程是如何与内存进行交互,以及单线程中代码执行的顺序等,并提供了一系列基础的并发语义原则;最早的 Java 内存模型于 1995 年提出,致力于解决不同处理器/操作系统中线程交互/同步的问题,规定和指引 Java 程序在不同的内存架构、CPU 和操作系统间有确定性地行为。在 Java 5 版本之前,JMM 并不完善,彼时多线程往往会在共享内存中读取到很多奇怪的数据;譬如,某个线程无法看到其他线程对共享变量写入的值,或者因为指令重排序的问题,某个线程可能看到其他线程奇怪的操作步骤。

Java 内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

Java 内存模型对一个线程所做的变动能被其它线程可见提供了保证,它们之间是先行发生关系。

  • 线程内的代码能够按先后顺序执行,这被称为程序次序规则

  • 对于同一个锁,一个解锁操作一定要发生在时间上后发生的另一个锁定操作之前,也叫做管程锁定规则

  • 前一个对 volatile 的写操作在后一个 volatile 的读操作之前,也叫 volatile 变量规则

  • 一个线程内的任何操作必需在这个线程的 start()调用之后,也叫作线程启动规则

  • 一个线程的所有操作都会在线程终止之前,线程终止规则

  • 一个对象的终结操作必需在这个对象构造完成之后,也叫对象终结规则

对于程序次序规则来说,就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。