Java内存模型

时间:2022-07-23
本文章向大家介绍Java内存模型,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

什么是Java内存模型

java内存模型(JMM)全称为Java Memory Model,是java虚拟机为了java程序能够正常运行而制定的一套规范,规范中规定了JVM中的数据如何与RAM的数据进行交互。

Java内存模型是怎样的

我们知道,在Java中,实例字段、静态字段和构成数组对象的元素是线程共享的,但局部变量与方法参数是线程私有的,不会被共享。所以以下我们所讲的变量等均指线程共享的数据,而非线程私有的数据。

Java 内存模型中规定了所有的变量都存储在主内存中,每个线程还有自己的工作内存(类比缓存理解),线程的工作内存中保存了该线程使用到主内存中的变量拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递(通信)均需要在主内存来完成,线程、主内存和工作内存的交互关系如下图所示

在这里插入图片描述

这个图和 CPU 与缓存的图非常类似,搞不好 JMM 的构建就是仿照硬件系统来的。同样的道理我们要思考一下在多线程的环境中,JMM 又是如何保证主内存和工作内存中的变量一致性?回忆一下 CPU 是如何保证缓存一致性的,使用 MESI 协议。那在这里呢,Java 内存模型就定义了 8 种操作和 8 个规则。

回头想想,JMM 是一套规则呀,它只会给你定义规范,模型,具体的实现自己玩去!理解这一点很重要。我们来看看它给出了哪些操作和必须满足的规则吧。

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

如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行 read 和 load 操作,如果把变量从工作内存同步回主内存中,就要按顺序地执行 store 和 write 操作。Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间,store 和 write 之间是可以插入其他指令的,如对主内存中的变量 a、b 进行访问时,可能的顺序是 read a,read b,load b, load a。Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

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

好了,到这里看似我们可以完美的保证多线程情况下主内存和工作内存中数据的一致性了(也就是线程安全),But 醒醒好不好,JMM 只是一套规则呀,请问你实现了么???并没有……

对了,还是要说明一下,我从开始到现在都在介绍 JMM 看清楚这不是 JVM 啊,我的理解啊,JMM 是灵魂,是规范,是一个标准,那我们说的 JVM 其实是一个个的实现,其中比较著名的一个实现就是 HotSpot VM,好了,现在的问题就比较清楚了,接口中已经告诉你了,你只需要满足 8 个操作和 8 条规则,你就是线程安全的。

问题是,我们的 HotSpot VM 怎么实现???先别急,还有一个事请呢!

关于指令重排序

你难道忘了吗,在 CPU 执行指令的时候会存在乱序执行优化,这里也是一样啊,也会为了提高执行效率而对我们写的代码进行优化,但是呢,这里换一个叫法,改名指令重排。

指令重排可以加快程序的执行效率,但在某些情况下可能引起程序BUG。比如下面这种情况

/**
 * 一个简单的展示Happen-Before的例子.
 * 这里有两个共享变量:a和flag,初始值分别为0和false.在ThreadA中先给     a=1,然后flag=true.
 * 如果按照有序的话,那么在ThreadB中如果if(flag)成功的话,则应该a=1,而a=a*1之后a仍然为1,下方的if(a==0)应该永远不会为
 * 真,永远不会打印.
 * 但实际情况是:在试验100次的情况下会出现0次或几次的打印结果,而试验1000次结果更明显,有十几次打印.
 */
public class SimpleHappenBefore {
    /** 这是一个验证结果的变量 */
    private static int a=0;
    /** 这是一个标志位 */
    private static boolean flag=false;
    public static void main(String[] args) throws InterruptedException {
        //由于多线程情况下未必会试出重排序的结论,所以多试一些次
        for(int i=0;i<1000;i++){
            ThreadA threadA=new ThreadA();
            ThreadB threadB=new ThreadB();
            threadA.start();
            threadB.start();
            //这里等待线程结束后,重置共享变量,以使验证结果的工作变得简单些.
            threadA.join();
            threadB.join();
            a=0;
            flag=false;
        }
    }
    static class ThreadA extends Thread{
        public void run(){
        a=1;
        flag=true;
        }
    }
    static class ThreadB extends Thread{
        public void run(){
            if(flag){
            a=a*1;
            }
            if(a==0){
            System.out.println("ha,a==0");
            }
        }
    }
}

产生上诉问题的原因是因为flag=true在a=1之前执行完毕了。我们假设a=1是一个很耗时的操作,那么CPU就有可能会把相对不耗时的操作提前执行。

那 JMM 又是怎么来处理多线程下的指令重排呢?JMM 提供了一个关键字 volatile 来解决指令重排问题。

关于 volatile 关键字,JMM 专门定义了一套特殊的访问规则,主要为达到两个目的,一是保证此变量对所有变量的可见性。二是禁止指令重排优化。解释一下第一个目的,我们知道在主内存和工作内存中变量交互的时候,假如线程将变量 a + 1,还没有写入主内存的时候,其它线程是不知道 a 的值被修改了。那现在就是希望我在工作内存改了变量之后,其它的线程能看到变量被改了。

我们经常会误用 volatile 关键字,虽然被 volatile 修饰的变量 i 是可见的,我们也保证 i 的值是可以实时从主存中获取,但是这并不代表 i ++ 就是线程安全的,因为 i ++ 不是原子性操作,可以被拆成 geti addi puti 3步。

好了,到这里 JMM 就有了一套解决多线程安全问题的方案,这套方案又有哪些特性呢,或者说,线程安全的特性有哪些呢?

原子性:我们要求一个线程在操作数据的时候,不能被打断。Java 内存模型定义的 8 种操作,就要求虚拟机的实现每一步都必须是原子性的,即不可分割的。

对于基本数据类型的读或写可以看成是原子性操作,但是有例外,对于32位的机器来说,一次只能处理32位,但是 double 和 long 类型的数据长度为 64,理论上会可能会被拆分,但实际运行中没有这种情况,所以可以认为对 double 和 long 来说,读或写也是线程安全的。

可见性:因为在多线程环境下主内存和工作内存中数据不一致可能会导致问题,可见性要求一个线程修改了主内存中的值之后,其它的线程能立即得知这个修改。

有序性:主要体现在在单线程时逻辑上的有序,在定义 8 种操作规则的时候的有序,还有最后的指令重排中的有序。这八种操作规则就是大家常说的happens-before 原则。他包括如下

  1. 程序次序规则(Program Order Rule):在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是代码顺序,因为要考虑分支、循环等结构。
  2. 监视器锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个对象锁的lock操作。这里强调的是同一个锁,而“后面”指的是时间上的先后顺序,如发生在其他线程中的lock操作。 volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作发生于后面对这个变量的读操作,这里的“后面”也指的是时间上的先后顺序。
  3. 线程启动规则(Thread Start Rule):Thread独享的start()方法先行于此线程的每一个动作。
  4. 线程终止规则(Thread Termination Rule):线程中的每个操作都先行发生于对此线程的终止检测,
  5. 我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值检测到线程已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupte()方法的调用优先于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否已中断。
  7. 对象终结原则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

如果两个操作的执行顺序不能通过 happens-before 原则推导出来,就不能保证他们的执行次序,虚拟机就可以随意的对他们进行重排序。

总结

可以看到,要说清楚Java的内存模型其实并不容易,简单而言就是java有自己的线程,线程可和主内存中的数据进行交互,但往细里说,如何保证多线程交互的过程不出现问题实际上才是JMM的难点。

原文:https://zhuanlan.zhihu.com/p/58387104