2. Java并发编程-MESI协议

193 阅读10分钟

今天我们来聊聊线程安全性,那么何为线程安全性呢?我们来看看它的定义。

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

要想保证线程安全性,就必须解决并发编程中存在的原子性、可见性和有序性问题。

首先,我们需要先来了解下解决CPU缓存一致性问题的MESI协议和Java内存抽象模型JMM。

MESI缓存一致性协议

在操作系统中,多线程应用程序在运行时,因为CPU多级缓存的存在,导致线程读取到的数据可能已经被其他线程进行了修改,却无从感知。如下图所示,如果Thread-0将a=10修改为a=15并写回内存,对于Thread-1来说,这个操作是不可见的,这时候Thread-1读取到的a变量已经是脏数据。这种就是典型的CPU缓存一致性问题。

那么有什么办法可以解决可见性问题呢?

在早期的CPU中,都是通过在总线上加LOCK#锁来解决CPU缓存一致性问题。但这种方法因为锁的是总线,同一时刻只能有一条线程能访问Bus总线,所以性能低下。所以后面诞生了CPU缓存一致性协议的解决方案: MESI一致性协议。

MESI一致性协议

MESI是4中状态的首字符,每种状态表示的是CPU缓存行的状态,包含如下:

(1). M修改(Modified)。表示该缓存行数据变更,未写回内存,与内存数据不一致。

(2). E独占/互斥(Exclusive)。和内存数据一致,该数据不存在其他CPU Core中,仅存在当前缓存行。

(3). S共享(Shared)。当前缓存行和其他CPU Core缓存行都存在一份相同数据,数据和内存一致。

(4). I 无效(Invalid)。修改了相对应内存数据的缓存行已经将数据写回内存。所以当前缓存行失效。

以上述图为例,我以几种场景来解释这四种状态的切换:

1. Thread-0加载数据到CPU缓存中,这时,因为其他CPU Core还未读取数据,所以该缓存行的状态为E(独占)。

2. 在1的基础上,Thread-1加载数据到CPU Core中,因为Thread-0已经加载过该数据,所以Thread-1将状态改为S(共享)。这时Bus总线也会广播信号给其他CPU Core,Thread-0收到总线通知,将状态改为S(共享)。

3. Thread-0修改数据a = 15,同时将缓存行的状态改为M(修改)。

4. Thread-0将a=15写回内存中,Bus总线广播信号,Thread-1收到信号时将缓存行状态改为I(无效)。

通过上面的状态切换场景,我们可以知道,Thread-1读取的缓存行无效之后,想要读取到数据,只能重新从内存中进行加载,这时候内存中的数据已经是最新的。

JMM内存模型

在高级编程语言中如Java,我们的用户无需知道CPU是如何运作的,所以JVM帮我们将底层细节进行抽象,进而产生了JMM内存模型。它描述了一组规则或规范,通过这组规范定义了程序中各个变量的访问方式。

当我们创建线程的时候,会为其分配栈空间,这是线程独有的工作空间。而数据时存储在内存空间的,线程想要操作数据,必须先从内存空间中将数据读取到工作空间,才可进行操作。

八大原子性操作

