一、程序执行流程

JMM使用共享内存模型,因为JAVA的线程是内核线程,是需要使用到硬件的。

1、单核cpu的执行流程1.png

一个程序是保存在磁盘上面的,启动之后会把需要的数据加载到内存,分配线程资源。
例如要运行 x = 5;y = x + 5; 这两行代码,那么cpu会先从pc(程序计数器)中读取指令地址,并从寄存器,高速缓存,内存中依次查找x的值,经过计算之后把y = 8 写回内存。
数据交互是通过系统总线的。
因为cup的速度比内存快很多,所以需要在cup和内存之间引入高速缓存。
高速缓存的容量比内存大,比内存小。
2.pngcpu执行一条指令需要一个时钟周期,那么主内存加载一条指令需要167个时钟周期,这个速度对于cpu来说很慢,对于一级缓存需要4个时钟周期,在一级缓存加载的时候cup回去处理别的线程,提高cpu的利用率。
三级缓存是所有核心共享的,刚刚使用过的数据是不会被马上淘汰的(时间局部性),会在缓存中保存一段时间循环使用,减少cpu的等待时间。

2、局部性原理

在CPU访问存储设备时,无论是存取数据或存取指令,都会取在一片连续的区域中的,这就是局部性原理。
时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。 比如循环、递归、方法的反复调用等。
空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。 比如顺序执行的代码、连续创建的两个对象、数组等。所以在读取的时候,会把附近的数据一并读取到。

3、多核cpu的执行流程

物理CPU:物理CPU就是插在主机上的真实的CPU硬件,在Linux下可以数不同的physical id 来确认主机的物理CPU个数。
核心数:我们常常会听说多核处理器,其中的核指的就是核心数。在Linux下可以通过cores来确认主机的物理CPU的核心数。
逻辑CPU:逻辑CPU跟超线程技术有联系,假如物理CPU不支持超线程的,那么逻辑CPU的数量等于核心数的数量;如果物理CPU支持超线程,那么逻辑CPU的数目是核心数数目的两倍。在Linux下可以通 过 processors 的数目来确认逻辑CPU的数量。
目前使用的计算机多属于一个物理cpu多个核心。多核和多cpu并不冲突,也可以多个cpu,每个cpu有多个核心。
3.png现代CPU为了提升执行效率,减少CPU与内存的交互,一般在CPU上集成了多级缓存架 构,常见的为三级缓存结构。
每个CPU的内核都有寄存器和L1,L2,L3高速缓存,其中L1,L2高速缓存是由内核独享的,L3高速缓存是由CPU的多个内核共享的。L1高速缓存分为指令缓存和数据缓存。
4.png
当有两个线程同时修改一个共享变量的时候,由于两个线程之间的数据不同步,所以导致修改的结果不符合预期,例如主内存中存在一个共享变量 x = 5,thread1读到后进行 x + 3操作,同时thread2读到后进行 x + 5 操作,那么thread1 得到的是x = 8,thread得到的是 x = 10,不论哪个线程先写回内存,都会被后写回的覆盖掉,预期的结果应该是 x = 3 + 5 + 8 = 13,而目前的结果x的值不是 8 就是 10,需要解决这个缓存不一致的问题一般有两种方式, 窥探机制(snooping )和 基于目录的机制(directory-based)。

二、缓存一致性

1、 缓存一致性的要求

1)写传播(Write Propagation)

对任何缓存中的数据的更改都必须传播到对等缓存中的其他副本(该缓存行的副本)。

2)事务串行化(Transaction Serialization)

对单个内存位置的读/写必须被所有处理器以相同的顺序看到。理论上,一致性可以在加载/ 存储粒度上执行。然而,在实践中,它通常在缓存块的粒度上执行。

3)一致性机制(Coherence mechanisms)

保一致性的两种最常见的机制是窥探机制(snooping)基于目录的机制(directorybased),这两种机制各有优缺点。如果有足够的带宽可用,基于协议的窥探往往会更快,因为所有事务都是所有处理器看到的请求/响应。其缺点是窥探是不可扩展的。每个请求都必须广播到系统中的所有节点,这意味着随着系统变大,(逻辑或物理)总线的大小及其提供的带宽也必须增加。另一方面,目录往往有更长的延迟(3跳 请求/转发/响应),但使用更少的带宽,因为消息是点对点的,而不是广播的。由于这个原因,许多较大的系统(>64处理器)使用这种类型的缓存 一致性。

2、总线窥探

总线窥探(Bus snooping)是缓存中的一致性控制器(snoopy cache)监视或窥探总线事务的一种方案,其目标是在分布式共享内存系统中维护缓存一致性。包含一致性控制器(snooper)的缓存称为snoopy缓存。
该方案由Ravishankar和Goodman于1983年提出。

