Java Memory Model (JMM)JAVA内存模型是一种抽象的概念,描述的是一组规范,规范中定义了程序中各个变量(实例字段、静态字段、数组对象的组成元素)的访问方式,决定了一个线程对共享变量的写入何时对另一个线程可见;JVM运行程序的实体是线程,在线程创建时,JVM都会为其分配工作内存,用于存储线程私有的数据;JMM规定所有变量都存储在主内存中,所以当线程想操作变量时,需要先将变量从主内存中拷贝进自己的工作内存中,然后再对变量进行操作,操作完成后再将变更后的值刷写回主内存中;JVM,也就是当线程操作一个对象时,会根据工作内存中引用地址去找到主内存中的真实对象,然后会讲对象拷贝到自己的工作内存中,当操作的对象较大时,会进行选择性拷贝,只拷贝自己需要操作的那部分数据;JVM来说,主内存包括了堆和方法区;JVM来说,工作内存包括了程序计数器、虚拟机栈和本地方法栈;数据存储类型:
boolean、byte、short、char、int、long、float、double八大基本数据类型,则这些数据将直接存储在工作内存的栈帧结构的局部变量表中,引用类型的局部变量则是存储对象的引用地址;static静态变量;数据操作方式:
主内存:
public class Test {
Integer num = new Integer(100);
private void add(){
num++;
}
}

工作内存:
public class Test {
private void add(){
Integer num = new Integer(100);
num++;
}
}

JAVA程序是运行在操作系统上的,它的所有操作最终都是在与操作系统交互,想要理解JAVA内存模型,需要对计算机内存架构有一定的了解;

多核CPU: 现在的CPU一般都为多核CPU拥有多个核心,可以支持多任务并发执行,每个线程也最终也是映射到各个CPU核心上去执行的;
超线程技术:增强核心并行运算性能,它允许一个CPU执行多个控制流,工作原理是将一颗物理CPU虚拟化为两颗逻辑CPU,我们常说的4核8线程就是通过这个技术实现的;

多级缓存:
CPU,导致CPU在处理指令时大量时间花费在等待内存准备数据上,从而影响CPU性能;L1、L2、L3多级高速缓冲区,来缓存CPU频繁访问的数据,之后寄存器再需要获取数据就可以直接从高速缓冲区中获取,不需要去访问内存;由于CPU为了提升性能使用了多核与多级缓存等技术,那么各各核心、各级缓存之间就可能出现数据不一致的情况,CPU主要通过以下集中方式来保证缓存的一致性;
写直达:在数据写入前判断数据是否已经在Cache中,如果数据存在,则将Cache中的数据更新,然后将数据写入内存中;
这种方式无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,影响性能;
写回:当发生写操作时,只有在Cache不命中且数据对应的Cache中的 Cache Block 为脏标记情况下,才会将数据写到内存中;
这种方式可以减少对主内存的写操作次数,提高性能。但是,可能会导致缓存中的数据与主内存不一致,需要额外的机制(如缓存一致性协议)来维护数据一致性。

问题:现在的多核CPU,由于L1/L2 Cache是多个核心各自独有的,而且CPU为了考虑性能,数据写入采用的是写回策略,这就可能导致多个核心缓存中数据不一致的情况,从而造成结果错误;

i 变量,并执行了i++语句,由于使用了写回策略;
i = 1写入到 L1/L2 Cache 中,然后把L1/L2 Cache中对应的 Block 标记为脏的,此时数据并没有被同步到内存中的,因为写回策略,只有在 A 中的这个 Cache Block 要被替换的时候,数据才会写入到内存里,这就出现了内存中的数据和A中Cache数据不一致的情况;解决思路:解决这个问题就需要对两个不同核心中的缓存数据进行同步,一般需要满足两点:
写传播:某个CPU核心里的Cache数据更新是,需要传播到其他核心的Cache中;
事物串行化:在某个CPU里对数据的操作顺序,必须在其他核心看起来顺序是一样的;
Cache中的数据不一致;所以,我们要保证 C 、 D能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串行化;
实现技术:
Cache中的i变量时,会通过总线将这个事件广播通知给其他核心,CPU的所有核心,都会监听总线上的广播事件,检查Cache是否有相同变量,如果有则会更新自己的Cache;概念:将数据用四种状态来标记M(Modified|已修改) E(Exclusive|独占) S(Shared|共享) I(Invalidated|已失效);
M(Modified)已修改:表示Cache中的数据已经被更新过,但还没写入到内存中;E(Exclusive)独占:表示数据值存储在一个CPU核心中,不需要考虑缓存一致性问题;S(Shared)共享:表示数据存储在多个CPU核心中,当我们需要更新数据时,需要向其他核心发送广播请求,要求其他核心将Cache中对应的数据标记为已失效状态,然后再更新;I(Invalidated)已失效:表示Cache中的数据已经失效,不可读取该状态数据;相互之间的转化:
当A从内存中读取变量 i 的值,会通过总线发消息通知其他CPU核心,如果其他CPU核心中没有缓存该数据,则A的Cache中 i 变量的标记为独占状态;
之后,若其他核心中任意一个也要读取 i 变量,假设是B,则会通过总线发送广播消息,给其他核心,由于A中已经读取了i 变量,所以会把数据返回给B,并将核心中的 i 变量标记为共享状态;
如果A这时要修改变量 i 的值,并且Cache中变量 i 的标记为共享状态,则会通过总线发送广播消息,将各个核心中的 i 变量标记为失效状态,然后将A的Cache中 i 变量标记为修改状态;
若此时A继续修改 i 变量的值,则不需要通知其他核心,直接修改即可;
当B需要读取i数据时,发现Cache中变量 i 的标记为失效,会发出读取数据的请求,A在收到B读取数据的请求后会将变量 i 同步到内存中,并将状态设为共享,这是B就可以读取到最新数据;

