概述
JMM,全名为Java Memory Model,即Java内存模型。它是一组规范,需要各个JVM的实现来遵守JMM规范,它屏蔽了各种硬件和操作系统的内存访问差异,以实现Java程序在各个平台下都能达到一致的内存访问效果。不像C/C++那样直接访问物理硬件和操作系统的内存模型,它的主要目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码重排序、处理器会对代码乱序执行等带来的问题。可以保证并发编程场景中的原子性、可见性和有序性。
它有利于开发者可以利用这些规范,更方便地开发多线程程序。如果没有这样的JMM内存模型来规范,那么很可能经过了不同JVM的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样。
JVM对Java内存模型的实现
物理PC内存模型
在了解Java内存模型之前,我们先来了解一下cpu和计算机内存的交互情况。
最下面是主内存,上面是cpu 。在现代计算机中,cpu和内存之间的速度差距巨大,所以在这两者之间引入了高速缓存,来作为内存与处理器之间的缓冲。cpu处理数据都要放在寄存器中处理,寄存器一般很小,但是读取速度很快。高速缓存是内存的部分拷贝,因为高速缓存速度快,把常用的数据放这里可以提高速度。高速缓存分为一级,二级,三级缓存,自上而下速度逐渐变慢,但是容量逐渐变大。但是在解决速度差异的同时,也引入了其他问题,缓存一致性。
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,举例说明变量在多个CPU之间的共享。如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
Java内存模型
JMM抽象了主内存和本地内存的概念。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。如下图所示:
线程不能直接读写主内存的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中。主内存是多个线程共享的,单线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成。
Java内存模型所带来地问题
- 可见性问题
CPU中运行的线程从主存中拷贝共享数据到它的本地内存,并在之后对这个变量在本地内存中做出了更改,但这个变更对运行在其他CPU中的线程是不可见地,因为这个更改还没有flush到主存中:要解决共享对象可见性这个问题,我们可以使用volatile关键字或者是加锁。
-
竞争现象
线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的本地内存中,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的本地内存中。如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而如果线程A读取count到自己的本地内存,并且在未更改count的情况下被剥夺了时间片,这时线程B读取count到本地内存,然后两个线程执行加1操作,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。 要解决上面的问题我们可以使用java synchronized代码块。
Java内存模型的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令作优化。如下面这种情况:
重排序是指代码指令不严格按照代码语言顺序执行的。
下面这段程序将可能因为重排序出现不同的结果
-
/**
-
* 演示重排序的现象
-
* @author samy
-
* @date 2019/9/25 19:32
-
*/
-
public class OutOfOrderExecution {
-
private static int x = 0,y = 0;
-
private static int a = 0,b = 0;
-
private static int c;
-
public static void main(String[] args) throws InterruptedException {
-
CountDownLatch latch = new CountDownLatch(1);
-
for (;;){
-
c ++;
-
a = 0;
-
b = 0;
-
x = 0;
-
y = 0;
-
Thread one = new Thread(new Runnable() {
-
@Override
-
public void run() {
-
try {
-
latch.await();
-
} catch (InterruptedException e) {
-
e.printStackTrace();
-
}
-
a = 1;
-
x = b;
-
}
-
});
-
Thread two = new Thread(new Runnable() {
-
@Override
-
public void run() {
-
try {
-
latch.await();
-
} catch (InterruptedException e) {
-
e.printStackTrace();
-
}
-
b = 1;
-
y = a;
-
}
-
});
-
one.start();
-
two.start();
-
latch.countDown();
-
one.join();
-
two.join();
-
System.out.println("x = " + x + ",y = " + y);
-
}
-
}
-
}
以上程序可能会以下情况:
- (正常情况)线程1先执行完,线程2再开始执行,那么输出结果为x = 1,y = 0
- (正常情况) 线程2先执行完,线程1再开始执行,那么输出结果为x = 0,y = 1
- (正常情况)线程1执行a = 1后被调度掉线程2执行,对b = 1赋值,然后输出结果是x = 1,y = 1
- (重排序情况)x= b,y = a重排序到a = 1,b = 1前执行,执行顺序为(y = a,a = 1,x = b,b=1)那么输出结果是x = 0,y = 0
重排序共分为三种:
- 编译器优化
编译器(包括JVM,JIT编译器等)出于优化的目的(例如当前有了数据a,那么如果把对a的操作放到一起效率会更高,避免了读取b后又返回来重新读取a的时间开销),在编译的过程中会进行一定程度的重排,导致生成的机器指令和之前的字节码的顺序不一致。刚才出现x=0,y=0的情况就是编译器优化下的结果。
- 指令重排序
CPU 的优化行为,和编译器优化很类似,是通过乱序执行的技术,来提高执行效率,可能会将部分汇编代码会提前执行。所以就算编译器不发生重排,CPU 也可能对指令进行重排。
- 内存的"重排序"
内存系统内不存在重排序,但是内存会带来看上去和重排序一样的效果,所以这里的“重排序”打了双引号。由于内存有缓存的存在,在JMM里表现为主存和本地内存,由于主存和本地内存的不一致,会使得程序表现出乱序的行为。
在刚才的例子中,假设没编译器重排和指令重排,但是如果发生了内存缓存不一致,也可能导致同样的情况:线程1 修改了 a 的值,但是修改后并没有写回主存,所以线程2是看不到刚才线程1对a的修改的,所以线程2看到a还是等于0。同理,线程2对b的赋值操作也可能由于没及时写回主存,导致线程1看不到刚才线程2的修改。
可见性
Happens-Before
要想保证执行操作B的线程看到A的结果,那么A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM对它们任意地重排序。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
Happens-Before的规则包括:
- 程序顺序原则:在一个线程内一段代码的执行结果是有序的。虽然还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
- 锁规则:无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。
- volatile变量规则:如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
- 线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
- 线程结束规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。
- 中断规则:线程interrupt()方法的调用比检测线程中断的事件发生的早,可以通过Thread.interrupted()检测到是否发生中断。
- 终结器规则:一个对象的初始化的完成,也就是构造函数执行的结束一定早于happens-before它的finalize()方法。
- 传递性:A happens-before B , B happens-before C,那么A happens-before C。
原子性
简单来说就是一组操作,要么全部执行成功,要么全部执行不成功,不会出现执行一半的情况,是不可风割的。
Java中的synchronized和Lock就实现了原子性,在一个线程在做某个操作时其他线程无法对其进行干扰,只能等待它执行完成或者出现异常后才能执行。
Java中的原子操作
- 除long和double之外的基本类型的赋值操作
- 所有引用的赋值操作,不管是32位机器还是64位机器
- java.concurrent.Atomic.*包中所有类的原子操作
- ..
long和double的原子性
在官方文档中,对于64位值的写入可以分为两个32位的操作进行写入。所以在32位上的JVM,对于long和double变量的操作就不是原子性的,在64位的JVM就是原子性的。在商用的虚拟机中,已经将long和double变量的写入都为原子性的
原子操作+原子操作 !=原子操作