1)工作原理

当数据经过总线,读取到高速缓存中,如果是共享数据(其他处理器也读取了该数据),那么当处理器修改了这个共享数据的值时,总线会发送广播来告知其他处理器,其他处理器会监听总线的广播,如果处理器本身也读取了这个共享数据,那么会根据窥探协议来处理这个数据的状态。
当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。数据变更的通知可以通过总线窥探来完成。所有的窥探者都在监视总线上的每一个事务。如果一个修改共享缓存块的事务出现在总线上,所有的窥探者都会检查他们的缓存是否有共享块的相同副本。如果缓存中有共享块的副本,则相应的窥探者执行一个动作以确保缓存一致性。这个动作可以是刷新缓存块或使缓存块失效。它还涉及到缓存块状态的改变,这取决于缓存一致性协议(cache coherence protocol)。

2)窥探协议类型

根据管理写操作的本地副本的方式,有两种窥探协议:
写失效(Write-invalidate):
当处理器窥探到共享的数据被修改之后,会把自己的缓存中的共享数据设为失效。
当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议属于该类型。
MSI是最基本的协议,MESI是比较通用性的协议,MESIF是I7架构使用的协议,MOESI是AMD常用的协议。
写更新(Write-update):
当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量,因为总线的带宽是有限的。这就是为什么这种方法不常见。Dragon和firefly协议属于此类别。

三、一致性协议(Coherence protocol)

一致性协议在多处理器系统中应用于高速缓存一致性。为了保持一致性,人们设计了各种模型和协议,如MSI、MESI(又名Illinois)、MOSI、MOESI、MERSI、MESIF、write-once、 Synapse、Berkeley、Firefly和Dragon协议。
MSI protocol, the basic protocol from which the MESI protocol is derived. Write-once (cache coherency), an early form of the MESI protocol.
MESI protocol
MOSI protocol
MOESI protocol
MESIF protocol
MERSI protocol
Dragon protocol
Firefly protocol

四、 MESI协议

处理器发送lock前缀指令,lock前缀指令使用缓存锁定,只锁定缓存行保证该缓存行读取,写入的原子性,lock前缀指令会使用缓存行的一致性由MESI来保证,所以多个处理器可以同时通过总线读写内存。
MESI协议是一个基于写失效的缓存一致性协议,是支持回写(write-back)缓存的最常用协议。也称作伊利诺伊协议 (Illinois protocol,因为是在伊利诺伊大学厄巴纳-香槟分校被发明的)。与写通过(write through)缓存相比,回写缓冲能节约大量带宽。总是有“脏”(dirty)状态表示缓存中的数据与主存中不同。MESI协议要求在缓存不命中(miss) 且数据块在另一个缓存时,允许缓存到缓存的数据复制。与MSI协议相比,MESI协议减少了主 存的事务数量。这极大改善了性能。

1、缓存行

缓存行(cache line),大小为64字节,是高速缓存的最小单位,也是和空间局部性有关,cpu会一次读取一个缓存行的数据,因为在一个数据被使用,下一次这个数据旁边的数据也大概率会被使用。例如读取一个int类型的值,int类型占用4个字节,那么这个int类型之后的60个字节也会被读取,这样虽然提高了cpu的读取速度,也有可能导致伪共享问题。

2、MESI协议的状态

MESI协议把缓存行分为四种状态:
已修改Modified (M) :
当处理器A修改了缓存行内某一个数据的值,那么会把这个缓存行标为已修改状态。
独占Exclusive (E):
当处理器通过总线窥探来判断是第一个把数据读取到高速缓存中的,那么会把这个缓存行标为独占状态。
共享Shared (S):
如果处理器A把数据读取到高速缓存时,已经有别的处理器B也读取过这个缓存行,那么处理器A会把这个缓存行表为共享状态,已经读取过该缓存行的处理器B会通过总线窥探到这个缓存行被其他处理器A读取,也会把缓存行改为共享状态。
无效Invalid (I):
当处理器B窥探到其他处理器A已经修改了该缓存行的值,那么处理器B会把该缓存行标为无效状态,并从内存中重新读取。
lock前缀指令要求当处理器修改了缓存行的内容后,必须马上刷回内存中,这样别的处理器在窥探到其他处理器修改缓存行后会把当前缓存中的缓存行丢弃,重新读取缓存,但是修改完成的处理器在写回内存也是需要时间的,在这个时间内,其他处理器如果想要重新读取缓存,由于lock前缀指令锁定缓存行,来保证读写的原子性,所以其他的处理器需要等待。
当跨缓存行或者处理器没有实现缓存一致性协议时,无法使用这种方式,只能使用总线裁决机制来保证处理器竞争时的独占和公平性。

