JVM学习笔记10:内存模型

概念

  • 个人感觉这个内存模型的名称很让人迷惑,开始理解的模型是一个数据结构,但是明显这里的内存模型是一个套解决内存可见性和代码乱序执行的技术方案。
  • PS:《深入理解java虚拟机:jvm高级特性与最佳实践》中提到的:Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

解决什么问题

  • 在多线程环境下,假设这两个方法分别跑在两个不同的线程之上,如果 Java 虚拟机在执行了任一方法的第一条赋值语句之后便切换线程,那么最终结果将可能出现(0,0)的情况。
  • 造成这一情况的原因有三个,分别为即时编译器的重排序,处理器的乱序执行,以及内存系统的重排序。 后两种原因涉及具体的体系架构。
1
2
3
4
5
6
7
8
9
10
11
int a=0, b=0;

public void method1() {
int r2 = a;
b = 1;
}

public void method2() {
int r1 = b;
a = 2;
}
  • 即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。
  • 另外,如果两个操作之间存在数据依赖,那么即时编译器(和处理器)不能调整它们的顺序,否则将会造成程序语义的改变。

Java 内存模型与 happens-before 关系

  • happens-before 关系是用来描述两个操作的内存可见性的。如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 可见。
  • 在同一个线程中,字节码的先后顺序(program order)也暗含了 happens-before 关系:在程序控制流路径中靠前的字节码 happens-before 靠后的字节码。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者没有观测前者的运行结果,即后者没有数据依赖于前者,那么它们可能会被重排序。
  • PS:万恶的编译器优化

happens-before规则 (非常关键

  • 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。
  • volatile 字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。前提是同一个线程中,不同线程不一定。
  • 线程的启动操作(即 Thread.starts()) happens-before 该线程的第一个操作。
  • 线程的最后一个操作 happens-before 它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止)。
  • 线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用)。
  • 构造器中的最后一个操作 happens-before 析构器的第一个操作。
  • happens-before 关系还具备传递性。如果操作 X happens-before 操作 Y,而操作 Y happens-before 操作 Z,那么操作 X happens-before 操作 Z。

解决可见性和编译器乱序问题的方法

  • volatile 字段
  • 以上面的代码为例,比如说将 b 设置为 volatile 字段。假设 r1 能够观测到 b 的赋值结果 1。显然,这需要 b 的赋值操作在时钟顺序上先于 r1 的赋值操作。根据 volatile 字段的 happens-before 关系,我们知道 b 的赋值操作 happens-before r1 的赋值操作。
  • PS:上面这段话中的粗体部分很关键,还是要看执行顺序的。也就是说如果两个线程同时跑修改后的代码,先执行了method2,其实r1还是0。
1
2
3
4
5
6
7
8
9
10
11
12
13

int a=0;
volatile int b=0;

public void method1() {
int r2 = a;
b = 1;
}

public void method2() {
int r1 = b;
a = 2;
}
  • 下图是.NET中的处理方式,其实都是一样的。

image

Java 内存模型的底层实现

  • Java 内存模型是通过内存屏障(memory barrier)来禁止重排序的。
  • 对于即时编译器来说,它会针对前面提到的每一个 happens-before 关系,向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。
  • PS:在.NET中相当于在合适的位置调用MemoryBarrier方法。
  • 这些内存屏障会限制即时编译器的重排序操作。以 volatile 字段访问为例,所插入的内存屏障将不允许 volatile 字段写操作之前的内存访问被重排序至其之后;也将不允许 volatile 字段读操作之后的内存访问被重排序至其之前。
  • 即时编译器将根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。以我们日常接触的 X86_64 架构来说,读读、读写以及写写内存屏障是空操作(no-op),只有写读内存屏障会被替换成具体指令。

volatile的实际效果

  • 在 X86_64 架构上,只有 volatile 字段写操作之后的写读内存屏障需要用具体指令来替代。(HotSpot 所选取的具体指令是 lock add DWORD PTR [rsp],0x0,而非 mfence。)
  • 该具体指令的效果,可以简单理解为强制刷新处理器的写缓存。写缓存是处理器用来加速内存存储效率的一项技术。
  • 在碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步至主内存(main memory)之中。
  • 强制刷新写缓存,将使得当前线程写入 volatile 字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。
  • 由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该 volatile 字段的最新值。

锁,volatile 字段,final 字段与安全发布

  • 在解锁时,Java 虚拟机同样需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
  • 锁操作的 happens-before 规则的关键字是同一把锁。也就意味着,如果编译器能够(通过逃逸分析)证明某把锁仅被同一线程持有,那么它可以移除相应的加锁解锁操作。
  • PS:即时编译后的 synchronized (new Object()) {},可能等同于空操作,而不会强制刷新缓存。
  • PS2:例子就是多线程对资源进行抢占,用lock或者synchronized来做锁。synchronized的本质是成对出现的lock和unlock。第一个抢到的线程unlock后,下一个线程对同一个资源对象进行lock,此时前一个锁中的内存变化对后一个可见。

volatile

  • volatile 字段可以看成一种轻量级的、不保证原子性的同步,其性能往往优于(至少不亚于)锁操作。然而,频繁地访问 volatile 字段也会因为不断地强制刷新缓存而严重影响程序的性能。
  • 在 X86_64 平台上,只有 volatile 字段的写操作会强制刷新缓存。因此,理想情况下对 volatile 字段的使用应当多读少写,并且应当只有一个线程进行写操作。
  • volatile 字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说,volatile 字段的每次访问均需要直接从内存中读写。
  • PS:volatile变量不能被分配到寄存器中,但是计算还是加载到寄存器中来计算的。所谓的分配到寄存器中,可以理解为编译器将内存中的值缓存在寄存器中,之后一直用访问寄存器来代表对这个内存的访问的。假设我们要遍历一个数组,数组的长度是内存中的值。由于我们每次循环都要比较一次,因此编译器决定把它放在寄存器中,免得每次比较都要读一次内存。对于会更改的内存值,编译器也可以先缓存至寄存器,最后更新回内存即可。Volatile会禁止上述优化。
  • 最后,volatile禁止了编译器的顺序优化。

final

  • final 实例字段则涉及新建对象的发布问题。当一个对象包含 final 实例字段时,希望其他线程只能看到已初始化的 final 实例字段。因此,即时编译器会在 final 字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布(即将实例对象写入一个共享引用中)重排序至 final 字段的写操作之前。在 X86_64 平台上,写写屏障是空操作。
  • 新建对象的安全发布(safe publication)问题不仅仅包括 final 实例字段的可见性,还包括其他实例字段的可见性。

发布问题

  • 首先原子性指的是CPU指令的原子执行,一句高级代码后面是多个CPU指令,并不能保证原子性。
  • 看似安全的双检锁由于编译器的优化,会先申请内存,然后把内存地址付给instance变量,在初始化对象。
  • 在多线程的情况下,后面到来的线程可能会读到一个非空但是没有初始化的instance对象,导致运行出错。
  • 对instance加了volatile关键字后,可以解决编译器优化导致的乱序问题,这样在发布的时候保证的是一个初始化后的对象。
  • PS:对标.NET里面的Interlock.Exchange()方法。
1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

总结一下:

  • 总结来说:刷新内存、禁止编译优化就是volatile的作用,这解决了可见性与有序性两个问题。
  • 也就是说第二段代码里面可以保证的是int r2 = a;b = 1;之前执行。为啥会不在之前呢?因为这两句话完全没有依赖性,所以正常来说编译后谁先谁后都可以的。
  • PS:CLR via C#里这一段真的很有趣。
# Java
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×