带来的优点:当数据标记为修改或者独占时,修改更新数据不需要发送广播消息,在一定程度上减小了总线带宽压力;
JMM只是一组抽象的概念,是一组规则,它的内存划分:工作内存(线程私有)、主内存(线程共享)对于计算机硬件来说并不存在;JMM的数据操作对应到底层也是操作主内存、操作高速缓存来操作数据的,而多核CPU有是通过MESI协议来达到数据一致性的,所以,JAVA内存模型底层其实也还是通过MESI一致性来保证的数据一次性;为了提高性能,编译器和处理器通常会对指令进行重排序,有如下三种:
编译器在不改变程序语义的前提下,重新安排语句的执行顺序;
int a = 1; int b = a;和条件依赖boolean f = ture; if(f){};例子:如下代码我们的预期应该是得到x=0、y=0这个结果,但实际上可能出现x=2、y=1这种结果;
// 主存的共享变量
int a = 0;
int b = 0;
//代码的顺序
//线程A 线程B
代码1:int x = a; 代码3:int y = b;
代码2:b = 1; 代码4:a = 2;
//经过重排序后最终执行可能出现的顺序
//线程A 线程B
代码2:b = 1; 代码4:a = 2;
代码1:int x = a; 代码3:int y = b;
指令执行一般分为如下步骤:
IF取指:CPU 会根据程序计数器里的存储地址,从内存或缓存中取出待执行的指令,将其加载到指令寄存器中;
ID译码和取寄存器操作数:指令译码单元将指令解析成操作码和操作数,并确定执行指令所需的资源;
EX执行或者有效地址计算:处理器根据指令的操作码执行相应的操作,可能涉及算术逻辑运算、内存访问或控制流操作等;
MEM存储器访问:如果指令涉及内存操作,处理器会在这个阶段进行内存读取或写入操作;
WB写回:将执行阶段得到的结果写回到寄存器文件或内存中;

为了提高硬件的利用率,CPU执行指令会采用流水线技术来工作;图中可以看出,当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样能提升CPU性能;