五、总线裁决机制

在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(WriteTransaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。总线要保证读写事务的原子性,这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。
5.png
假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁(Bus Arbitration)会对竞争做出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续它的总线事务,而其他两个处理器则要等待处理器A的总线事务完成后才能再次执行内存访问。假设在处理器A执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的请求会被总线禁止。 总线的这种工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。

原子操作是指不可被中断的一个或者一组操作。处理器会自动保证基本的内存操作的原子性,也就是一个处理器从内存中读取或者写入一个字节时,其他处理器是不能访问这个字节的内存地址。最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

1、 总线锁定

总线裁决机制是锁定了处理器的一个读或写操作即单个总线事务,而总线锁定将会锁定处理器的整个操作流程,直到处理器完成对数据的操作,例如32位的机器在处理long类型(64位)的数据时,需要分两次(高位和低位)操作,需要总线锁定来保证操作的原子性。
总线锁定就是使用处理器提供的一个 LOCK#信号,当其中一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

2、 缓存锁定

缓存锁定是锁定了一个缓存行,允许多个处理器对同一个缓存行进行读写操作,因为有缓存一致性协议来保证即便是多个处理器同时操作,也只会有一份有效数据写入内存中。
由于总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁住特定的一块内存区域,因此总线锁定开销较大。 缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不会在总线上声言LOCK#信号(总线锁定信号),而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。
缓存锁定不能使用的特殊情况:
1)当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定。
2)有些处理器不支持缓存锁定。

3、store buffer

处理器在处理完lock前缀指令的数据后刷回主存,其他的处理器才可以访问,这样会导致处理器的性能打折扣,所以处理器不会先写回缓存中,而是先写入store buffer(写缓冲区)。

六、伪共享

由于空间局部性原理,在加载数据的时候,每次会加载一个缓存行的数据(64字节),那么如果在同一个缓存行中的不同数据被不同处理器处理的时候,就会出现伪共享问题,从而降低处理效率。
下面关于伪共享的代码:
在一个对象中有两个被volatile修饰的long类型的属性x,y,这样可以保证属性的可见性,long类型占用8个字节,两个就是16字节,也不够一个缓存行的长度,所以这两个属性会被加载到同一个缓存行中。
两个线程分别处理x和y,那么如此根据MESI协议,当线程1修改这个缓存行的x的值时,线程2的缓存行就失效了,就需要重新加载,而线程2在修改y的值时,线程1的缓存行就失效了,那么两个线程修改两个不同的变量但是两个变量又在同一个缓存行中就出现了互相干扰。

  1. public class FalseSharingTest {
  2. public static void main(String[] args) throws InterruptedException {
  3. testPointer(new Pointer());
  4. }
  5. private static void testPointer(Pointer pointer) throws InterruptedException {
  6. long start = System.currentTimeMillis();
  7. Thread t1 = new Thread(() -> {
  8. for (int i = 0; i < 100000000; i++) {
  9. pointer.x++;
  10. }
  11. });
  12. Thread t2 = new Thread(() -> {
  13. for (int i = 0; i < 100000000; i++) {
  14. pointer.y++;
  15. }
  16. });
  17. t1.start();
  18. t2.start();
  19. t1.join();
  20. t2.join();
  21. System.out.println(pointer.x+","+pointer.y);
  22. System.out.println(System.currentTimeMillis() - start);
  23. }
  24. }
  25. class Pointer {
  26. // 避免伪共享: @Contended + jvm参数:-XX:-RestrictContended jdk8支持
  27. @Contended
  28. volatile long x;
  29. //避免伪共享: 缓存行填充
  30. //long p1, p2, p3, p4, p5, p6, p7;
  31. volatile long y;
  32. }

这种问题就称为伪共享,因为每次线程在嗅探到这个缓存行被修改之后,还需要重新加载就降低了效率,为了解决这个问题有两个方法:
1)在1.7之前,可以使用缓存填充,已知缓存行是64个字节,所以手动的把缓存行填满到64个字节,即增加7个long类型的属性来占位,使这8个long类型的属性在一个缓存行。另一个需要处理的属性在另一个缓存行。
2)在1.7之后,可以在属性上增加@Contended注解并配置-XX:-RestrictContended参数
这两种方法会相对提升效率,和不加volatile关键字的执行效率还是有一定的差距。

七、JMM的内存可见性保证

