您的位置 首页 java

一次性带你了解清楚Java内存模型

Java 内存模型

Java 虚拟机内部使用 JMM(Java 内存模型) 将内存划分为两个逻辑单元, 线程栈 (或者叫 本地内存 )和

每一个线程都有属于自己的线程栈,在线程栈中会保存局部变量(也叫做本地变量)、方法中定义的参数和异常处理器的参数(catch中的参数);这些参数和变量都属于线程局部操作,会被隔离,所以不受内存模型影响。

可见性问题

假设有下面这样一组代码,:

 static boolean run = true;
public static void main(String[] args) throws InterruptedException {
     Thread  t = new Thread(()->{
        while(run){
            // ....
        }
    });
    t.start();

    sleep(1);

    run = false; // 线程 t 不会如预想的停下来
}
  

下面是执行步骤:

  • 线程 t 刚开始执行时,会从堆中将 run 的变量值读取一份保存到自己的线程栈中(也就是在自己的线程栈中创建了一个副本)。注意:这样做是为了减少对堆中 run 的访问,提高效率。
  • 这时 线程 t 每次读取 run 的值,都是从自己的线程栈中读取的。
  • 当 1 秒之后,main 线程修改了 run 的值,并同步到堆中,而 t 读取 run 的值时,还从自己的线程栈中读取,线程栈中的 run 值并没有被改变,所以线程一直没法停止。

下面是一个对象在内存中分布的例子:

 public class MyRunnable implements Runnable() {

    public void run() {
        methodOne();
    }

    public void methodOne() {
        int localVariable1 = 45;

        MySharedObject localVariable2 = MySharedObject.sharedInstance;

        //... do more with local variables.

        methodTwo();
    }

    public void methodTwo() {
        Integer localVariable1 = new Integer(99);

        //... do more with local variable.
    }
}

public class MySharedObject {

    //static variable pointing to instance of MySharedObject

    public static final MySharedObject sharedInstance = new MySharedObject();

    //member variables pointing to two objects on the heap

    public  Integer  object2 = new Integer(22);
    public Integer object4 = new Integer(44);

    public long member1 = 12345;
    public long member2 = 67890;
}
  

每个执行 methodOne() 的线程都会在各自的线程栈上创建自己的 localVariable1 和 localVariable2 的副本;localVariable1 副本之间将完全分离,只活在每个线程的线程栈上; 一个线程不能看到另一个线程对 localVariable1 地做了什么改变,就是说对其它线程不可见(就算一个线程将修改的值同步回堆中,另一个线程没有重新读取该值,对这个线程来说就是不可见的),也不受到内存模型的影响。

localVariable2 在不同线程栈中也有不同的副本,但是这两个不同副本最终都指向静态变量所引用的对象(堆上的同一个对象);因此,localVariable2 的两个副本最终都指向静态变量指向的 MySharedObject 实例。MySharedObject 实例也存储在堆上。它对应于下图中的 Object 3。

注意:MySharedObject 类也包含了四个成员变量,这些成员变量和对象一起存储在堆上,其中两个成员变量指向另外两个 Integer 对象,这些 Integer 对象对应上图中的 Object 2 和 Object 4。

在 methodTwo() 方法中创建了一个名为 localVariable1 的局部变量,这个局部变量是对 Integer 对象的引用,localVariable1 引用将在每个执行 methodTwo() 的线程中存储一个副本;但是由于该方法每次执行时都会创建一个新的 Integer 对象,所以 localVariable1 引用指向一个新的 Integer 实例,对应下图中的 Object 1 和 Object 5。

值得注意的是:对于基本数据类型(boolean、byte、short、char、int、long、float、double) 都是直接保存变量的副本到自己的线程栈中,而对于引用类型(Byte、Integer、Long 等) 它的引用(就是变量名) 是保存在自己的线程栈中,对象本身是保存在堆中的。还有就是 堆内存 中存放了所有的实例字段(也可以叫做实例域)、静态字段(也可以叫做静态域)和数组元素(数组也是对象),堆内存在多个线程之间共享。