(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态

(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后

的变量才可以被其他线程锁定

(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存

中,以便随后的load动作使用

(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工

作内存的变量副本中

(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎

(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内

存的变量

(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存

中,以便随后的write的操作

(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值

传送到主内存的变量中

JMM中,原子性、可见性、有序性的解决方案

首先,我们先来理解一下原子性、可见性和有序性。

原子性

原子性的含义指的是一个操作一旦执行,就不可中断且不受其他线程的影响。在Java编程当中,long和double因为是8个字节大小(64位存储单元),所以在32位操作系统中,它们并不是原子性操作。在32位操作系统中,线程一次读取只能读取32位数据,所以可能会有其他线程去读取另外的32位数据。在多线程的环境下,我们可以选择使用AtomicLong和AtomicDouble原子操作类来解决该问题。

在java中,还给我们提供了synchronized和Lock来解决原子性问题。因为synchronized和Lock可以保证同一时刻只有一条线程能访问代码块

可见性

可见性是一种复杂的属性,因为可见性中的错误总会违背我们的直觉。当线程修改了某个共享变量之后,其他线程能否马上感知到这个变量的修改。对于串行程序中,可见性问题是不存在的,因为对于一个变量来说,总是只有一条线程会对其进行访问。但在多线程程序中,可见性问题就需要引起我们的关注。线程对共享变量的修改,都需要拷贝到自己的工作内存中进行操作,而工作内存又是线程独有的,所以对共享变量的修改 对其他线程也是不可见的。另外,指令重排序以及编译优化也可能造成可见性问题。

在java中,提供了volatile来解决可见性问题。当一个变量被volatile修饰时,它保证对该变量的修改会立即被其他线程所看到,它是基于MESI协议来实现的。

有序性

有序性指的是当我们运行一段代码时,我们会认为它是顺序执行的,在串行运行下它确实如此。但是在多线程运行时,则有可能发生乱序执行的现象。因为程序被编译成机器码时,可能会因为编译器的优化导致指令重排序。

在java里面,我们可以使用volatile来保证一定的有序性。另外synchronized和Lock也可以保证有序性。

JMM规范为我们提供了一些具有先天有序性的操作原则——happens-before原则。从JDK 5开始,Java使用了JSR-133内存模型,提供了happens-before原则来辅助程序运行的有序性。

(1). 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执

行。

(2). 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是

说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个

锁)。

(3). volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简

单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的

值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的

线程总是能够看到该变量的最新值。

(4). 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B

的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享

变量的修改对线程B可见

(5). 传递性 A先于B ,B先于C 那么A必然先于C

(6). 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待

当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的

join方法成功返回后,线程B对共享变量的修改将对线程A可见。

(7). 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到

中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

(8). 对象终结规则对象的构造函数执行,结束先于finalize()方法

volatile内存语义

volatile是Java虚拟机提供的轻量级的同步机制,volatile有如下两个作用:

(1) 保证volatile所修饰的变量对所有线程都是可见的。

(2) volatile禁止重排序优化。

内存屏障

volatile是通过插入内存屏障的方式来禁止指令重排的。我们先来了解下硬件层面的内存屏障。

Intel提供了一系列的内存屏障,主要有:

  1. Ifence。是一种Load Barrier,读屏障。
  2. sfence。是一种Store Barrier,写屏障。
  3. mfence。是一种全能型屏障,同时具备Store Barrier和LoadBarrier。
  4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。

因为不同平台的内存屏障实现方式的不同,JVM为我们提供了四类内存屏障指令:

  1. LoadLoad。在两个Load指令之间插入内存屏障,保证Load1读取操作在Load2读取操作之前执行。
  2. LoadStore。在Load和Store之间插入内存屏障,保证Load1的读取操作在Store2的写入操作之前执行。
  3. StoreStore。在两个Store之间插入内存屏障,保证Store1的写入操作在Store2的写入操作之前执行。
  4. StoreLoad。保证Store1写入数据到内存中之后,Load2才执行读取操作。

内存屏障,又称内存栅栏,是一个CPU指令。它的作用有两个:一个是保证了指令执行的顺序;另一个是保证变量的内存可见性。如果在指令之间插入Memory Barrier,则会告诉编译器和CPU,不管什么时候都不能将其他指令和Memory Barrier进行重排,也就是说插入Memory Barrier,那么就禁止了Memory Barrier前后指令的重排序。而且,Memory Barrier指令还能强制刷出CPU缓存,保证了内存可见性。

总结

到了这里我们这一文就算结束了,这篇主要还是偏向概念性的,我希望能简单明了的把一些概念说清楚。

通过本文,我们了解到了CPU在多级缓存下会存在数据一致性问题以及它的解决方案MESI一致性协议,也进一步熟悉了JVM将我们对系统内存的操作抽象出JMM内存模型,令我们不必去熟悉操作系统的细节。最后我们也进入到了这篇文章的主题,如何解决原子性、可见性和有序性问题,Java为我们提供了Synchronized和Lock等锁机制以及volatile去解决并发编程中锁遇到的上述三个问题。