Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。Java内存模型(JMM)规定了jvm有主内存,主内存是多个线程共享的。每个线程都有自己的工作内存,工作内存存储了主存的某些共享变量的副本。

线程的工作内存在主存还是缓存中**?
MM 中定义的每个线程私有的工作内存是抽象的规范,实际上工作内存和真实的CPU 内存架构如下所示,Java 内存模型和真实硬件内存架构是不同的:
Java内存模型 - 图1JMM与真实内存架构

JMM 是内存模型,是抽象的协议。首先真实的内存架构是没有区分堆和栈的,这个Java 的JVM 来做的划分,另外线程私有的本地内存线程栈可能包括CPU 寄存器、缓存和主存。堆亦是如此!

当然线程的工作大小是有限制的。当线程操作某个对象时,执行顺序如下:

  1. 从主存复制变量到当前工作内存(readandload)
  2. 执行代码,改变共享变量值(useandassign)
  3. 用工作内存数据刷新主存相关内容(storeandwrite)

JVM规范定义了线程对主存的操作指令:read、load、use、assign、store、write。

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作(单一操作都是原子的)来完成:

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

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

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

Java 通过 Java 内存模型(JMM )实现 volatile 平台无关

当一个共享变量在多个线程的工作内存中都有副本时,如果一个线程修改了这个共享变量,那么其他线程应该能够看到这个被修改之后的值,这就是多线程的可见性问题。
那么什么是有序性呢?线程在引用变量时不能直接从主内存中引用,如果线程工作内存中没有该变量,则会从主内存中拷贝一个副本到工作内存中,这个过程为read-load,完成后线程会引用该副本。当同一线程再度引用该字段时,有可能从主内存中获取变量副本(read-load-use),也有可能直接引用原来的副本(use),也就是说read、load、use顺序可以由JVM实现系统决定。
线程不能直接为主内存中字段赋值,它会将值指定给工作内存中的变量副本(assign),完成后这个变量副本会同步到主存储区(store-write),至于何时同步过去,根据JVM实现系统决定。有该字段,则会从主内存中将该字段赋值到工作内存中,这个过程为read-load,完成后线程会引用该变量副本,当同一线程多次重复对字段赋值时,比如:
Java代码:

  1. for (inti=0;i<10;i++)
  2. a++;

线程有可能只对工作内存中的副本进行赋值,只到最后一次赋值后才同步到主存储区,所以assign、store、write顺序可以由JVM实现系统决定。假设有一个共享变量x,线程a执行x=x+1。从上面的描述中课可以知道x=x+1并不是一个原子操作,它的执行过程如下:

  1. 从主存中读取变量x副本到工作内存
  2. 给x加1
  3. 讲x加1后的值写回主存

如果另外一个线程b执行x=x-1,执行过程如下:

  1. 从主存中读取变量x副本到工作内存
  2. 给x减1
  3. 将x减1后的值写回主存

那么显然,最终的x的值是不可靠的。假设x现在为10,线程a加1,线程b减1,从表面上看,似乎最终x还是为10,但是多线程情况下会有这样的情况发生:

  1. 线程a从主存读取x副本到工作内存,工作内存中x的值为10
  2. 线程b从主存读取x副本到工作内存,工作内存中x的值为10
  3. 线程a将工作内存中x加1,工作内存中x为11,
  4. 线程a将x提交到主存中,主存中x为11
  5. 线程b将工作内存中x减1,工作内存中x为9,
  6. 线程b将x提交到主存中,主存中x为9

同样,x有可能为11,如果x是一个银行账户,线程a存款,线程b扣款,显然这样是有严重问题的,要解决这个问题,必须保证线程a和线程b是有序执行的,并且每个线程执行的加1和减1是一个原子操作。

publicclassAccount{
    privateintbalace;
    publicAccount(intbalance){
        this.balance=balance;
    }
    publicintgetBalance(){
        returnbalance;
    }
    publicvoidadd(intnum){
        balance=balance+num;
    }
    publicvoidwithdraw(intnum){
        balance=balance-num;
    }
    publicstaticvoidmain(Stringargs[])throwsInterruptedException{
        Accountaccount=newAccount(1000);
        Threada=newThread(newAddThread(account,20),”add”);
        Threadb=newThread(newWithdrawThread(account,20),”withdraw”);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println(account.getBalance());
    }
    staticclassAddThreadimplementsRunnable{
        Accountaccount;
        intamount;
        publicAddThread(Accountaccount,intamount){
            this.account=account;
            this.amount=amount;
        }
        publicvoidrun(){
            for (inti=0;i<200000;i++){
                account.add(amount);
            }
        }
    }
}
staticclassWithdrawThreadimplementsRunnable{
    Accountaccount;
    intamount;
    publicWithdrawThread(Accountaccount;
    intamount){
        this.account=account;
        this.amount=amount;
    }
    publicvoidrun(){
        for (inti=0;i<100000;i++){
            account.withdraw(amount);
        }
    }
}

