从零开始理解 Java 内存模型——可见性与有序性详解

本文将着重从JMM指令规范以及如何解决程序可见性和有序性两个问题为入口,为读者深入剖析JMM内存模型
首页 新闻资讯 行业资讯 从零开始理解 Java 内存模型——可见性与有序性详解

一、详解指令重排序问题

1.什么是重排序问题

代码在执行过程从,不同层级的运行为了提高最终指令执行效率,都会对执行响应重排序,以Java程序为例,从编译到执行会经历:

  • 生成指令阶段:编译器重排,该阶段JMM通过禁止特定类型的编译器重排序达到要求。

  • 处理器阶段:指令并行重排序和内存系统加载重排序,这种处理器级别的重排序问题,则是要求编译器在生成指令阶段通过插入内存屏障即memory barriers指令禁止特定方式重排序。

929034d385a6b66e5e43748d949673a7d70012.webp

2.编译器重排序

编译器(包括 JVM、JIT 编译器等)重排序即不影响单线程执行结果的情况下,会针对性的重排代码的效率以提高单线程情况下代码执行效率。当然这种重排序可能也会存在一些问题,假设我们现在有这样一段代码,双方先对各自的localNum初始化,然后用变量x、y读取变量localNum的值,假设发生指令重排序就会导致x、y拿到默认的零值而输出0:

91d06921008aaded61c449ef095af8840c7c4e.webp

对于这种情况,JMM会针对性发生这种重排序的编译器进行禁止来解决这种问题。

3.指令重排序

