写点什么

Java 内存模型

用户头像
Geek_571bdf
关注
发布于: 3 小时前

线索:

1. 如何理解 happens-before 规则;

2. 线程内与线程间(六项)的 happens-before 规则;

3. 指令重排现象;

4. happens-before 的底层实现,举例 volatile;

5. synchronized、final、对象发布

 

1. 站在程序员的视角,可以将 Java 内存模型理解为:Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。

 

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


happens-before 规则

1. 理解 happens-before:happens-before 关系是用来描述两个操作的内存可见性的。比如,操作 x happens-before 操作 y,那么 x 的结果对 y 可见。

 

2. 在线程内,字节码的先后顺序也暗含了 happens-before 关系:在程序控制流路径中靠前的字节码 happens-before 靠后的字节码。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者没有观测前者的运行结果,即后者没有数据依赖于前者,那么它们可能会被重排序。

 

3. 线程间的 happens-before 关系。(六条)

  1. 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。

  2. volatile 字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。

  3. 线程的启动操作(.starts()) happens-before 该线程的第一个操作。// 加上线程内的 happens-before 关系,那么在.start()前主线程对共享变量的操作都对子线程可见。

  4. 线程的最后一个操作 happens-before 它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止)。

  5. 线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用)。

  6. 构造器中的最后一个操作 happens-before 析构器的第一个操作。(析构器 finalizer 是在类被销毁之前由编译器自动调用的一个方法。)

 

4. happens-before 规则还具备传递性。如果操作 X happens-before 操作 Y,操作 Y happens-before 操作 Z,那么操作 X happens-before 操作 Z。

int a=0, b=0;
public void method1() { int r2 = a; b = 1;}
public void method2() { int r1 = b; a = 2;}
复制代码

上面这段代码中,没有定义任何 happens-before 关系,仅拥有默认的线程内 happens-before 关系。也就是 r2 的赋值操作 happens-before b 的赋值操作,r1 的赋值操作 happens-before a 的赋值操作。(程序声明的顺序)

Thread1      Thread2  |            | b=1           |  |          r1=b  |           a=2r2=a           |
复制代码

拥有(默认的线程内)happens-before 关系的赋值操作之间没有数据依赖,因此即时编译器、处理器都可能对其进行重排序。举例来说,只要将 b 的赋值操作排在 r2 的赋值操作之前,那么便可以按照赋值 b,赋值 r1,赋值 a,赋值 r2 的顺序得到(1,2)的结果。

 

要解决这个问题,方法可以是将 a 或者 b 设置为 volatile 字段。比如将 b 设置为 volatile 字段。假设 r1 能够观测到 b 的赋值结果 1。显然,这需要 b 的赋值操作在时钟顺序上先于 r1 的赋值操作。根据 volatile 字段的 happens-before 关系,我们可以得到, b 的赋值操作 happens-before r1 的赋值操作。

 

根据同一个线程中,字节码顺序所暗含的 happens-before 关系,我们可以得到如下的 happens-before 关系:

 

① 赋值 b happens-before 赋值 r1

② 赋值 r2 happens-before 赋值 b

③ 赋值 r1 happens-before 赋值 a

 

根据 happens-before 关系的传递性,我们可以得到 赋值 r2 happens-before 赋值 a

 

这也就意味着,当对 a 进行赋值时,对 r2 的赋值操作已经完成了。因此,在 b 为 volatile 字段的情况下,程序不可能出现(r1,r2)为(1,2)的情况。由此可以看出,解决这种数据竞争问题的关键在于构造一个跨线程的 happens-before 关系。

正常情况包括(r1,r2)=(1,0)、(0,2)、(0,0):发生线程切换


Java 内存模型的底层实现


1. Java 内存模型是通过内存屏障(memory barrier)来禁止重排序的。对于即时编译器来说,它会针对前面提到的每一个 happens-before 关系,向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。这些内存屏障会限制即时编译器的重排序操作。

以 volatile 字段访问为例,所插入的内存屏障将不允许 volatile 字段写操作之前的内存访问被重排序至其之后;也将不允许 volatile 字段读操作之后的内存访问被重排序至其之前。

 

然后,即时编译器会根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于我们日常接触的 X86_64 架构来说,读读、读写以及写写内存屏障是空操作(no-op),只有写读内存屏障会被替换成具体指令。

 

2. 举例来说,对于 volatile 字段,即时编译器会在对 volatile 字段的读写操作前后各插入一些内存屏障。然而,在 X86_64 架构上,只有在 对 volatile 字段写操作之后的 写读内存屏障需要用具体的指令来替代。(HotSpot 所选取的具体指令是 lock add DWORD PTR [rsp],0x0,而非 mfence)。该指令的效果可以理解为 强制刷新处理器的写缓存。

 

写缓存是处理器用来加速内存存储效率的一项技术。在碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步至主内存之中。

 

强制刷新写缓存,将使得当前线程写入 volatile 字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该 volatile 字段的最新值。(无效化缓存行,则会到内存中读取)。


synchronized,volatile,final 与安全发布

1. synchronized(锁=互斥+可见性)

解锁操作 happens-before 之后对同一把锁的加锁操作。实际上,在解锁时,虚拟机同样需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。

 

不过,synchronized 规则说的是对同一把锁。这就意味着,如果编译器能够通过逃逸分析证明某把锁仅被同一线程持有,那么它可以移除相应的加锁解锁操作,自然也就不再强制刷新缓存。比如,即时编译后的 synchronized (new Object()) {},可能等同于空操作(既不会加锁,也不需要解锁),自然也就不会强制刷新缓存。

 

2. volatile

volatile 字段可以看成一种轻量级的、不保证原子性的同步,其性能往往优于锁操作。然而,频繁地访问 volatile 字段也会因为不断地强制刷新缓存而严重影响程序的性能。在 X86_64 平台上,只有 volatile 字段的写操作会强制刷新缓存,因此,理想情况下对 volatile 字段应该是读多写少的,并且应当只有一个线程进行写操作(对 volatile 字段的写操作是互斥的)。

volatile 字段的另一个特性是即时编译器无法将其分配到寄存器里。也就是说,它会确保我们对于这个变量的读取和写入,都一定会同步到主内存里,而不是从 Cache 里面读取。

 

volatile 变量对可见性的影响比 volatile 变量本身更为重要。当线程 A 首先写 volatile 变量,线程 B 随后读取同一 volatile 变量,根据 volatile 的 happens-before 规则,在写入 volatile 变量之前对 A 可见的所有变量的值,在 B 读取了 volatile 变量后,对 B 也是可见的。因此,从内存可见性的角度看,写入 volatile 变量相当于退出同步代码块,而读取 volatile 变量就相当于进入同步代码块。

 

3. final

final 实例字段涉及新建对象的发布问题。当一个对象包含 final 实例字段时,我们希望其它线程看到的是已初始化的 final 实例字段。因此,即时编译器会在 final 字段的写操作 之后插入一个写写屏障,以防某些优化将新建对象的发布(即将实例对象写入到一个共享引用中)重排序至 final 字段的写操作之前。// 双重检测创建单例对象。

 

当然,新建对象的安全发布 不仅包括 final 实例字段的可见性,还包括其它字段的可见性。当发布一个已初始化的对象时,我们希望所有已初始化的实例字段对其他线程可见。否则,其他线程可能见到一个仅部分初始化的新建对象,从而造成程序错误。


用户头像

Geek_571bdf

关注

还未添加个人签名 2019.06.13 加入

还未添加个人简介

评论

发布
暂无评论
Java内存模型