JUC学习之Java内存模型 JMM

概述

        java内存模型可以说出java线程内存模型,因为这是在多线程才有的概念,请不要和jvm里的内存布局作比较。

        上一篇是缓存一致性协议MESI,讲到了缓存一致性问题,也是对本章的一个重要铺垫,这里我就不多说了。除了缓存一致性问题,还有指令重排序问题。

JMM

        Java内存模型是在Java虚拟机规范中定义的,为什么叫做java内存模型呢?就是因为该内存模型仅在java中产生,不是我们通常所说的硬件有关的内存模型。

        Java设计这一内存模型主要是用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量

简单的说,JMM定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障

主内存和工作内存

        java内存模型要做的事就是定义多线程程序中各种变量(该变量不包括线程私有的)的访问规则

        java内存模型规定了所有的变量都存储在主内存中,这个主内存和硬件的主内存是不同的,此处的主内存仅仅是jvm内存的一部分,但是功能是相似的,可以类比。

        其次每条线程还有自己的工作内存(本地内存),工作内存就好比cpu和内存之间的高速缓存一样,主要是存放该线程使用的变量的内存副本线程对变量的所有操作(读写等)必须在工作内存中进行,而不能直接读写主内存中的数据。

内存间交互操作

上面也说过线程间通信必须要经过主内存:

如下,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量。

        JMM还定义了一些关于主内存和工作内存之间互相交互的协议,也可以说是多线程之间的交互操作,JMM定义了以下8种操作来完成。下面的操作都是原子的、不可再分的

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则*

  • 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作,这也是java语言层面我们不能操作没有进行初始化的变量的原因
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值(从主内存重新装配)
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

其中的lock和unlock的规定也就是为什么synchronized能给保证原子性和可见性的原因。

Java内存模型解决的问题

        当对象被存放在计算机中各种不同的内存区域中时,就可能会出现一些具体的问题。Java内存模型建立所围绕的问题:在多线程并发过程中,如何处理多线程读同步问题与可见性(多线程缓存与指令重排序)、多线程写同步问题与原子性(多线程竞争race condition)。

        

重排序导致的可见性问题:

        Java程序中天然的有序性可以总结为一句话:如果在本地线程内观察,所有操作都是有序的(“线程内表现为串行”(Within-Thread As-If-Serial Semantics));如果在一个线程中观察另一个线程,所有操作都是无序的。

源代码要经过编译器重排序->处理器重排序->内存重排序

指令序列的重排序:

        每个处理器上的写缓冲区(store buffer),仅仅对它所在的处理器可见。这会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。

        举个例子:两个线程分别对应两行代码

processor Aprocessor  B
代码

a=1; //A1

x=b; //A2

b=2; //B1

y=a; //B2

结果

初始状态:a=b=0;

可能得到的结果为x=y=0

为什么可能得到的结果会为0呢?因为当A1赋值操作执行时,a=1可能会存放到写缓冲区,然后进行A2读取操作,可能B1的写操作也没用刷新到主存,所以取到0这个脏数据赋给x,同理y也可能会是0。最后才把写缓冲区的数据同步到内存。

        所以可以看出其实A2的操作时先于A1的,因为A1写的值要把值刷新回主存才算真正的执行完毕,所以这就是指令的重排序出现的后果

由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序:

编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

指令重排序改变多线程程序的执行结果例子:

public class VolatileExample {
    int a = 0;
    boolean flag = false;
    public void writer() {
        a = 1;             //1
        flag = true;       //2
    }
    public void reader() {
        if (flag) {        //3
            int i = a;      //4
          ……
        }
    }
}

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

内存屏障:

        对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

        为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

内存屏障实现思路

为什么要插入屏障?本质是业务层面不能接受写store buffer与刷回内存这两个异步操作产生的哪怕是极少的延迟,即对内存可见性的要求极高的时候。

内存屏障到底是什么?内存屏障什么都不是,它只是一个抽象概念,就像OOP。如果这样说你不理解,那你把他理解成一堵墙,这堵墙正面与反面的指令无法被CPU乱序执行及这堵墙正面与反面的读写操作需有序执行

CPU提供了三个汇编指令串行化运行读写指令达到实现保证读写有序性的目的:

  • SFENCE:在该指令前的写操作必须在该指令后的写操作前完成
  • LFENCE:在该指令前的读操作必须在该指令后的读操作前完成
  • MFENCE:在该指令前的读写操作必须在该指令后的读写操作前完成

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性:

  • volatile关键字本身就包含了禁止指令重排序的语义
  • synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

使用原子性保证多线程写同步问题:

原子性:指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。

  • Reads and writes are atomic for reference variables and for most primitive variables (all types except long and double).
  • Reads and writes are atomic for all variables declared volatile (including long and double variables).

实现原子性:

  • 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write,我们大致可以认为基本数据类型变量、引用类型变量、声明为volatile的任何类型变量的访问读写是具备原子性的(long和double的非原子性协定:对于64位的数据,如long和double,Java内存模型规范允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这四个操作的原子性,即如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。但由于目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此在编写代码时一般也不需要将用到的long和double变量专门声明为volatile)。这些类型变量的读、写天然具有原子性,但类似于 “基本变量++” / “volatile++” 这种复合操作并没有原子性。
  • 如果应用场景需要一个更大范围的原子性保证,需要使用同步块技术。Java内存模型提供了lock和unlock操作来满足这种需求。虚拟机提供了字节码指令monitorenter和monitorexist来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步快——synchronized关键字。

as-if-serial语义:

不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。(编译器、runtime和处理器都必须遵守as-if-serial语义),虽然结果是对的,但是底层不一定是按顺序执行的喔。

happens-before:

从JDK 5开始,Java使用新的JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性:在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系:

  • 程序顺序规则:一个线程中的每个操作 happens-before 于该线程中的任意后续操作
  • 监视器规则:对一个锁的解锁 happens-before 于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写 happens-before 于任意后续对这个volatile域的读。
  • start()规则:如果线程A 执行 ThreadB.start()操作,那么A线程的ThreadB.start()操作 happens-before 线程B 中的任意操作
  • join()规则:如果线程A执行操作ThreadB.join()成功返回,那么线程B中的任意操作happens-before 线程A从ThreadB.join()操作成功返回。
  • 传递性:如果A happens-before B 且B happens-before C ,那么A happens-before C。

一个happens-before规则对应于一个或多个编译器和处理器重排序规则。

happens-before规则不是单纯的谁先谁后,而是保证一种可见性,就比如第一条规则,它保证了一个线程中的前面的操作对后面的操作一定可见,该排序的还是会排序,只不过不会乱排序。

细心点你会发现如果java多线程没有happens before规则,那么就会很危险,如:join()方法执行了,但是要等待的线程还没有start()完毕,但是有happens-before规则,start()一定会执行完毕才会执行join(),想象一下如果没有该规则,那么java多线程也就没有严谨性了。

JMM主要是根据原子性、有序性和可见性三点进行的

JMM中重要的语义详解:

volatile详解:volatile关键字

synchronized:共享模型之管程 synchronized详解

final详解:JUC之final原理

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值