【JUC】JMM内存模型
0、为什么要有内存模型?
想要回答这个问题,需要先弄懂传统计算机硬件内存架构。
0.1 硬件内存架构
(1)CPU:一般服务器上有多个CPU,每个CPU有多个核,这就意味着多个cpu或者多个核可以并发工作
(2)CPU Register:CPU寄存器,是CPU内部集成的,在寄存器上执行操作的效率要比咋主存上高出几个数量级(L1)
(3)CPU Cache Memory:CPU高速缓存,相对于寄存器来说,通常也可以称为L2二级缓存。相对于硬盘读取速度来说内存的读取效率非常高,但是与CPU还是相差数量级,所以在CPU和主内存间引入了多级缓存,目的是为了做一下缓冲。
(4)Main Memory:主存,比L1、L2缓存要大很多
注意:部分机器有L3三级缓存
0.2 Java运行时内存区域与硬件内存的关系
JVM在运行时内存区域时分为堆、栈等,其实这些都是JVM定义的逻辑概念。在传统的硬件内存架构中是并没有这些概念的。
从图中可以看出堆和栈既存在于高速缓存中又存在于主内存中,所以两者并没有很直接的关系
0.3 缓存一致性问题
由于主存与CPU处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构会引入高速缓存来作为主存和处理器之间的缓冲,CPU将常用的数据放在高速缓冲中,运算结束后CPU再将运算结果同步到主存中。
使用高速缓存解决了CPU和主存速率不匹配的问题,但是同时又引入了另一个新问题:缓存一致性问题。
缓存一致性问题:在多CPU的系统中(或单CPU多核的系统),每个CPU内核都有自己的高速缓存,它们共享同一主内存(Main Memory)。当多个CPU的运算任务都涉及同一块主内存区域时,CPU会将数据读取到缓存中进行运算,这可能会导致各自的缓存数据不一致。
因此需要每个CPU访问缓存时遵循一定的协议——JMM(Java内存模型),在读写数据时根据协议进行操作,共同维护缓存一致性。
0.4 处理器优化和指令重排序
为了提升性能,在CPU和主内存之间增加了高速缓存,但在多线程并发场景下可能会遇到缓存一致性问题。是否还有什么办法进一步提升CPU的执行效率呢?答案是:处理器优化。
(1)处理器优化:为了使处理器内部的运算单元能够最大化被充分利用,处理器会对代码进行乱序执行处理,切换线程执行任务导致原子性问题。
(2)指令重排序:编译器在编译的时候,允许重排序指令以优化运行速度。CPU在执行指令的时候,为了使处理器内部运算单元能被充分利用,也可以对指令进行乱序执行。
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 指令级并行的重排序:现代处理器采用了指令级并行技术将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是乱序执行的
0.5 总结:与并发相联系
在Java并发中,有三个问题:【可见性问题】、【原子性问题】、【有序性问题】。如果从更深层次看这三个问题,其实就是上面讲的【缓存一致性】、【处理器优化】、【指令重排序】。
- 原子性:一个或多个操作,要么全部执行并且执行过程不会受到任何因素打断,要么都不执行(处理器优化切换线程导致原子性问题)
- 可见性:当多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的(缓存一致性问题就是可见性问题)
- 有序性:程序执行的顺序按照代码先后执行(指令重排序导致有序性问题)
1、JMM内存模型基础
1.1 什么是JMM
内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程的抽象描述,不同架构下的物理机拥有不一样的内存模型,Java虚拟机是一个实现了跨平台的虚拟系统,因此也有自己的内存模型,即Java内存模型(Java Memory Model)。
Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节。
在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
1.2 JMM 定义线程和主存的关系
Java内存模型是一种规范,定义了很多东西:
- 所有的变量都存储在主内存(Main Memory)中
- 每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程用以 读/写 共享变量的拷贝副本
- 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主存
- 不同的线程之间无法直接访问对方本地内存中的变量
1.3 JMM 定义线程间的通信
如果两个线程都对一个共享变量进行操作,共享变量初始值为1,每个线程都对变量进行加1操作,预期共享变量的值为3。在JMM规范下会有一下一系列操作:
(1)首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
(2)然后,线程B到主内存中去读取线程A之前已更新过的共享变量
1.4 八种内存交互操作
JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。
为了更好的控制主内存和本地内存的交互,Java内存模型定义了八种操作来实现:
- lock:锁定。作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock:解锁。作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read:读取。作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load:载入。作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use:使用。作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign:赋值。作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store:存储。作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write:写入。作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
工作内存即本地内存
1.5 Java 维护并发的三个特性
内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节。是围绕着在并发过程中如何处理可见性、原子性、有序性三个特性而建立的模型。
(1)原子性
JMM只能保证基本的原子性,如果要保证一个代码块的原子性,提供了monitorenter 和 moniterexit 两个字节码指令,也就是 synchronized 关键字。因此在 synchronized 块之间的操作都是原子性的。
(2)可见性
可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。Java是利用volatile关键字来提供可见性的。 当变量被volatile修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。
除了volatile关键字之外,final和synchronized也能实现可见性。
synchronized的原理是,在执行完,进入unlock之前,必须将共享变量同步到主内存中。
final修饰的字段,一旦初始化完成,如果没有对象逸出(指对象为初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。
(3)有序性
在Java中,可以使用synchronized或者volatile保证多线程之间操作的有序性。实现原理有些区别:
volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。
synchronized的原理是,一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized包住的代码块在多线程之间是串行执行的。
2、指令重排序
前文中提过,从Java源代码到最终实际执行的指令序列,编译器和处理器常常会对指令做重排序。
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 指令级并行的重排序:现代处理器采用了指令级并行技术将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是乱序执行的
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
3、内存屏障
为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:读读,写写,读写,写读
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)
4、Happens-Before先行发生原则
happens-before是JMM最核心的概念。对应Java程序员来说,理解happens-before是理解JMM的关键。
从JDK 5开始,Java使用新的JSR-133内存模型。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
与程序员密切相关的happens-before规则如下。
(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中该操作的任意后续操作。
(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前
5、as-if-serial语义
5.1数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
数据依赖分为以下三种类型:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1; b = a; | 写一个变量之后,再读这个位置。 |
写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
上述三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
5.2 as-if-serial 语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
例如:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
如上图所示,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序:
happens-before关系本质上和as-if-serial语义是一回事。
- as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的
- as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
6、锁的获取与释放的内存语义
class MonitorExample {
int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
……
} // 6
}
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。(隐式通信)