0%

Java-内存模型

并发模型

如果开发项目涉及到并发,一般需要考虑2个问题

  • 线程之间如何通信
  • 线程之间如何同步 (这里的线程是指并发执行的活动实体)

通信是指线程之间以何种机制来交换信息。

比如Android比较常见的:线程之间的通信机制 Handler

在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

Java的并发模型

编程上的专业术语都有特定的定义,饮水思源,思考为什么要设计出来,还有为什么要这样设计,当2个时候都满足的时候,定义也就自然而然的清楚了。

众所周知,我们Java是跨平台,因为不同平台的硬件和操作系统访问都是存在访问差异的,为了要保证Java程序在各个平台下的运行对内存的访问都能得到一致效果的机制和规范,这就是为什么要设计JMM内存模型。

根据Google后,相关的资料,其实JMM只是一个抽象的概念,描述一个规则和规范,这个后面会细讲。

目的是解决由于多线程通过主存(共享内存)进行通信时,存在的原子性,可见性(缓存一致性)以及有序性问题。

JMM 规范

Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。

局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响

1.在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,

典型的共享内存通信方式就是通过共享对象进行通信。

参考 Linux 进程与线程

每个线程都有对应自己独立,私有的栈区。

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存( main memory )中,每个线程都有一个私有的本地内存( local memory ),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

重排序

java代码 > 编译器重排序 > 指令重排序 > 内存重排序

指令重排序

CPU运行效率 相比缓存、内存、硬盘IO之间效率有着指数级的差别,目的就是把CPU的资源利用起来

编译器

针对程序代码语而言,编译器可以在不改变单线程程序语义的情况下,可以对代码语句顺序进行调整重新排序。

数据依赖性

as-if—serial 语义

as-if-serial语义的意思指∶不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。

程序顺序规则

JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

在计算机中,软件技术和硬件技术有一个共同的目标∶在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从happens –before 的定义我们可以看出,JMM同样遵从这一目标。


happen-before

JVM 规定了先行发生的有的原则,让一个操作无需控制就能先于另一个操作完成

单一线程原则

Single Thread rule
在同一个线程内,书写在前面的操作happen-before后面的操作。

1
2
int a = 3; // 1
int b = a + 1; // 2

这个时候,java虚拟机不会对 1,2 的顺序进行重排序排序,jvm一定会保证1对2的可见性。
再换个例子

1
2
int a = 3; // 1
int b = 4; // 2

这2个语句没有依赖性,所以指定会进行重排序,有可能2是在1的前面。

管程锁定规则(锁的原则)

同一个锁的unlock操作happen-before此锁的lock操作

volatile 的特性

  • 简而言之,volatile变量自身具有下列特性︰
  • 内存可见性 : 当线程A更新了 volatile 修饰的变量时,它会立即刷新到主线程,并且将其余缓存中该变量的值清空,导致其余线程只能去主内存读取最新值。
  • 原子性︰对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
  • 有序性 : 保证这个变量之前的代码一定会比它先执行,而之后的代码一定会比它后执行, 编译器不会对此变量次序进行排序优化。

volatile变量规则

1
2
3
4
5
6
7
8
//x、y为非volatile变量
//flag为volatile变量

x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5

由于 flag变量 为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行。

2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

什么是复合操作 ? 既读又写

volatile 写-读建立的happen-before 关系

从内存的语义,volatile写和锁的释放有相同的内存语义,volatile读和锁的获取有相同的内存语义。

volatile 写-读 内存语义

线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所在修改的)消息。

线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。

线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

释放和获得锁的含义

当线程释放锁时,JMM 会把本地的内存中的共享变量刷新到主存。

当线程获得锁时,JMM 会把本地的内存中的共享变量置为无效。从而使监视器的保护的临界代码必须要从主存中读取共享变量。

内存访问重排序与Java内存模型

重排序示意表

1
2
3
4
5
6
7
8
9
10
volatile int v1 = 1;
volatile int v2 = 2;
int v3 = 3;

void test(){
int a = v3 + 1;
int b = v1 + v3; // 第二个 volatile 写时。

}

当第二个操作是volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile 写之前的操作不会被编译器重排序到volatile 写之后。

1
2
3
4
5
6
7
8
9
volatile int v1 = 1;
volatile int v2 = 2;
int v3 = 0;

void test(){
int a = v1; // 第一个 volatile 读
int b = v3 + 1;
}

当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile 读之后的操作不会被编译器重排序到 volatile 读之前。


volatile 内存语义

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入 内存屏障 来禁止特定类型的处理器重排序。

JMM内存屏障插入策略

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。

内存屏障示意表

JMM内存屏障插入策略

  • 在每个 volatile 写操作的前面插入一个 StoreStore屏障。

  • 在每个 volatile 写操作的后面插入一个 StoreLoa屏障。

  • 在每个 volatile 读操作的前面插入一个 LoadLoad屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadStore屏障。

在 StoreStore屏障可以保证在 volatile 写之前,其前面的操作已经对任意处理器可见,把StoreStore上面的普通读写操作在volatile写之前刷新到主存。