现代的处理器会对某些指令进行重叠执行(采用指令级并行技术(Instruction-Level Parallelism,ILP),亦或者在不影响执行结果的情况下会将Java字节码对应的机器码指令进行顺序调换以提高单线程下代码的执行效率,这种问题的表象和上述情况类似,这里也就不再演示了。

4.内存系统重排序

该方式排序并不是真正意义上的重排序,在JMM上常常表现为主存和本地内存的数据不一致。

5.如何避免指令重排序

这一点其实在上述各种重排序都已经简单的说明了,对于编译器,会禁止特定类型的编译器重排序来避免编译器重排序在多线程情况下带来的问题。对于指令重排序即处理器重排序,JVM生成程序指令序列时,会根据情况插入特定的内存屏障(Memory Barrier)来相关指令来告知处理器避免特定类型的指令重排序。

二、详解Java内存模型JMM

1.什么是JMM模型

为了屏蔽不同操作系统之间操作系统内存模型的差异,Java定义了属于自己的内存模型规范解决这个问题。 JMM也可以理解为针对Java并发编程的一组规范,抽象了线程和主内存之间的关系,以类似于volatile、synchronized等关键字以解决并发场景下重排序带来的问题。

JMM规定所有示例对象都必须放置在主存中,所以每个线程需要操作这些数据时就需要将数据拷贝一份到本地内存中在进行相应的操作。

234ae5b279b541fde33724a8e47575553e5057.webp

而每个Java将主存中拷贝的变量在完成操作后写回主存中会经历以下过程:

  • lock:首先将变量锁住,将这个共享变量设置为线程独占变量。

  • read:将主存的共享变量读取到本地内存中。

  • load:将变量load拷贝一份到本地内存中生成共享变量的副本。

  • use:将共享变量副本放到执行引擎中。

  • assign:将共享变量副本赋值给本地内存的变量。

  • store:将变量放到主内存中

  • write:写入主内存对应变量中

  • unlock:解锁,该共享变量此时就可以被其他线程操作了。

94dbd7805191a04eb04603ad25318bf303a84f.webp

同时,JMM模型还规定这些操作还得符合以下规范:

  • 线程没有发任何assign操作的变量不可以写回主内存中。

  • 新的变量只能在主内存中诞生。这就意味的线程中的变量必须是通过load从主存加载后再通过assign得到的。

  • 一个线程通过lock锁定主内存变量共享变量时,这个线程可以对其上无数次锁(即线程可重入),其他线程就不能在对其上锁了。

  • 一个线程没有lock一个共享变量,就不能对其进行unlock。

  • 在执行use操作前,必须清空本地内存,通过load或者assign初始化变量值才可操作本地变量。

2.JVM和JMM有何区别(重点)

JVM规定了运行时的区域划分,例如实例对象必须放置在堆区等。 而JMM则决定了线程和和主内存之间的关系,例如共享变量必须存放在主内存中。通过定义一系列规范和原则简化用户实现并发编程的种种操作且确保Java代码从编译到转为CPU机器码执行结果都是准确无误的,也就是说JMM是一种内存模型语义的抽象并非实际的内存模型。

3.什么是happens-before原则?常见的happens-before原则有哪些?

happens-before也是一种JMM内存模型用来阐述内存可见性的一种规约,对应的happens-before原则共有8条,而常见的有以下5条:

  • 程序顺序规则 :写前面的变量happens-before于后面的代码。

  • 传递规则: A happens-before B,B happens-before C,那么A happens-before C。

  • volatile 变量规则: volatile的变量的写操作, happens-before后续读该变量的代码。

  • 线程启动规则 :Thread的start都有先于后面对于该线程的操作。

  • 解锁规则:对一个锁的解锁操作happens-before对这个锁的加锁操作。

对于不会影响单线程或者多线程指令重排序操作不做要求,即不会过分干预编译器和处理器的大部分优化操作,例如下面这段代码,在单线程情况下,因为两者声明没有任何关联,处理器为了提高程序执行的并行度完全可以不管任何顺序任意执行,这也就是我们常说的as-if-serial,即没有强关联的指令,处理器可以根据自己的优化算法执行,任意重排序,对外结果好像就是串行执行一样:

49120e1951439a7614065534865141d5b916a6.webp

而对于某些场景, JMM对于编译器或处理的某些会影响指令重排序的操作进行禁止,如下所示,getOne和getTwo先于最后计算,计算依赖于前两个变量,操作即两个get操作happens-before于最后的计算,但是两个get操作没有强关联,所以JVM这两段代码进行指令重排序的时候,JMM是允许的,所以执行时getTwo可能会先于getOne执行。

与之相反就是最后的计算,因为依赖于前两个get,所以JMM模型是明确要求禁止这种情况,于是就提出了happens-before原则,即写前面的变量happens-before于后面的代码以及A happens-before B,B happens-before C,那么A happens-before C,按照我们的例子就是每一个get操作都会按照顺序写,因为1操作先于2先于3,所以最终执行顺序就是1、2、3:

publicstaticvoidmain(String[]args){int one=getOne();//1int two=getTwo();//2System.out.println(one+two);//3}privatestaticintgetOne(){return1;}privatestaticintgetTwo(){return2;}

4.happens-before和JMM有什么关系

JMM原则和禁止重排序的遵循的准则都是基于 happens-before准则要求,也就是要求针对编译器的指令重排序必须根据该准则通过某种方式落实,最常见的方式就是在生成执行指令前插入内存屏障让处理器知晓那些指令不可重排序来解决问题,由此实现程序员只需理解happens-before原则的抽象即可理解可见性,由此避免底层编译器和处理器具体的实现:

a8a314d7224f0c5add77143d72d07eb0e20fd5.webp

5.JMM规范如何解决处理器指令重排序问题

为了保证内存可见性,编译器在生成指令指令序列时通过内存屏障指令来禁止特定类型的处理器重排序问题,对应的屏障指令有:

  • loadload:先加载load1先于后load2的操作。

  • loadstore:load1的操作先于后store及其后续存储指令刷新到内存。

  • storestore:store1的数据对其他处理器可见,且先于后store及其后续的写指令。

  • storeload:先store的操作对于后load可见,即先store操作会刷新到内存这一步先于后续load的后续读指令。

所以对于多核CPU对彼此内存操作不可见导致数据错乱,我们可以直接通过storeload指令来解决该问题:

e843df3881ffeca613f924f1ce79f440102240.webp

63    2024-11-18 16:37:35    JMM Java 内存模型