0%

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

什么是Java内存模型

内存结构指JVM中堆、栈等。
“工作内存”不是物理上存在的。(主内存、工作内存等概念)

导致可见性的原因是缓存,导致有序性的原因是编译优化,解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,程序的性能就堪忧了。合理的方案应该是按需禁用缓存以及编译优化。

Java内存模型是个很复杂的规范(jsr[JavaSpecification Requests]规范),本质上可以理解为,Java内存模型规范了JVM如何按需禁用缓存和编译优化的方法。具体来说这,这些方法包括volatile、synchronized、final三个关键字,以及六项Happens-Before规则

Java内存模型底层主要是通过内存屏障(memory barrier)禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的CPU指令。
对于编译器而言,内存屏障将限制它所能做的重排序优化。对于处理器而言,内幕才能屏障将会导致缓存的刷新操作。

使用volatile的困惑

volatile关键字并不是Java语言的特产,C语言也有,它最原始的意义就是禁用CPU缓存。

声明一个volatile变量volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。(volatile修饰的变量刷新回内存的时候,所有CPU缓存里的值都会失效,下次访问到的都是最新新的)

Java内存模型在1.5版本对volatile语义进行了增强-一项Happens-Before规则。

Happens-Before规则

字面意思“先行发生”。Happens-Before并不是说前面一个操作发生在后续操作的前面,真正表达的是:前面一个操作的结果对后续操作是可见的。比较正式的说法是:Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before规则。

Happens-Before规则和程序员相关的规则一共有八项,都是关于可见性的。

1.程序的顺序性规则

在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任意操作。比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。(程序控制流顺序而不是程序代码顺序(分支、循环))

2.volatile变量规则

对一个volatile变量的写操作,Happens-Before于后续对这个volatile变量的读操作。(表面是禁用缓存的意思,须关联规则3)。【不保证原子性,但保证可见性、有序性】

3.传递性

如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
这是1.5版本对volatile语义的增强,1.5并发工具包(java.util.concurrent)就是靠volatile语义来搞定可见性的。

4.管程中锁的规则

对同一个锁的解锁Happens-Before于后续对这个锁的加锁。

管程是一种通用的同步原语,在Java中指的就是synchronized,synchronized是Java里对管程的实现。

管程中的锁在Java里是隐式实现的,在进入同步块之前,会自动加锁monitorentor,而在代码块执行完或异常会自动释放锁monitorexit,加锁以及释放锁都是编译器实现的。

5.线程start()规则[线程启动]

主线程A启动子线程B后,子线程B能够看到主线程在启动子线程前的操作。

如果线程A调用线程B的start()方法(即在线程A中启动线程B),那么该start()操作Happens-Before于线程B中的任意操作。

6.线程join()规则[线程等待]

主线程A等待子线程B完成(主线程A通过调用子线程B的join()方法实现),当子线程B完成后(主线程A中join()方法返回),主线程能够看拿到子线程的操作。所谓的“看到”,指的是对共享变量的操作。

如果在线程A中,调用线程B的join()并成功返回,那么线程B中的任意操作Happens-Before于该join()操作的返回。

线程中的所有操作都先行发生于对此线程的终止检测。通过Thread.join()方法结束、Thread.isAlive()返回值等手段检测线程已经终止执行。

7.线程中断规则

对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。可以通过Thread.interrupted()方法检测到是否有中断发生。

8.对象终结规则

一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

被忽视的final

final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。Java编译器在1.5以前的版本的确优化的很努力,以至于都优化错了。(双重检查创建单例,构造函数的错误重排导致线程可能看到final变量的值会变化(指令重排导致访问到未初始化完的对象))

在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要提供正确构造函数没有“逸出”(对封装性的破坏),就不会出现问题。

1
2
3
4
5
6
7
8
9
final int x;
public FinalFieldExample() {
x = 3;
y = 4;
//this逸出
global.obj = this;
//在构造函数中将this赋值给了全局变量global.obj,这就是逸出
//线程通过global.obj读取x是有可能读到0的。(可能不是3)
}

总结

Java的内存模型是并发编程领域的一次重要创新,之后C++、C#、Golang等高级语言都开始支持内存模型。Java内存模型里面。最晦涩的部分是Happens-Before规则。Happens-Before规则最初是在Time, Clocks, and the Ordering of Events in a Distributed System论文中提出来的,论文中,Happens-Before的语义是一种因果关系。在现实世界中,如果A事件时导致B事件的起因,那么A事件一定是先于(Happens-Before)B事件发生的,这个就是Happens-Before语义的现实理解。

在Java语言里面,Happens-Before的语义本质上是一种可见性,A Happens-Before B意味着A事件对B事件来说是可见的,无论A事件和B事件是否发生在同一个线程里。

Java内存模型主要分为两部分,一部分面向编写并发程序的应用开发人员(这部分内容的核心就是Happens-Before规则),另一部分是面向JVM的实现人员。