- Published on
可见性和有序性的原理-03
1-原子性问题、可见性问题和有序性问题
原子性问题
所谓原子操作,就是“不可中断的一个或一系列操作”,是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何线程的切换。
可见性问题
一个线程对共享变量的修改,另一个线程能够立刻可见,我们称该共享变量具备内存可见性。
谈到内存可见性,要先引出JMM(Java Memory Model,Java内存模型)的概念。JMM规定,将所有的变量都存放在公共主存中,当线程使用变量时会把主存中的变量复制到自己的工作空间(或者叫私有内存)中,线程对变量的读写操作,是自己工作内存中的变量副本。
如果两个线程同时操作一个共享变量,就可能发生可见性问题。举一个例子:
(1)主存中有变量sum,初始值为0。
(2)线程A计划将sum加1,先将sum=0复制到自己的私有内存中,然后更新sum的值。线程A操作完成之后其私有内存中sum的值为1,然而线程A将更新后的sum值回刷到主存的时间是不固定的。
(3)在线程A没有回刷sum到主存前,刚好线程B同样从主存中读取sum,此时值为0,和线程A进行同样的操作,最后期盼的sum=2目标没有达成,最终sum=1。
线程B没有将sum变成2的原因是:线程A的修改还在其工作内存中,对线程B不可见,因为线程A的修改还没有刷入主存。这就发生了典型的内存可见性问题。
要想解决多线程的内存可见性问题,所有线程都必须将共享变量刷新到主存,一种简单的方案是:使用Java提供的关键字volatile修饰共享变量。
有序性问题
所谓程序的有序性,是指程序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果,即发生了有序性问题。
**指令重排序(Reordering):**什么是指令重排序。一般来说,CPU为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行顺序同代码中的先后顺序一致,但是它会保证程序最终的执行结果和代码顺序执行的结果是一致的。指令重排序不会影响单个线程的执行,但是会影响多个线程并发执行的正确性。
2-MESI协议原理
由于每个线程可能会运行在不同的CPU内核中,因此每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个CPU内核中,在不同CPU内核中运行的线程看到同一个变量的缓存值就会不一样,就可能发生内存的可见性问题。
硬件层的MESI协议是一种用于解决内存的可见性问题的手段,接下来为大家介绍MESI协议的原理和具体内容。
总线锁和缓存锁
为了解决内存的可见性问题,CPU主要提供了两种解决办法:总线锁和缓存锁。
- 总线锁
总线锁的粒度太大了,最好的方法就是控制锁的保护粒度,只需要保证被多个CPU缓存的同一份数据一致即可。所以引入了缓存锁(如缓存一致性机制)。
- 缓存锁
缓存一致性机制就是当某CPU对高速缓存中的数据进行操作之后,通知其他CPU放弃存储在它们内部的缓存数据,或者从主存中重新读取。用MESI描述的原理图如图。
2.1-MSI协议
缓存一致性协议的基础版本为MSI协议,也叫作写入失效协议。如果同时有多个CPU要写入,总线会进行串行化,同一时刻只会有一个CPU获得总线的访问权。比如CPU c1、c2对变量m进行读写,采用缓存回写模式,总线操作如表:
2.2-MESI协议详细说明(理解比较绕)
在MESI协议中,每个缓存行(Cache Line)有4种状态,即M、E、S和I(全名是Modified、Exclusive、Shared和Invalid),可用2 bit表示。
MESI协议是以缓存行的4种状态的首字母缩写来命名的。该协议要求在每个缓存行上维护两个状态位,使得每个数据位可能处于M、E、S和I这4种状态之一。
1.M:被修改(Modified)
该缓存行的数据只在本CPU的私有高速缓存中进行了缓存,而其他CPU中没有,是被修改过的(Dirty),即与主存中的数据不一致,且没有更新到内存中。该缓存行中的内存需要在未来的某个时间点(允许其他CPU读取主存中相应的数据之前)写回(Write Back)主存。当被写回主存之后,该缓存行的状态会变成独享状态。
简单来说,处于Modified状态的缓存行数据只在本CPU中有缓存,且其数据与内存中的数据不一致,数据被修改过。
2.E:独享的(Exclusive)
该缓存行的数据只在本CPU的私有高速缓存中进行了缓存,而其他CPU中没有,缓存行的数据是未被修改过的(Clean),并且与主存中的数据一致。该状态下的缓存行在任何时刻被其他CPU读取之后,其状态将变成共享状态。在本CPU修改了缓存行中的数据后,该缓存行的状态可以变成Modified状态。
简单来说,处于Exclusive状态的缓存行数据只在本CPU中有缓存,且其数据与内存中一致,没有被修改过。
3.S:共享的(Shared)
该缓存行的数据可能在本CPU以及其他CPU的私有高速缓存中进行了缓存,并且各CPU私有高速缓存中的数据与主存数据一致(Clean), 当有一个CPU修改该缓存行时,其他CPU中该缓存行将被作废,变成无效状态。
简单来说,处于Shared状态的缓存行的数据在多个CPU中都有缓存,且与主存一致。
4.I:无效的(Invalid)
该缓存行是无效的,可能有其他CPU修改了该缓存行。
2.3-volatile的原理
在正常情况下,系统操作并不会校验共享变量的缓存一致性,只有当共享变量用volatile关键字修饰了,该变量所在的缓存行才被要求进行缓存一致性的校验。
由于共享变量var加了volatile关键字,因此在汇编指令中,操作var之前多出一个lock前缀指令lock addl,该lock前缀指令有三个功能。
(1)将当前CPU缓存行的数据立即写回系统内存
在对volatile修饰的共享变量进行写操作时,其汇编指令前用lock前缀修饰。lock前缀指令使得在执行指令期间,CPU可以独占共享内存(即主存)。对共享内存的独占,老的CPU(如Intel 486)通过总线锁方式实现。由于总线锁开销比较大,因此新版CPU(如IA-32、Intel 64)通过缓存锁实现对共享内存的独占性访问,缓存锁(缓存一致性协议)会阻止两个CPU同时修改共享内存的数据。
(2)lock前缀指令会引起在其他CPU中缓存了该内存地址的数据无效
写回操作时要经过总线传播数据,而每个CPU通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当CPU发现自己缓存行对应的内存地址被修改时,就会将当前CPU的缓存行设置为无效状态,当CPU要对这个值进行修改的时候,会强制重新从系统内存中把数据读到CPU缓存。
(3)lock前缀指令禁止指令重排
lock前缀指令的最后一个作用是作为内 存屏障(Memory Barrier)使用,可以禁止指令重排序,从而避免多线程环境下程序出现乱序执行的现象。
volatile语义中的内存屏障
在Java代码中,volatile关键字主要有两层语义:
·不同线程对volatile变量的值具有内存可见性,即一个线程修改了某个volatile变量的值,该值对其他线程立即可见。
·禁止进行指令重排序。
2.4-有序性与内存屏障
内存屏障又称内存栅栏(Memory Fences),是一系列的CPU指令,它的作用主要是保证特定操作的执行顺序,保障并发执行的有序性。在编译器和CPU都进行指令的重排优化时,可以通过在指令间插入一个内存屏障指令,告诉编译器和CPU,禁止在内存屏障指令前(或后)执行指令重排序。
As-if-Serial规则
在单核CPU的场景下,当指令被重排序之后,如何保障运行的正确性呢?其实很简单,编译器和CPU都需要遵守As-if-Serial规则。
As-if-Serial规则的具体内容为:无论如何重排序,都必须保证代码在单线程下运行正确。
为了遵守As-if-Serial规则,编译器和CPU不会对存在数据依赖关系的操作进行重排序,因为这种重排序会改变执行结果。但是,如果指令之间不存在数据依赖关系,这些指令可能被编译器和CPU重排序。
虽然编译器和CPU遵守了As-if-Serial规则,无论如何,也只能在单CPU执行的情况下保证结果正确。在多核CPU并发执行的场景下,由于CPU的一个内核无法清晰分辨其他内核上指令序列中的数据依赖关系,因此可能出现乱序执行, 从而导致程序运行结果错误。
所以,As-if-Serial规则只能保障单内核指令重排序之后的执行结果正确,不能保障多内核以及跨CPU指令重排序之后的执行结果正确。
硬件层面的内存屏障
1.硬件层的内存屏障定义
内存屏障又称内存栅栏,是让一个CPU高速缓存的内存状态对其他CPU内核可见的一项技术,也是一项保障跨CPU内核有序执行指令的技术。
硬件层常用的内存屏障分为三种:读屏障(Load Barrier)、写屏障(Store Barrier)和全屏障(Full Barrier)
2.5-JMM详解
JMM(Java Memory Model,Java内存模型)并不像JVM内存结构一样是真实存在的运行实体,更多体现为一种规范和规则。
2.5.1 Java内存模型
JMM最初由JSR-133(Java Memory Model and Thread Specification)文档描述,JMM定义了一组规则或规范,该规范定义了一个线程对共享变量写入时,如何确保对另一个线程是可见的。实际上,JMM提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性。
Java内存模型定义的两个概念:
(1)主存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主存中,无论该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括共享的类信息、常量、静态变量。由于是共享数据区域,因此多条线程对同一个变量进行访问可能 会发现线程安全问题。
(2)工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主存中的变量副本),每个线程只能访问自己的工作内存,即线程中的本地变量对其他线程是不可见的,即使两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括字节码行号指示器、相关Native方法的信息。注意,由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
2.5.2 JMM与JVM物理内存的区别
**JMM(Java内存模型)看上去和JVM(Java内存结构)**差不多,很多人会误以为两者是一回事,这也就导致面试过程中经常答非所问。
JMM属于语言级别的内存模型,它确保了在不同的编译器和不同的CPU平台上为Java程序员提供一致的内存可见性保证和指令并发执行的有序性。
以Java为例,一个i方法编译成字节码后,在JVM中是分成以下三个步骤运行的:
(1)从主存中复制i的值并复制到CPU的工作内存中。
(2)CPU取工作内存中的值,然后执行i操作,完成后刷新到工作内存。
(3)将工作内存中的值更新到主存。
当多个线程同时访问该共享变量i时,每个线程都会将变量i复制到工作内存中进行修改,如果线程A读取变量i的值时,线程B正在修改i的值,问题就来了:线程B对变量i的修改对线程A而言就是不可见的。
这就是多线程并发访问共享变量所造成的结果不一致问题,该问题属于JMM需要解决的问题。
JMM属于概念和规范维度的模型,是一个参考性质的模型。JVM模型定义了一个指令集、一个虚拟计算机架构和一个执行模型。具体的JVM实现需要遵循JVM的模型,它能够运行根据JVM模型指令集编写的代码,就像真机可以运行机器代码一样。
虽然JVM也是一个概念和规范维度的模型,但是大家常常将JVM理解为实体的、实现维度的虚拟机,通常是指HotSpot VM。
2.5.3 JMM的8个操作
Java内存模型规定所有的变量都存储在主存中(类似于前面讲的主存或者物理内存),每个线程都有自己的工作内存(类似于CPU中的高速缓存)。工作内存保存了线程使用到的变量的拷贝副本,线程对变量的所有操作(读取、赋值等)必须在该线程的工作内存中进行。
2.5.4 JMM如何解决有序性问题
JMM内存屏障主要有Load和Store两类,具体如下:
(1)Load Barrier(读屏障)
在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主存加载数据。
(2)Store Barrier(写屏障)
在写指令之后插入写屏障,能让写入缓存的最新数据写回主存。
在实际使用时,会对以上JMM的Load Barrier和Store Barrier两类屏障进行组合,组合成LoadLoad(LL)、StoreStore(SS)、LoadStore(LS)、StoreLoad(SL)四个屏障,用于禁止特定类型的CPU重排序。
2.6-Happens-Before规则
JMM定义了一套自己的规则:Happens-Before(先行发生)规则,并且确保只要两个Java语句之间必须存在Happens-Before关系,JMM尽量确保这两个Java语句之间的内存可见性和指令有序性。
Happens-Before规则的主要内容包括以下几个方面:
(1)程序顺序执行规则(as-if-serial规则)
在同一个线程中,有依赖关系的操作按照先后顺序,前一个操作必须先行发生于后一个操作(Happens-Before)。换句话说,单个线程中的代码顺序无论怎么重排序,对于结果来说是不变的。
(2)volatile变量规则
对volatile(修饰的)变量的写操作必须先行发生于对volatile变量的读操作。
(3)传递性规则
如果A操作先于B操作,而B操作又先行发生于C操作,那么A操作先行发生于C操作。
(4)监视锁规则(Monitor Lock Rule)
对一个监视锁的解锁操作先行发生于后续对这个监视锁的加锁操作。
(5)start规则
对线程的start操作先行于这个线程内部的其他任何操作。具体来说,如果线程A执行B.start()启动线程B,那么线程A的B.start()操作先行发生于线程B中的任意操作。
(6)join规则
如果线程A执行了B.join()操作并成功返回,那么线程B中的任意操作先行发生于线程A所执行的ThreadB.join()操作
2.7-volatile不具备原子性
volatile能保证数据的可见性,但volatile不能完全保证数据的原子性,对于volatile类型的变量进行复合操作(如++),其仍存在线程不安全的问题。