可见性与重排序有关,重排序就是为了保证可见性而存在的。
按程序类型,Java程序的内存可见性保证可以分为下列3类:

1、单线程程序

单线程程序不会出现内存可见性问题。在单线程中,JMM有as-if-serial来保证结果的正确性。
编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
例如下面的代码:

  1. // 第一种写法
  2. int x = 5;
  3. int y = 6;
  4. int z = x + 3;
  5. // 第二种写法
  6. int y = 6;
  7. int x = 5;
  8. int z = x + 3;

这两种写法的结果是一样的,但是在执行的时候效率是不同的,第一种写法,处理器首先load变量x,在load变量y,在load变量x计算,而第二种写法,只需要lode一次x就可以马上参与运算,如此处理器会在不影响结果的前提下进行重排序。

2、as-if-serial

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
JMM为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
例如下面的代码:

  1. double pi = 3.14;
  2. double r = 1.0;
  3. double area = pi * r * r;

上面的代码中,pi 和 r 不存在依赖关系,而area 和 pi,area 和 r 都存在依赖关系,所以area不能重排序,pi 和 r 可以重排序。

3、顺序一致性模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。
顺序一致性内存模型有两大特性:
一个线程中的所有操作必须按照程序的顺序来执行。
(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

4、正确同步的多线程程序

利用锁机制或者 volatile 关键字,进制重排序来保证可见性。
正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。

5、未同步/未正确同步的多线程程序

这种情况是保证不了可见性的,例如前面的例子,一个线程判断共享变量flag如果flag为false就跳出循环,另一个线程修改共享变量flag = false,flag的值在两个线程执行的过程中没有进行同步。
JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值。未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。 JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。
这里 volatile 关键字保证flag变量的同步,happens-before,保证使对flag的写操作会在flag的读操作之前,这样线程A的读操作会见到线程B的写操作。

6、未同步程序在两个模型中的差异

未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异。
1)顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行,比如正确同步的多线程程序在临界区内的重排序。
临界区特点:
a、属于公共资源或者共享数据。
b、同一时间只能被一个线程占用
c、如果该临界区资源被占用,其他想使用临界区资源的线程只能等待
d、在并行(多核)程序中,临界区资源是保护的对象
例如在synchronized中,也会出现重排序,常见的单例模式代码:

  1. public class SingletonFactory {
  2. private static volatile SingletonFactory myInstance;
  3. public static SingletonFactory getMyInstance() {
  4. if (myInstance == null) {
  5. synchronized (SingletonFactory.class) {
  6. if (myInstance == null) {
  7. // 1. 开辟一片内存空间
  8. // 2. 对象初始化
  9. // 3. myInstance指向内存空间的地址
  10. myInstance = new SingletonFactory();
  11. }
  12. }
  13. }
  14. return myInstance;
  15. }
  16. public static void main(String[] args) {
  17. SingletonFactory.getMyInstance();
  18. }
  19. }

在单例模式中,会用到double check 和 volatile关键字,在第一个线程获取锁,创建对象的时候不是一个原子操作,jvm会分三步,先开辟一片内存空间,再初始化对象,最后把变量指向内存空间的对象的地址,那么如果出现指令重排,先开辟内存空间,再把变量指向内存空间地址,此时变量还没有初始化,第二个线程在进入判断的时候,对象已经不是null了,就会直接返回,但是返回的是还没有初始化的对象。所以需要使用 volatile关键字 禁止指令重排,也是用来确保对象对多个线程的可见性。

2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
例如两个线程各自执行自己的逻辑,那么对于这两个线程各自来说,都是单线程执行的,所以会存在指令重排的情况,在JMM模型中,两个线程无法看到另一个线程的执行顺序。

  1. public class ReOrderTest {
  2. private static int x = 0, y = 0;
  3. private volatile static int a = 0, b = 0;
  4. public static void main(String[] args) throws InterruptedException {
  5. int i=0;
  6. while (true) {
  7. i++;
  8. x = 0;
  9. y = 0;
  10. a = 0;
  11. b = 0;
  12. /**
  13. * x,y: 10, 01, 11
  14. */
  15. Thread thread1 = new Thread(new Runnable() {
  16. @Override
  17. public void run() {
  18. shortWait(20000);
  19. a = 1; // volatile写
  20. // StoreLoad
  21. //UnsafeFactory.getUnsafe().storeFence();
  22. x = b; // volatile读
  23. }
  24. });
  25. Thread thread2 = new Thread(new Runnable() {
  26. @Override
  27. public void run() {
  28. b = 1;
  29. //UnsafeFactory.getUnsafe().storeFence();
  30. y = a;
  31. }
  32. });
  33. thread1.start();
  34. thread2.start();
  35. thread1.join();
  36. thread2.join();
  37. System.out.println("第" + i + "次(" + x + "," + y + ")");
  38. if (x==0&&y==0){
  39. break;
  40. }
  41. }
  42. }
  43. public static void shortWait(long interval){
  44. long start = System.nanoTime();
  45. long end;
  46. do{
  47. end = System.nanoTime();
  48. }while(start + interval >= end);
  49. }
  50. }