第一次执行的结果是10200,第二次执行的结果是1060,每次执行的结果都是不确定的,因为线程的执行顺序是不可预见的。这是java同步产生的根源。synchronized关键字保证了多个线程对于同步块是互斥的,synchronized作为一种同步手段,解决java多线程的执行的有序性和内存可见性,而volatile关键字只解决多线程的内存可见性问题。
针对上面的问题,Java 中提供了一些关键字来解决。

  1. 可见性 & 有序性 问题解决
    volatile 可以让共享变量实现可见性,同时禁止共享变量的指令重排,保障可见性。从JSR-333 规范 和 实现原理讲:
  • JSR-333 规范:JDK 5定义的内存模型规范,

    在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

  • 1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
    2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

    happens-before

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

实现原理:上面说的happens-before原则保障可见性
禁止指令重排保证有序性,如何实现的呢?

  • Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,保证共享变量操作的有序性。
    内存屏障指令:写操作的会让线程本地的共享内存变量写完强制刷新到主存。读操作让本地线程变量无效,强制从主内存读取,保证了共享内存变量的可见性。
    JVM中提供了四类内存屏障指令:
    Java内存模型 - 图2image-20200512091721797JSR-133 定义的相应的内存屏障,在第一步操作(列)和第二步操作(行)之间需要的内存屏障指令如下:
    Java内存模型 - 图3image-20200511174714486Java volatile 例子:
    Java内存模型 - 图4image-20200511175002261
    以下是区分各个CPU体系支持的内存屏障(也叫内存栅栏),由JVM 实现平台无关(volatile所有平台表现一致)
    Java内存模型 - 图5image-20200511172853931synchronized 也可以实现有序性和可见性,但是是通过锁让并发串行化实现有序,内存屏障实现可见。原理可以看《安琪拉与面试官二三事》系列的synchronized 篇。
  • 一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
  • 在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。

    As-if-serial

    As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。

    并发&并行

    现代操作系统,现代操作系统都是按时间片调度执行的,最小的调度执行单元是线程,多任务和并行处理能力是衡量一台计算机处理器的非常重要的指标。这里有个概念要说一下:

  • 并发:多个程序可能同时运行的现象,例如刷微博和听歌同时进行,可能你电脑只有一颗CPU,但是通过时间片轮转的方式让你感觉在同时进行。

  • 并行:多核CPU,每个CPU 内运行自己的线程,是真正的同时进行的,叫并行。

    内存屏障

    JSR-133 对应规则需要的规则
    Java内存模型 - 图6image-20200511174714486
    另外 final 关键字需要 StoreStore 屏障
    x.finalField = v; StoreStore; sharedRef = x;

    MESI 协议运作模式

    MESI 协议运作的具体流程,举个实例
    Java内存模型 - 图7image-20200511161720436
    第一列是操作序列号,第二列是执行操作的CPU,第三列是具体执行哪一种操作,第四列描述了各个cpu local cache中的cacheline的状态(用meory address/状态表示),最后一列描述了内存在0地址和8地址的数据内容的状态:V表示是最新的,和cache一致,I表示不是最新的内容,最新的内容保存在cache中。

    总结

    Java内存模型

    Java 内存模型(JSR-133)屏蔽了硬件、操作系统的差异,实现让Java程序在各种平台下都能达到一致的并发效果,规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量,JMM使用内存屏障提供了java程序运行时统一的内存模型。

    volatile的实现原理

    volatile可以实现内存的可见性和防止指令重排序。
    通过内存屏障技术实现的。
    为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障指令,内存屏障效果有:

  • 禁止volatile 修饰变量指令的重排序

  • 写入数据强制刷新到主存
  • 读取数据强制从主存读取

    volatile使用总结
  • volatile 是Java 提供的一种轻量级同步机制,可以保证共享变量的可见性和有序性(禁止指令重排),常用于
    状态标志、双重检查的单例等场景。使用原则:

  • 对变量的写操作不依赖于当前值。例如 i++ 这种就不适用。
  • 该变量没有包含在具有其他变量的不变式中。
    volatile的使用场景不是很多,使用时需要仔细考虑下是否适用volatile,注意满足上面的二个原则。
  • 单个的共享变量的读/写(比如a=1)具有原子性,但是像num++或者a=b+1;这种复合操作,volatile无法保证其原子性;

不同的平台,内存模型是不一样的,但是jvm的内存模型规范是统一的。其实JAVA的多线程并发问题最终都会反映在java的内存模型上,所谓线程安全无非是要控制多个线程对某个资源的有序访问或修改。总结java的内存模型,要解决两个主要的问题:可见性和有序性。我们都知道计算机有高速缓存的存在,处理器并不是每次处理数据都是取内存的。JVM定义了自己的内存模型,屏蔽了底层平台内存管理细节,对于java开发人员,要清楚在jvm模型的基础上,如果解决多线程的可见性和有序性。
那么,何谓可见性呢?多个线程之间是不能互相传递数据通信的,他们之间的沟通只能通过共享变量来进行。