引言
在《深入理解Java虚拟机》中有一张介绍JMM的图,如下:

由图可知,每一个Java线程中都有一个内部的工作内存,保存了主内存共享数据的拷贝副本。这里的工作线程与JVM内存结构中一个线程私有的内存空间——虚拟机栈不是一个概念。Java线程中的并不存在工作内存,它只是对CPU寄存器和高速缓存的抽象描述。
CPU
线程是CPU调度的最小单位,线程中的字节码指令最终都是在CPU中执行的,CPU在执行过程中需要跟各种数据打交道,而Java中所有数据都是存放在主内存(RAM)中,这一过程可以理解为:

随着CPU技术的发展,CPU的执行速度越来越快,但内存的技术并没有太大变化,所以内存中读取和写入数据的速度远不及CPU的执行速度。如果CPU对主内存的访问需要等待较长时间,就无法体现CPU超强运算能力的优势了。
因此,为了达到“高并发”的效果,在CPU中添加了高速缓存(cache)作为缓存。

在执行任务时,CPU会先将运算所需要使用的数据复制到高速缓存中,让运算能够快速进行,当运算完成后,再将缓存中的结果刷回(flush back)主内存,这样CPU就不用等待主内存的读写操作了。但这个方法也存在问题, 每个处理器都有自己的高速缓存,同时又共同操作同一块主内存,当多个处理器同时操作主内存时可能导致数据不一致,这就是缓存一致性问题。
缓存一致性问题
当一个移动设备存在两个或多个CPU,其中一些CPU还有多核。每个CPU在某一时刻都能运行一个线程,这就意味着,如果Java程序是多线程的,那么就有可能存在多个线程在同一时刻被不同的CPU执行的情况。
比如以下一段代码:

定义了两个变量x和y,初始值都为0;假设一台设备上有两个CPU为C1和C2,将这段代码执行在这台设备上,最后打印出r1和r2的结果并不确定。因为不能确保线程p1和p2在C1和C2的执行位置,甚至有可能出现执行完之后没有将结果刷新回主内存中的情况,那么r1和r2均为0。
指令重排
除了缓存一致性问题,还存在另一种硬件问题,为了使CPU内部的运算单元能够尽量被充分利用,处理器可能会对输入的字节码指令进行重新排序,也就是处理器优化。除了CPU之外,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。
用以下代码举个栗子
a = 1;
b = 2;
a = a + 1;
编译之后的字节码指令如下:
0:iconst_1 //把常量1压入操作数栈
1:istore_1 //将栈顶元素保存到局部变量表下标为1的位置
2:iconst_2 //把常量2压入操作数栈
3:istore_2 //将栈顶元素保存到局部变量表下标为2的位置
4:iload_1 //将局部变量表下标1的元素压入操作数栈
5:iconst_1 //把常量1压入操作数栈
6:iadd //将栈顶两个元素相加,并将结果压入操作数栈
7:istore_1 //将栈顶元素保存到局部变量表下标为1的位置
从上述指令可以看出,代码的执行并不依赖2、3指令,此时CPU会对指令的顺序做如下优化:

从代码来理解就是

以缓存一致性的例子来说明,当代码进行指令重排后可能出现r1=2,r2=1的情况,原因是p2进行了指令重排

为了解决这些问题,让Java代码在不同硬件、不同操作系统中输出的结果一致,Java虚拟机规范提出了一套机制——Java内存模型。
Java内存模型
内存模型是一套共享内存系统中多线程读写操作行为的规范,这套规范屏蔽了底层硬件和操作系统的内存访问差异,解决了CPU多级缓存、CPU优化、指令重排等导致内存访问的问题,从而保证Java程序(尤其多线程程序)在各种平台下对内存的访问效果一致。
在Java内存模型中,统一用“工作内存”来当作CPU中寄存器或高速缓存的抽象。线程之间的共享变量存储在主内存中,每个线程都有一个私有工作内存,本地内存中存储了该线程读/写共享变量副本。在JMM中,有一个非常重要的规则就是happens-before。
happens-before先行发生原则
用于描述两个操作的内存可见性,通过保证可见性的机制可以让应用程序免于数据竞争干扰。此外,happens-before原则具有传递性。
定义:如果一个操作A happens-before 操作B,那么操作A的执行结果对操作B可见。
用以下代码举个栗子
假设setValue是操作A,getValue是操作B,如果先后在两个线程中调用A和B,求返回的value值;

假设setValue是操作A,getValue是操作B,如果先后在两个线程中调用A和B,求返回的value值;
如果A happens-before B不成立,当线程调用操作B时,即使操作A已经在其他线程中被调用过,但这个修改对操作B仍然不可见,value返回值不确定。
如果A happens-before B成立,先发生动作的结果,对后发生动作是可见的。也就是说我们先在一个线程中调用操作A,那么修改后的结果对操作B始终可见,所以,如果先调用setValue将value赋值为1后,后续其他线程中调用getValue的值一定是1。
JMM中定义了几种自动符合happens-before规则:
1、程序次序规则
在单线程内部,如果一段代码顺序执行,那么逻辑顺序靠前的字节码的执行结果一定对后续的字节码可见
2、锁定规则
无论单线程还是多线程,一个锁如果处于被锁定状态,必须先执行unlock操作后才能执行lock操作。
3、线程启动规则
假设线程A在执行过程中,通过执行Thread.start()来启动线程B,那么线程A对共享变量的修改在线程B开始执行后确保对线程B可见。
4、线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测,直到中断事件的发生。
5、线程终结规则
线程中所有的操作都发生在线程的终止检测之前,假设线程A在执行的过程中,调用ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见;
6、对象终结规则
一个对象完成它的初始化必须发生在finalize()方法开始前,对象在回收前会调用finalize()方法。
JMM特性
可见性:
当一个线程修改了共享变量的值,其他线程能够立即获得这个修改;
原子性:
一个操作是不可中断的,比如a=0符合原子性,但a++不符合。即使多个线程一起执行,操作一旦开始就不会被其他线程干扰;
有序性:
在单线程中程序的最终执行结果和代码顺序执行结果一致,但在多线程中,为了提高程序执行的性能,编译器和处理器会进行指令重排,重排后的指令与原指令的顺序未必一致。
Java内存模型应用
happens-before原则是判断数据是否存在竞争、线程是否安全的主要依据,根据这个原则,能够解决在并发环境下各操作之间可能存在冲突的所有问题,在此基础上,Java提供了一系列关键字让开发者可以自己实现多线程操作“happens-before”化,使某些本来不符合该原则的操作通过某种方法变得符合该原则。
- 使用volatile修饰变量;
- 使用synchronized关键字修饰操作
本文深入解析Java内存模型(JMM),探讨工作内存与主内存的关系,分析CPU缓存一致性问题及指令重排对多线程的影响,阐述JMM如何保证可见性、原子性和有序性,以及happens-before规则的应用。
686

被折叠的 条评论
为什么被折叠?



