暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

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

53

写在文章开头

本文将着重从JMM指令规范以及如何解决程序可见性和有序性两个问题为入口,为读者深入剖析JMM内存模型,希望对你有帮助。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的技术人,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili

同时也非常欢迎你star我的开源项目mini-redis:https://github.com/shark-ctrl/mini-redis

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注  “加群”  即可和笔者和笔者的朋友们进行深入交流。

详解指令重排序问题

什么是重排序问题

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

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

编译器重排序

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

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

指令重排序

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

内存系统重排序

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

如何避免指令重排序

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

详解Java内存模型JMM

什么是JMM模型

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

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

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

  1. lock:首先将变量锁住,将这个共享变量设置为线程独占变量。
  2. read:将主存的共享变量读取到本地内存中。
  3. load:将变量load
    拷贝一份到本地内存中生成共享变量的副本。
  4. use:将共享变量副本放到执行引擎中。
  5. assign:将共享变量副本赋值给本地内存的变量。
  6. store:将变量放到主内存中
  7. write:写入主内存对应变量中
  8. unlock:解锁,该共享变量此时就可以被其他线程操作了。

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

  1. 线程没有发任何assign
    操作的变量不可以写回主内存中。
  2. 新的变量只能在主内存中诞生。这就意味的线程中的变量必须是通过load
    从主存加载后再通过assign
    得到的。
  3. 一个线程通过lock
    锁定主内存变量共享变量时,这个线程可以对其上无数次锁(即线程可重入)
    ,其他线程就不能在对其上锁了。
  4. 一个线程没有lock
    一个共享变量,就不能对其进行unlock
  5. 在执行use
    操作前,必须清空本地内存,通过load
    或者assign
    初始化变量值才可操作本地变量。

JVM和JMM有何区别(重点)

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

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

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

  1. 程序顺序规则
    :写前面的变量happens-before
    于后面的代码。
  2. 传递规则
    : A happens-before B
    B happens-before C
    ,那么A happens-before C
  3. volatile 变量规则
    : volatile
    的变量的写操作, happens-before
    后续读该变量的代码。
  4. 线程启动规则
    Thread
    start
    都有先于后面对于该线程的操作。
  5. 解锁规则
    :对一个锁的解锁操作happens-before
    对这个锁的加锁操作。

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

而对于某些场景, 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:

 public static void main(String[] args) {
        int one = getOne();//1
        int two = getTwo();//2
        System.out.println(one + two);//3
    }

    private static int getOne() {
        return 1;
    }

    private static int getTwo() {
        return 2;
    }


happens-before和JMM有什么关系

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

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

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

  1. loadload
    :先加载load1
    先于后load2
    的操作。
  2. loadstore
    load1
    的操作先于后store
    及其后续存储指令刷新到内存。
  3. storestore
    store1
    的数据对其他处理器可见,且先于后store及其后续的写指令。
  4. storeload
    :先store
    的操作对于后load
    可见,即先store操作会刷新到内存这一步先于后续load的后续读指令。

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

小结

我是 sharkchiliCSDN Java 领域博客专家mini-redis的作者,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili

同时也非常欢迎你star我的开源项目mini-redis:https://github.com/shark-ctrl/mini-redis

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注  “加群”  即可和笔者和笔者的朋友们进行深入交流。

参考

JMM(Java 内存模型)详解:https://javaguide.cn/java/concurrent/jmm.html#从-cpu-缓存模型说起

《Java并发编程的艺术》


文章转载自写代码的SharkChili,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论