在代码中,如果变量a,b没有加 volatile 关键字,那么整个执行过程只会存在三种情况
线程1先执行a = 1,线程2执行b = 1,那么最终结果x = 1,y = 1。11。
线程1先执行a = 1,x = b,此时 x = 0,线程2执行b = 1,y = a,此时y = 1,那么最终结果 x = 0,y = 1。01。
与上面相反,线程2先执行完,线程1再执行,结果就是 x = 1,y = 0。10。
运行的结果却是四种,还有 0,0的情况,这就出现了指令重排,但是两个线程之间却不可见,这样会对最终的结果有影响。
变量a,b加 volatile 关键字之后,禁止了指令重排序,或者直接使用内存屏障禁止重排序。内存屏障存在于volatile读和volatile写之间,使处理器不能对这两个volatile的操作进行重排序。

3)顺序一致性模型保证对所有的内存读/写操作都具有原子性,而JMM不保证对64位的 long型和double型变量的写操作具有原子性(32位处理器)。
JVM在32位处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位(高位和低位)的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位 long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性

7、happens-before

从JDK 5 开始,JMM使用happens-before的概念来阐述多线程之间的内存可见性。在JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happensbefore关系。
happens-before原则定义如下:
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作 可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则 制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果 一致,那么这种重排序并不非法。
下面是happens-before原则规则:
1)程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操 作;
2)锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
3)volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
4)传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A 先行发生于操作C;
5)线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
6)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件 的发生;
7)线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
8)对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

八、volatile的内存语义

1、volatile的特性

可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最 后的写入。
原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复 合操作不具有原子性(基于这点,我们通常会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。
64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。
有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指 令重排序来保障有序性。
volatile关键字为什么可以保证可见性,这个需要看hotspot的源码实现。
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许 volatile变量与普通变量重排序。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组 决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保 volatile的写-读和锁的释放-获取具有相同的内存语义。

2、volatile写-读的内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到 主内存。
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来 将从主内存中读取共享变量。

3、volatile可见性实现原理

JMM内存交互层面实现:
volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修 改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程 的可见性。
硬件层面实现:
通过lock前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”, 缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回 写到内存会导致其他处理器的缓存无效。

4、指令重排序

重排序分为编译器的重排序和处理器的重排序,所以内存屏障分为jvm的和硬件的。

1)JVM内存屏障

内存屏障可以禁止指令重排序,JMM内存屏障插入策略有以下四种:
a、在每个volatile写操作的前面插入一个StoreStore屏障
b、在每个volatile写操作的后面插入一个StoreLoad屏障
c、在每个volatile读操作的后面插入一个LoadLoad屏障
d、在每个volatile读操作的后面插入一个LoadStore屏障
volatile 关键字也是使用内存屏障,会增加StoreLoad,如下图。6.png以上四种都是JVM层面的内存屏障
在JSR规范中定义了4种内存屏障:
LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前, 保证Load1要读取的数据被读取完毕。
LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1 要读取的数据被读取完毕。
StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的 写入操作对其它处理器可见。
StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证 Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
由于x86只有store load可能会重排序,所以只有JSR的StoreLoad屏障对应它的mfence或lock前缀指令,其他屏障对应空操作
x86处理器不会对读-读、读-写和写-写操作做重排序, 会省略掉这3种操作类型对应的内存屏障。仅会 对写-读操作做重排序,所以volatile写-读操作只需要在volatile写后插入StoreLoad屏障

2)硬件层内存屏障

硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供一致性的能力。拿X86平台来说,有几种主要的内存屏障:
a、lfence,是一种Load Barrier 读屏障
b、sfence, 是一种Store Barrier 写屏障
c、mfence, 是一种全能型的屏障,具备lfence和sfence的能力
d、Lock前缀,x86架构比较特殊,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速 缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
7.png图中对于X86架构,StroeLoad内存屏障有三种:mfence、cpuid(总线)、locked insn(lock前缀指令),因为lock前缀指令性能比mfence高,所以都是使用lock前缀指令。
内存屏障有两个能力:
阻止屏障两边的指令重排序。
刷新处理器缓存/冲刷处理器缓存。