最后需要注意一点,当方法中调用了类变量(也称静态变量、静态域)和实例变量(也称实例域、非静态域)时,也会保存这些变量的副本到线程栈中。

解决可见性问题

最简单的方式就是使用 volatile 修饰符,将代码改成 volatile static boolean run = true; ,这样线程 t 每次使用到这个值的时候 都会从堆中获取

值得注意的是:该关键字可以用来修饰成员变量和静态成员变量。

当然也可以使用 synchronized 来解决可见性。

重排序

为了提高程序性能,在不影响单线程执行结果的前提下 CPU 和 JIT 编译器会做指令重排序操作(即生成的机器指令与字节码指令顺序不一致)。

这是一个单线程中重排序的例子,假设有下面这样一段代码:

 a = b + c
d = a + e

l = m + n
y = x + z
  

重排序后,可能会变成下面这个样子,由于前三行没有任何关联(关联指的是数据之间依赖,例如第六行代码依赖第一行代码,所以他们之间是有关联的), CPU 有可能会并行执行前三行,从而提高程序性能。

 a = b + c

l = m + n
y = x + z

d = a + e
  

使用图形展示了多核 CPU 重排序和指令执行的情况:

通过上面的图片可以发现,CPU 执行指令并不是随随便便执行,和重排序时一样必须遵循某个规则;这个规则就是 as-if-serial 语义:所有的动作都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。 Java 编译器、运行时和处理器都会保证单线程下的 as-if-serial 语义。比如,为了保证这一语义,重排序不会发生在有数据依赖的操作之中。

重排序对多线程的影响

请先看这样一段代码:

 public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
    Thread one = new Thread(new Runnable() {
        public void run() {
            a = 1;
            x = b;
        }
    });
    
    Thread other = new Thread(new Runnable() {
        public void run() {
            b = 1;
            y = a;
        }
    });
    one.start();other.start();
    one.join();other.join();
    System.out.println(“(” + x + “,” + y + “)”);
}
  

很容易想到这段代码的运行结果可能为(1,0)、(0,1)或(1,1),因为线程 one 可以在线程 two 开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。

然而,这段代码的执行结果也可能是(0,0). 因为,在实际运行时,代码指令可能并不是严格按照代码语句顺序执行的。得到(0,0)结果的语句执行过程,如下图所示。值得注意的是,a=1 和 x=b这 两个语句的赋值操作的顺序被颠倒了,或者说,发生了指令“重排序”(reordering)。(事实上,输出了这一结果,并不代表一定发生了指令重排序,内存可见性问题也会导致这样的输出)

一次性带你了解清楚Java内存模型

Happens-before 关系

Java 中对 Happens-before 定义为:如果一个操作的结果需要对另一个操作可见,那么这两个操作之间必须存在 Happens-before 关系。也就是说,Happens-before 的前后两个操作不会被重排序且后者对前者的内存可见。
值得注意的是:这里说的两个操作既可以是在一个线程之内,也可以是在不同线程之间。还有就是这两个操作并不是说一定要一前一后执行,Happens-before 只要求第一个操作的结果对第二个操作可见,并且第一个操作排在第二个操作之前就可以。
简单点来说就是:Happens-before 就是可见性规则;什么情况下对共享变量的写,可以对共享变量的读是可见的。

根据 Java 内存模型中的规定,可以总结出以下几条 happens-before 规则:

  • 单线程中代码执行顺序:A happens-before B。之前的操作(变量赋值),对之后的操作(变量取值)是可见的(就是说,变量的值是可见的)。
  • synchronized:第一个线程释放了锁后,对第二个要加锁的线程可见。注意:假设第二个线程没有获取锁,而是直接使用共享变量,这样不行(不可见)。
  • volatile:第一个线程的写,对第二个线程的读是可见的。每个线程都是直接读写堆内存。
  • 线程启动。
  • 线程结束:其它线程调用了 Thread.join(等到该方法返回)或者调用了 Thread.isAlive(必须返回 false)时,才对调用的线程可见。
  • 中断:一个线程 Thread#interrupt() 另一个线程后,共享变量的值对被打断的线程可见。
  • 终结:一个对象的构造函数的结束 happens-before 于这个对象 finalizer 的开始。
  • 传递性:如果 A happens-before B,且 B happens-before C,A happens-before C。