存在的问题:当流水线出现中断,所有的硬件设备都会进入一轮停顿期;
i = a + b; j = c + d;不存在指令重排序时他们的执行顺序:1.读取a、2.读取b、3.执行a+b、4.保存结果到i、5.读取c、6.读取d、7.执行c+d、8.保存结果到j;作用:根据上述,我们可以知道,指令重排序的作用时减少CPU在流水线执行时的停顿;
带来的问题:对于单线程而言,由于指令重排是在保证串行语义执行的一致性的情况下进行的,但对于多线程环境就可能导致程序乱序执行的问题;
load和存储store操作看上去可能是在乱序操作;JMM主要是围绕程序执行的原子性、有序性、可见性来开展的,通过这三大特性来保证数据的并发安全;
32位系统而言,byte、short、int、float、boolean、char等基本数据类型的读写操作是原子操作,而lone、double存储大小为64bit,一次读写操作需要分两次读取数据,就可能出现数据被两个线程分两次读取的情况;synchronize关键字或Lock锁接口实现类来保证程序执行的原子性;volatile关键字解决;volatile关键字解决,volatile可以通过禁止指令重排序来保证有序性;JMM内部还定义一套happens-before原则来保证多线程环境下两个操作间的原子性、可见性以及有序性JAVA程序在执行过程中,实际就是OS在调度JVM的线程执行,执行过程就是与内存的交互操作,而内存的交互操作有8种;
lock锁定:作用于主内存的变量,将一个变量标识为线程独占状态;unlock解锁:作用于主内存的变量,将一个锁定状态的变量释放出来;read读取:作用于主内存的变量,将一个变量的值从主内存传输到线程的工作内存中;load载入:作用于工作内存的变量,将read操作从主内存中传输的值放入工作内存中;use使用:作用于工作内存的变量,将工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到的变量值,就会使用到这个命令;assign赋值:作用于工作内存的变量,把一个从执行引擎中接受到的值放入工作内存的变量副本中;store存储:作用于主内存的变量,把一个工作内存的一个变量值传送到主内存中;write写入:作用于主内存的变量,把store操作传送的值放入主内存的变量中;JMM指定的交互规则:
assign操作,即工作变量的数据改变后,必须告知主内存;assign操作的数据同步回主内存;use、store操作之前,必须经过assign和load操作;unlock)操作必然发生在后续的同一个锁的加锁(lock)之前;volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性;简单的理解就是:volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值;start()方法先于它的每一个动作,即如果线程A,在执行线程B的start方法前修改了共享变量的值,那么当线程B执行start方法时,线程A变更过的共享变量,对线程B可见;Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见;interrupt()方法的调用,先发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断;finalize()方法;volatile是JAVA提供的轻量级的同步工具,它可以保证可见性和做到禁止指令重排序做到有序性,但不能保证原子性;
CPU指令,作用是保证特定操作的执行顺序和保证某些变量的内存可见性;
MemoryBarrier内存屏障,相当于告诉编译器和CPU不管什么指令都不能与这条MemoryBarrier进行指令重排序,也就是通过插入内存屏障,禁止在内存屏障前后的指令执行重排序;CPU缓存数据,使CPU上的任何线程都能读到这些数据的最新版本;LoadLoad Barriers:确保Load1指令数据的装载,发生于Load2及后续所有装载指令的数据装载之前;
Load1; LoadLoad; Load2;StoreStore Barriers:确保Store1数据的存储对其他处理器可见(刷新到内存中)并发生于Store2及后续所有存储指令的数据写入之前。
Store1; StoreStore; Store2;LoadStore Barriers:确保Load1指令数据的装载,发生于Store2及后续所有存储指令的数据写入之前。
Load1; LoadStore; Store2;StoreLoad Barriers:确保Store1数据的存储对其他处理器可见(刷新到内存中),并发生于Load2及后续所有装载指令的数据装载之前;StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载)完成之后,才执行该屏障之后的内存访问指令;
Store1; StoreLoad; Load2;volatile可以保证,一个线程对volatile所修饰的变量进行更改操作后,总能对其他线程可见;volatile修饰,发生写操作时JMM会把该线程工作内存中的共享变量值刷新到主内存中;当读取操作时,JMM会把该线程对应的工作内存置为无效,要求该线程从主内存中重新读取该变量的值,也是通过内存屏障实现的;volatile通过在修饰变量访问前后添加内存屏障,来静止指令重排序,从而保证有序性;
例子:说明volatile禁止指令重排序,synchronized不禁止;
以下是一个双重锁检测的代码:
public class Singleton{
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if(singleton == null){
synchronized(Singleton.class){
if(singleton == null){
singleton = new Singleton();
}
}
}
return singleton;
}
}
当singleton没有被volatile修饰的时候是可能获取到null值,出现线程不安全的情况,,原因如下:

但当如果singleton变量加上volatile后,会禁止new这个操作被其他线程打断,从而保证线程安全;
A、B、C、D、E,现在对B、C、D加上内存屏障就变成了A、内存屏障( B、C、D)内存屏障、E;此时依旧可以发生重排序,但会将(B、C、D)看作一个整体,再去与A、E排序,其内部也可以重排序;volatile并没有直接使用操作系统的内存屏障指令,而是使用的JVM内存屏障字节码指令,JVM的内存屏障字节码指令会间接使用操作系统的内存屏障指令,也就是JVM对操作系统的内存屏障指令做了层封装,具体的定义位于HotSport源码的bytecodeInterpreter.cpp文件中;StoreLoad读+写屏障实现;
volatile修改的高速缓存数据,写回到机器内存时,会通过缓存一致性协议和总线嗅探技术,通知其他处理器来检查这个变量有没有在自己的缓存中,如果缓存了该变量的数据则将该数据置为无效,后续再需要使用次变量时,重新从内存中读取;T1、T2此时内存中i = 1,现在两个线程都执行i++操作,大致操作如下:
i = 2不符合预期的i = 3;volatile来修饰变量,假设还是上面的执行流程,当T2写值时,会触发内存屏障,此时会要求还未刷回数据的T1线程重新获取一次主存数据,也就是i = 2,再经过计算后写入主存,最终结果为i = 3符合预期;
volatile解决线程安全问题,但其实步骤三是需要建立在T1、T2处与同一核心的情况;对于多核CPU,T1、T2线程可以绑定不同核心,从而达到并行执行的效果,此时就可能出现T1、T2的i++操作,在同一时刻并发执行,接着出现T1、T2同时将i==2这个结果,刷写回主内存情况,从而导致线程安全问题;