Java内存模型(JMM)

时间:2022-02-06 17:07:00


同步与并发


线程并发编程模型有那两个?

在我们多线程并发编程的时候,总会遇到为什么线程之间如何同步?线程之间如何通讯?


共享内存并发模型

顾名思义,共享内存模式,就是线程之间有一块共享的区域,通过读写内存*享区域的数据来隐式进行通讯

消息传递并发模型

此模式下,内存是没有共享区域来交互数据,只能相互发送消息来进行显式的数据交换

线程在模型中如何同步

同步是指程序用于控制不同线程之间操作发生的相对顺序机制(就是来控制多个线程中逻辑调用顺序的)。在共享内存模型中,是显式的进行同步,需要开发人员指定线程之间互斥顺序。而消息传递模型中发送肯定优先于接收,所以是隐式的进行。

Java内存模型(Java Memory Model)


了解过内存模型的从上面一看就知道,java使用的是共享内存并发模型。
  • 实例,数组,对象都保存在堆内存中,堆内存在多线程之间共享,在后面我们称之它为主内存。
  • 局部变量,计算过程,返回值,异常等都在栈内存,这些不受模型影响,也不会在多线程之间共享
JMM决定了线程对数据的写入,在什么时候对另一个线程时可见的。JMM定义了线程与主内存之间的抽象关系。
每条线程都有它自己的 本地内存 ,当线程对主内存进行读写的时候,会先将 数据从主内存读到本地内存,再进行操作,然后再将结果放回主内存

本地内存

本地内存是一个抽象的概念
它并不真实存在,它涵盖了缓存,写缓冲区,寄存器及其他硬件与编译器优化。

从下面的图我们看到
1. 首先线程A从主内存中拷贝一份 i 的副本到本地内存,此时 i 为99
2. 线程A在本地内存中对 i 的值进行了 +1
3. 线程A 将 i 值写回主内存,此时主内存中 i 为 100
4. 线程B 去主内存读取 i 的值
从以上步骤来看,JMM通过控制主内存与每条线程的交互,来完成对线程之间提供可见性的功能。
Java内存模型(JMM)


重排


重排是为了将串行语句,根据规则重排并使用并行执行提升执行性能,最终确保满足as if serial 原则


编译器重排

指令重排:在 不改变单线程语义 的情况下,重新调整语句顺序

CPU重排

指令并行技术:在没有数据依赖性的语句中,改变对应机器指令的重拍顺序

内存重排:CPU使用缓存与读写缓冲区,使得加载和存储操作看起来是乱序执行


处理器与上面说的内存模型相似。

内存模型是把数据读取到本地内存,然后修改后刷新回到主内存。

处理器使用写缓冲区来保存临时数据,然后刷到内存。

写缓冲区与本地内存类似,互不可见,仅对自己所在的处理器/线程可见

例:   int x = 0; int y = 0;

         处理器A      i = 99  ,  x = j

         处理器B      j = 1 ,   y = i

         如果是有序执行x,y应该为  99  1

         但是指令重排后有可能是  x = 0 , y = 0;

处理器步骤

1.  i = 99    

2.  x  = j    

3.  j = 1     

4.  y = i       

内存步骤

2,4,1,3 读写乱序重排


如何避免

我们通过增加内存屏障的方法来禁止对特定类型的重排


数据依赖性

拥有数据依赖性的操作,不会被重排,重排规则为:写写,读写,写读相互依赖不会被重排

写写(i = 1,i =2 )

写读 ( i = 4, a =i )

读写 ( i = b,  i = 2 )


as-if-serial

该语义定义了。无论如何重排,也不会影响单线程执行结果的规范。

例:  int  i  = 9; // line 1

          int  j  = 10;  // line 2

          int k =  i  * j // line 3

l1,l2 与 l3 存在数据依赖,所以 l3的位置不会比l1,l2高,但是l1,l2没有数据依赖,所以他们可以是重排的

单线程任务看似有序,其实也是重排的,不过as if serial 使单线程任务无需担心重排对结果的干扰


多线程重排

class A {
boolean flag = false;
int a = 98;
public void write(){
a = 1; //L1
flag = true; // L2
}
public void read(){
if ( flag ) //L3
a += 2; //L4
}
}
顺序执行的话,L1 -> L2 -> L3 - > L4 最后 a = 3

但如果有2条线程Thread_A与Thread_B,A执行write,B执行read

a的结果有可能是98,首先L1与L2,L3与L4没有我们在上数据依赖性中所说的数据依赖,那么执行顺序可能是L4-> L1 -> L2 -> L3

假设:L4先执行那么得到98+2,并保存到重排序缓存中,等L3执行的时候把98+2的结果写入i,L3与L4虽然没有数据是有控制依赖。对于控制依赖,编译器和处理器会猜测的执行来控制并行。
假设:L2先执行,随后L3 true,L4位98+2,再随后L1才执行。

从上面两种多线程重排的重排后的执行假设可以看出单线程会有as-if-serial规范保护,然而多线程则被重排序破坏了程序本身的结果和过程


Happens-Before 原则


一个操作结果对另一个操作可见,那么他们就属于happens-before关系


规则

1.  一个线程中的每个操作都happens-before它的后续操作

2. 监视器(synchronized)解锁并happens- before 于后续这个监视器的加锁

3. volatile的写,happens-before于后续对这个volatile的读

4. 传递性,如果A hb B  ,B hb C, 那么 A 就一定 hb C

Happens-Before对编译器与CPU的重排定义了规范

监视器hb关系

class A{

public synchroniezd void write(){  //L1 得到锁
 a++; //L2
 } //L3 释放锁

public synchroniezd void read(){   //L4得到锁
  int i = a ; //L5
} //L6  释放锁

}

线程A调用write,线程B调用read

1.  L1  HB L2 , L2 HB  L3  /  L4 HB L5  , L5 HB L6

2.  根据Hb规则第二条,监视器解锁并hb于后续这个监视器的加锁, L3 HB L4

3.  传递后 L2  HB L5

获取锁就是得到锁后再本地内存进行计算和操作,当释放锁的时候将数据刷回主内存区,从而完成与其他线程的交互



Volatile


volatile提供了多线程可见性,因其内存屏障的存在不会被指令重排
对一个volatile单个变量的读/写,是有原子性的,即便是对long/double这样的64位类型。
特性1:对一个volatile变量的读,总是能看到最这个volatile变量的最后写入(happens-before)
特性2:一个volatile i++这样的 复合操作 是没有原子保障的

内存屏障

屏障保证了不会被重排

规则:
每个volatile写操作前面加入一个storestore屏障
每个volatile写操作后面插入一个storeload屏障
每个volatile读操作前面加入一个loadload屏障
每个volatile读操作后面加入一个loadstore屏障

例:
int i = 3;
volatile  j = 4;
1.  k=i 
2.  i = 9
3. storestore屏障
4. j = 99;
5. storeload 屏障 //省略了m = j 的loadload因为storeload的存在,无需再加loadload了
7.  m =  j;
8. loadstore屏障 //因为无法判断是否还有读写,所以尾部loadstore也是要有的

X86处理器仅会对写读进行重排,不会对写写,读读,读写重排。所以它只需要在上述line5添加storeload屏障即可 

总结


就到这里。其实书中对顺序一致性和final也进行了解读,我这里也没有去解读这两点
在我看来
顺序一致性笼统的说就是通过内存屏障对多线程语句顺序的控制,保证有序执行,反之则会有不可预料的计算结果产生
final总的说,其本身是有内存屏障(storestore,loadload),在程序设计中我们要注意这些

在此文中对部分章节进行了总结归纳,当然也加入了一些自己的东西。