zl程序教程

您现在的位置是:首页 >  后端

当前栏目

java内存模型的理解

JAVA内存 模型 理解
2023-09-14 09:13:33 时间


并发问题产生的源头

缓存导致的可见性问题

可见性定义: 一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

在单核CPU时代,所有线程都是被同一个CPU调度,因此共享同一块cpu的缓存,不存在缓存一致性问题:
在这里插入图片描述
多核CPU时代,每个线程都有可能同时被不同的cpu调度,每个cpu都有各自的缓存,并且读写优先走缓存,这就会导致缓存一致性问题:

在这里插入图片描述
高速缓存和主内存之间如何保持数据一致性


线程切换导致的原子性问题

CPU能保证的原子操作是CPU指令级别的,高级语言里一条语句往往需要多条 CPU 指令完成,例如:count += 1,至少需要三条 CPU 指令:
在这里插入图片描述


编译优化带来的有序性问题

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,从而导致意外的bug。

最常见的例子就是双重锁检查创建单例对象了:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

new创建对象在java中分为三步:

  • 分配一块内存 M;
  • 在内存 M 上初始化 Singleton 对象;
  • 然后 M 的地址赋值给 instance 变量。

但是如果编译器进行了指令重排序优化,变成了下面这样:

  • 分配一块内存 M;
  • 将 M 的地址赋值给 instance 变量;
  • 最后在内存 M 上初始化 Singleton 对象。

在下面场景中,线程B访问未初始化过的instance,可能会触发空指针异常:
在这里插入图片描述


小结

java并发编程问题三大根本来源: 可见性,原子性,有序性

  • 缓存导致的可见性问题
  • 线程切换导致的原子性问题
  • 编译优化导致的有序性问题

Java内存模型: 解决可见性和有序性问题

Java内存模型与JVM内存模型的区别

  1. Java内存模型定义了一套规范,能使JVM按需禁用cpu缓存和禁止编译优化。这套规范包括对volatile, synchronized, final三个关键字的解析,和7个Happen-Before规则。

  2. JVM内存模型是指程序计数器,虚拟机栈,本地方法栈,堆,方法区这5和要素。


volatile关键字

volatile在c语言中最原始的含义就是禁用cpu缓存,volatile修饰符表达的是: 对某个变量的读写,不能使用cpu缓存,必须从内存中读取或者写入。

大家看下面这个例子: 线程A执行writer方法,按照volatile语义,会把变量v=true写入内存,假设线程B执行reader方法,同样按照volatile语义,线程B会从内存中读取变量v, 如果线程B看到v==true时,那么线程B看到的变量x的值是多少呢?

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}

答案:

  • jdk 1.5之前,x可能是42,也可能是0 ,因为变量x可能被cpu缓存而导致可见性问题
  • jdk 1.5之后, x就是等于42。因为java内存模型在1.5版本对volatile语义进行了增强

怎么增强的呢?

  • happens-before规则

Happens-Before规则

Happens-Before规则:前面一个操作的结果对后续操作是可见的;

Happens-Before规则约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before规则。

具体规则如下:

  1. 程序的顺序性规则:在一个线程中,前面的操作Happens-Before于后续的任意操作。
  2. volatile变量规则:对一个volatile变量的写操作Happens-Before于对这个volatile变量的读操作。
  3. 传递性规则:A Happens-Before B,B Happens-Before C,那么A Happens-Before C。
    在这里插入图片描述

从图中,我们可以看到:

  • “x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;
  • 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。

再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能看到 “x == 42” ,有没有一种恍然大悟的感觉?这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的。

  1. 管程中锁的规则:synchronized是Java对管程的实现,隐式加锁、释放锁,对一个锁的解锁Happens-Before于后续对这个锁的加锁。
  2. 线程start()规则:主线程A启动子线程B后,start()操作 Happens-Before于子操作中的任意操作。
  3. 线程join规则:在线程A中调用线程B的join()并返回,线程B中的任意操作 Happens-Before于join()的返回。
  4. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  5. 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

上述很多规则都需要配合传递性规则进行理解。


小结

Java内存模型涉及的几个关键词:锁、volatile字段、final修饰符与对象的安全发布。

  • 第一是锁,锁操作是具备happens-before关系的,解锁操作happens-before之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
  • 第二是volatile字段,volatile字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。
  • 第三是final修饰符,final修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。

Java内存模型底层怎么实现的?

  • 主要是通过内存屏障(memory barrier)禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于volatile,编译器将在volatile字段的读写操作前后各插入一些内存屏障。

在java中,Happens-Before规则本质还是一种可见性,A Happens-Before B,意味着A事件对B事件来说是可见的,无论A事件和B事件是否发生在同一个线程里,例如: 事件A发生在线程1,事件B发生在线程2,Happens-Before规则保证线程2上也能看到A事件的发送。


思考题

还是文中给出的案例,大家思考会不会产生x=42和v=true重排序,导致线程B读取到x=0的结果呢?

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  
  public void writer() {
    x = 42;
    v = true;
  }
  
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
  
}

解答:

  • 程序顺序性规则是针对单线程的。如果只考虑单线程,那么编译器可以对范例代码进行指令重排优化。但是对于多线程,volatile 变量规则、传递性这 2 条规则,就附加了新的限制。对于多线程,这 3 条 happens-before 规则要求,线程 B 在读到 v = true 的时候,也能见到 x = 42。如果编译器仍然按照单线程的情况,对这两条语句进行指令重排,把 v = true 放到 x = 42 之前。那么,线程 B 就有可能看不到 x 的值为 42。这显然违背了 happens-before 的规定。编译器为了符合规则,只能不进行指令重排优化了。
  • 为了符合 happens-before 规定,对于示例代码,编译器不能进行指令重排的编译优化。但实际上,仅仅不进行指令重排编译优化,并不能保证编译后的代码的执行结果,符合 happnes-before 规定。因为指令重排这一优化措施,并不仅仅是编译器会做。现代 cpu 在执行机器指令的时候,同样会做指令重排的优化。所以,如果 cpu 在执行机器指令时,发生了机器指令的重排序。上述实例代码的结果,仍然有机会不符合 happens-before 规定。
  • 为了令到编译后的代码的执行结果,能够符合 happens-before 规定。编译器除了不能做指令重排序编译优化之外,还要在生成的机器代码中。加入特定 cpu 指令,令到 cpu 只会执行完这条特定指令之后,才会执行后续的其它机器指令。而这种特定指令,是通过建立 “内存屏障” 来禁止 cpu 的指令重排序的。那为什么叫 “内存屏障” 呢?可以理解为一种特殊指令,要求 cpu 把缓存数据写回到主内存中。这就像在内存中建立了一道屏障,令到后面的代码不能越过屏障,提前执行。

jmm 是一个规范,它用于指导编译器的行为。但它本身不会限制编译器所使用的具体编译技术。所以,在 jmm 规范中,不会提到 “指令重排” 或者 "内存屏障” 这些具体的实现技术。这是我们在学习规范类知识的时候,需要注意的。


参考

JAVA并发编程实战