java 内存模型jmm底层原理详解

  作者:图灵javaer

Java内存模型(Java Memory Model简称JMM)


不要跟Jvm内存模型搞混淆,Java内存模型指的是Java多线程内存模型,Java多线程内存模型跟cpu缓存模型类似,是基于cpu缓存模型来建立的,Java线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别


CPU多核并发缓存架构




现代CPU为了提升执行效率减少CPU与内存的交互(交互影响CPU效率),一般在CPU上集成了多级缓存架构,常见的为三级缓存结构

  • L1 Cache,分为数据缓存和指令缓存,逻辑核独占
  • L2 Cache,物理核独占,逻辑核共享
  • L3 Cache,所有物理核共享


存储器存储空间大小:内存>L3>L2>L1>寄存器;

存储器速度快慢排序:寄存器>L1>L2>L3>内存;


缓存是由最小的存储区块-缓存行(cacheline)组成,缓存行大小通常为64byte


比如你的L1缓存大小是512kb,而cacheline = 64byte,那么就是L1里有512 * 1024/64个cacheline


CPU读取存储器数据过程


  1. CPU要取寄存器X的值,只需要一步:直接读取
  2. CPU要取L1 cache的某个值,需要1-3步(或者更多):把cache行锁住,把某个数据拿来,解锁,如果没锁住就慢了。
  3. CPU要取L2 cache的某个值,先要到L1 cache里取,L1当中不存在,在L2里,L2开始加锁,加锁以后,把L2里的数据复制到L1,再执行读L1的过程,上面的3步,再解锁。
  4. CPU取L3 cache的也是一样,只不过先由L3复制到L2,从L2复制到L1,从L1到CPU。
  5. CPU取内存则最复杂:通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定。


可见性示例:


public class VolatileVisibilityTest {

private static boolean initFlag = false;

public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("waiting data...");
while (!initFlag) {

}
System.out.println("===========success");
}).start();

Thread.sleep(2000);
new Thread(() -> prepareData()).start();

}

public static void prepareData() {
System.out.println("prepare data...");
initFlag = true;
System.out.println("prepare data end...");
}


}


输出:




可以看到第一个线程不能感知到变量的改变,还是死循环运行着


那么怎么让一个线程修改了共享变量后,让另一个线程感知到呢?


修改程序:给共享变量添加volatile关键字


public class VolatileVisibilityTest {

private static volatile boolean initFlag = false;

public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("waiting data...");
while (!initFlag) {

}
System.out.println("===========success");
}).start();

Thread.sleep(2000);
new Thread(() -> prepareData()).start();

}

public static void prepareData() {
System.out.println("prepare data...");
initFlag = true;
System.out.println("prepare data end...");
}


}


输出:




可以看到共享变量修改后,其他线程能被感知到,并且退出了循环


数据同步八大原子操作


  • read(读取):从主内存读取数据
  • load(载入):将主内存读取到的数据写入工作内存
  • use(使用):从工作内存读取数据来计算
  • assign(赋值):将计算好的值重新赋值到工作内存中
  • store(存储):将工作内存数据写入主内存
  • write(写入):将store过去的变量值赋值给主内存中的变量
  • lock(锁定):将主内存变量加锁,标识为线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量


执行过程:



无volatile关键字执行修改共享变量后,并未同步给线程1,导致线程1还是死循环。




有volatile关键字执行修改共享变量后,同步给线程1,线程1跳出循环。


缓存一致性协议(MESI)


多个cpu主内存读取同一个数据到各自的高速缓存当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存其它cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效


缓存加锁


缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存无效,IA—32和Intel 64处理器使用MESI实现缓存一致性协议


Volatile缓存可见性实现原理


底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存

IA・32和Intel 64架构软件开发者手册对lock指令的解释:


1)会将当前处理器缓存行的数据写回到系统内存。

2)这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MESI协议)

3)提供内存屏障功能,使lock前后指令不能重排序


并发编程三大特性:可见性,有序性,原子性


volatile保证可见性与有序性,但是不保证原子性,保证原子性需要借助synchronized这样的锁机制


指令車排序:在不影响单线程序执行结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令重排序优化




重排序会遵循as-if-serialhappens-before原则


as-if-serial原则


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

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。


示例:


Thread one = new Thread(() -> {
a = y;
x = 1;
});


这两行代码没有任何依赖关系,这些操作就可能被编译器和处理器重排序。


Thread one = new Thread(() -> {
a = y;
x = a;
});


这两行代码存在依赖关系,这些操作不会做重排序。


代码实践:


public class VolatileSerialTest {

public static int x;
public static int y;
public static int a;
public static int b;

public static void main(String[] args) throws InterruptedException {
Set<String> resultSet = new HashSet<>();

for (int i = 0; i < 10000000; i++) {
x = 0;
y = 0;
a = 0;
b = 0;

Thread one = new Thread(() -> {
a = y;
x = 1;
});

Thread other = new Thread(() -> {
b = x;
y = 1;
});

one.start();
other.start();
one.join();
other.join();

resultSet.add("a=" + a + "," + "b=" + b);
System.out.println(resultSet);
}
}
}


输出结果存在4种情况:




执行顺序分别是:


  • 线程1和线程2都顺序执行a=y=0,b=x=0
  • 线程2先执行完毕,此时y=1,那么a=y=1,b=x=0
  • 线程1先执行完毕,此时x=1,那么a=y=0,b=x=1
  • 指令重排情况:执行顺序可能为:y=1; x=1; a=y=1; b=x=1;


happens-before原则(某些操作必须在某些操作之前执行,不能做指令重排序,因为会改变程序运行的结果


1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。


举个反例:(伪代码)


object.lock();
object.unlock();
object.lock();
object.unlock();


object.lock();
object.lock();
object.unlock();
object.unlock();


两者的结果完全不一样,那么就不应该做指令重排序


3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。

4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见

5. 传递性 A先于B ,B先于C 那么A必然先于C

比如下面这个代码具有传递性,不允许指令重排

int a = 0;
int b = 1;
int c = a+b;
int d = c+b;


如果重排后,将会改变代码的运行逻辑


int d = c+b;
int a = 0;
int b = 1;
int c = a+b;


两者完全不一致,不能进行重排


6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。

比如下面这个代码中,start()肯定是发生在join()之前的

Thread t = new Thread(() -> {

});
t.interrupt();
t.join();


7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

比如下面这个代码中,start()肯定是发生在interrupt()之前的

Thread t = new Thread(() -> {

});

t.start();
t.interrupt();


如果重排后,那中断interrupt()将毫无意义


Thread t = new Thread(() -> {

});
t.interrupt();
t.start();


8. 对象终结规则对象的构造函数执行,结束先于finalize()方法


也就是说,jvm是在保证代码安全的基础上,进行执行优化。


单例的双重检测的代码(多线程下不安全


public class DoubleCheckLock {
private static DoubleCheckLock instance;

private DoubleCheckLock() {
}

public static DoubleCheckLock getInstance() {
//第一次检测
if (instance == null) {
//同步
synchronized (DoubleCheckLock.class) {
if (instance == null) {
//多线程环境下可能会出现问题的地方
instance = new DoubleCheckLock();
}
}
}

return instance;
}
}


如果有两个线程同时调用getInstance()方法,由于加上了synchronized关键字,那么两个线程中只有一个线程可以执行程序,等第一个线程执行完毕后,第二个线程来到同步代码块中,由于instance不为空,那么就不会初始化DoubleCheckLock()对象。


但是,实际上这段代码还是线程不安全的,因为可能在初始化的时候会做指令重排序(发生的概率极低,但在高并发下可能就会发生)。


因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码)


正常情况下:

memory = allocate();//1.分配对象内存空间
instance(memory);   //2.初始化对象
instance = memory;  //3.设置instance指向刚分配的内存地址,此时instance!=null

由于步骤1和步骤2间可能会重排序,如下:

memory=allocate();//1.分配对象内存空间
instance=memory;  //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory); //2.初始化对象

为了保证这段代码线程安全:可以加上volatile关键字,它可以保证可见性与有序性,来禁止指令重排序。


public class DoubleCheckLock {
private static volatile DoubleCheckLock instance;

private DoubleCheckLock() {
}

public static DoubleCheckLock getInstance() {
//第一次检测
if (instance == null) {
//同步
synchronized (DoubleCheckLock.class) {
if (instance == null) {
//多线程环境下可能会出现问题的地方
instance = new DoubleCheckLock();
}
}
}

return instance;
}
}


内存屏障(Memory Barrier)



JVM规范定义的内存屏障



JVM规定volatile需要实现的内存屏障




  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。


下图是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图




不同CPU硬件对于JVM的内存屏障规范实现指令不一样

  • Intel CPU硬件级内存屏障实现指令
  • Ifence:是一种Load Barrier 读屏障,实现LoadLoad屏障 sfence:是一种Store Barrier 写屏障,实现StoreStore屏障
  • mfence:是一种全能型的屏障,具备lfence和sfence的能力,具有所有屏障能力


JVM底层简化了内存屏障硬件指令的实现

  • lock前缀lock指令不是一种内存屏障,但是它能完成类似内存屏障的功能


更多细节参考文档:


并发编程之JMM&volatile详解.pdf


JVM-JMM-CPU底层全执行流程.jpg

JSR133中文版-java内存模型与线程规范.pdf


图灵VIP-MESI缓存一致性协议详解.pdf


MESI协议状态切换过程分析.jpg

相关推荐

评论 抢沙发

表情

分类选择