Java并发编程——线程安全性深层原因

7次阅读

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

线程安全性深层原因
这里我们将会从计算机硬件和编辑器等方面来详细了解线程安全产生的深层原因。
缓存一致性问题
CPU 内存架构
随着 CPU 的发展,而因为 CPU 的速度和内存速度不匹配的问题(CPU 寄存器的访问速度非常快,而内存访问速度相对偏慢),所有在 CPU 和内存之间出现了多级高速缓存。下图是现代 CPU 和内存的一般架构图:我们可以看到高速缓存也分为三级缓存,越靠近寄存器的级别缓存访问速度越快。其中 L3 Cache 为多核共享的,L1 和 L2 Cache 为单核独享,而 L1 又有数据缓存(L1 d)和指令缓存 (L1 i)。
正因为高速缓存的出现,各 CPU 内核从主内存获取相同的数据将会存在于缓存中,当多核都对此数据进行操作并修改值,此时另外的核心并不知道此值已被其他核心修改,从而出现缓存不一致的问题。
如何解决缓存一致性问题
解决缓存一致性问题一般有两个方法:

第一个是采用总线锁,在总线级别加锁,这样从内存种访问到的数据将被当个 CPU 核心独占,在多核的情况下对单个资源将是串行化的。这种方式性能上将大打折扣。
第二个是采用缓存锁,在缓存的级别上进行加锁。此种方式需要某种协议对缓存行数据进行同步,后面所说的缓存一致行协议便是一种实现。

缓存一致性协议(MESI)
为了解决缓存一致性的问题,一些 CPU 系列(比如 Intel 奔腾系列)采用了 MESI 协议来解决缓存一致性问题。此协议将每个缓存行(Cache Line)使用 4 种状态进行标记。
M: 被修改(Modified)
该缓存行只被缓存在该 CPU 核心的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它 CPU 读取请主存中相应内存之前)写回(write back)主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive) 状态。
E: 独享的(Exclusive)
该缓存行只被缓存在该 CPU 核心缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它 CPU 核心读取该内存时变成共享状态(shared)。同样地,当 CPU 核心修改该缓存行中内容时,该状态可以变成 Modified 状态。
S: 共享的(Shared)
该状态意味着该缓存行可能被多个 CPU 缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个 CPU 修改该缓存行中,其它 CPU 中该缓存行可以被作废(变成无效状态(Invalid))。
I: 无效的(Invalid)
该缓存是无效的(可能有其它 CPU 核心修改了该缓存行)
在 MESI 协议中,每个 CPU 核心的缓存控制器不仅知道自己的操作(local read 和 local write),每个核心的缓存控制器通过监听也知道其他 CPU 中 cache 的操作(remote read 和 remote write),再确定自己 cache 中共享数据的状态是否需要调整。

local read(LR):读本地 cache 中的数据;
local write(LW):将数据写到本地 cache;
remote read(RR):其他核心发生 read;
remote write(RW):其他核心发生 write;

针对操作,缓存行的状态迁移图如下:
指令重排序问题
在我们编程过程中,习惯性程序思维认为程序是按我们写的代码顺序执行的,举个例子来说,某个程序中有三行代码:
int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3
从程序员角度执行顺序应该是 1 -> 2 -> 3,实际经过编译器和 CPU 的优化很有可能执行顺序会变成 2 -> 1 -> 3(注意这样的优化重排并没有改变最终的结果)。类似这种不影响单线程语义的乱序执行我们称为指令重排。(后面讲 Java 内存模型也会讲到这部分。)
编译器指令重排
举个例子,我们先看可以看一段代码:
class ReorderExample {
int a = 0;
boolean flag = false;
public void write() {
a = 1; // 1
flag = true; // 2
}

public void read() {
if (flag) {// 3
int i = a * a; // 4
}
}
}
在单线程的情况下如果先 write 再 read 的话,i 的结果应该是 1。但是在多线程的情况下,编译器很可能对指令进行重排,有可能出现的执行顺序是 2 -> 3 -> 4 -> 1。这个时候的 i 的结果就是 0 了。(1 和 2 之间以及 3 和 4 之间不存在数据依赖,有关数据依赖在后面的 Java 内存模型中会讲到。)
CPU 指令重排
在 CPU 层面,一条指令被分为多个步骤来执行,每个步骤会使用不同的硬件(比如寄存器、存储器、算术逻辑单元等)。执行多个指令时采用流水线技术进行执行,如下示意图:注意这里出现的”停顿“,出现这个原因是因为步骤 22 需要步骤 13 得到结果后才能进行。CPU 为了进一般优化:消除一些停顿,这时会将指令 3(指令 3 对指令 2 和 1 都没有数据依赖)移到指令 2 之前进行运行。这样就出现了指令重排,根本原因是为了优化指令的执行。
内存系统重排
CPU 经过长时间的优化,在寄存器和 L1 缓存之间添加了 LoadBuffer、StoreBuffer 来降低阻塞时间。LoadBuffer、StoreBuffer,合称排序缓冲 (Memoryordering Buffers (MOB)),Load 缓冲 64 长度,store 缓冲 36 长度,Buffer 与 L1 进行数据传输时,CPU 无须等待。

CPU 执行 load 读数据时,把读请求放到 LoadBuffer,这样就不用等待其它 CPU 响应,先进行下面操作,稍后再处理这个读请求的结果。
CPU 执行 store 写数据时,把数据写到 StoreBuffer 中,待到某个适合的时间点,把 StoreBuffer 的数据刷到主存中。

因为 StoreBuffer 的存在,CPU 在写数据时,真实数据并不会立即表现到内存中,所以对于其它 CPU 是不可见的;同样的道理,LoadBuffer 中的请求也无法拿到其它 CPU 设置的最新数据;由于 StoreBuffer 和 LoadBuffer 是异步执行的,所以在外面看来,先写后读,还是先读后写,没有严格的固定顺序。
由于引入 StoreBuffer 和 LoadBuffer 导致异步模式,从而导致内存数据的读写可能是乱序的(也就是内存系统的重排序)。
延伸
在程序我们常说的三大性质:可见性、原子性、有序性。通过线程安全性深层原因我们能更好的理解这三大性质的根本性原因。(可见性、原子性、有序性会在后面文章中进行详细讲解。)

正文完
 0