Happens-before 关系只是对 Java 内存模型的一种近似性的描述,它并不够严谨,但便于日常程序开发参考使用,关于更严谨的 Java 内存模型的定义和描述,请阅读 JSR-133 原文或 Java 语言规范章节 17.4。

除此之外,Java 内存模型对 volatile 和 final 的语义做了扩展。对 volatile 语义的扩展保证了 volatile 变量在一些情况下不会重排序,volatile 的 64 位变量 double 和 long 的读取和赋值操作都是原子的。对 final 语义的扩展保证一个对象的构建方法结束前,所有 final 成员变量都必须完成初始化(的前提是没有 this 引用溢出)。

happens-before 与 JMM 的关系如下图所示:

JMM 会根据 happens-before 禁止处理器对某些代码重排序,并保证共享变量的可见性;关于 Java 内存模型重排序的规定以及 volatile 关键字的详解,都可以查看 volatile 关键字。

讨论-为什么堆中存放的是字段和数组元组呢?

Java 中的对象是复合型对象,是由 Object + 原生类型的的字段或数组组成。

程序中使用的和 CPU 所看到的都是数据,数据是通过物理地址加偏移(offset)得到。

总结

线程栈和堆、happens-before、重排序、内存屏障这些都属于 Java 内存模型(Java Memory Model)。

知识拓展-原子性

原子(Atomic)就是不可分割(就是不可被拆分)的意思。

就是说, 多线程 操作同一个共享变量时,不能交叉操作;例如 t1 线程操作一下,然后 t2 线程操作一下,这样就没有办法保证变量的原子性;所以必须要等待其中一个线程操作完成后,另一个线程才能继续操作。

知识拓展-Java 异常处理机制中的重排序

为保证 as-if-serial 语义,Java 异常处理机制也会为重排序做一些特殊处理。

例如在下面的代码中,y = 0 / 0 可能会被重排序在 x = 2 之前执行,为了保证最终不致于输出 x = 1 的错误结果,JIT 在重排序时会在 catch 语句中插入错误代偿代码,将 x 赋值为 2,将程序恢复到发生异常时应有的状态。

这种做法的确将异常捕捉的逻辑变得复杂了,但是 JIT 的优化的原则是,尽力优化正常运行下的代码逻辑,哪怕以 catch 块逻辑变得复杂为代价,毕竟,进入 catch 块内是一种“异常”情况的表现。

 public class Reordering {
    public static void main(String[] args) {
        int x, y;
        x = 1;
        try {
            x = 2;
            y = 0 / 0;    
        } catch (Exception e) {
        } finally {
            System.out.println("x = " + x);
        }
    }
}
  

知识拓展-内存系统重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令集并行的重排序:现在处理器采用了指令集并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

前两种在本文已经有写过,这里针对第三种做详细说明。

计算机系统中,为了尽可能地避免处理器访问主内存的时间开销,处理器大多会利用缓存(cache)以提高性能。其模型如下图所示:

在这种模型下会存在一个现象,即缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。

这导致在同一个时间点,各 CPU 所看到同一内存地址的数据的值可能是不一致的。从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。

有的观点会将这种现象也视为重排序的一种,命名为“内存系统重排序”。因为这种内存可见性问题造成的结果就好像是内存访问指令发生了重排序一样。

从 Java 源代码到最终实际执行的指令顺序,会分别经历下面三种重排序:

最后,祝大家早日学有所成,拿到满意offer,快速升职加薪,走上人生巅峰。

求一个三连!!!!

文章来源:智云一二三科技

文章标题:一次性带你了解清楚Java内存模型

文章地址:https://www.zhihuclub.com/194005.shtml

关于作者: 智云科技

热门文章

网站地图