Java内存模型
多线程环境中存在因数据竞争而产生的同步问题,为了避免因数据竞争而产生同步问题,Java虚拟机规范(Java5)引入了happens-before这一概念来定义Java内存模型。
happens-before 描述了两个操作内存的可见性。例如X happens-before Y,那么 X 的结果对于 Y 可见。
happens-before 定义的可见性按照线程的划分可以分为单个线程内与多个线程间的操作可见性。单个线程内的happens-before规则叫做程序次序规则,这个规则其实指的就是解决指令重排序引起的数据竞争问题,当然程序次序规则并并没有明确禁止指令重排序,而是要求重排序后和我们的代码顺序一致。多个程序间的happens-before规则还有以下几种:
- 管理锁定规则: 【解锁操作】 happens-before 【之后(这里指时钟顺序先后)对同一把锁的加锁操作】。例如 thread1解锁了monitor a,接着thread2锁定了thread1已经解锁了的monitor a,那么,thread1解锁monitor a之前的写操作都对thread2可见(线程1和线程2可以是同一个线程)。
- volatile变量规则:【volatile 字段的写操作】happens-before 【之后(这里指时钟顺序先后)对同一字段的读操作】。例如线程1对volatile变量a进行写操作之后,线程2一定能看到线程1对于a的操作,也就是线程2一定能读到更新后a的值。
- 线程启动规则:【Thread.start()方法】happen—before【调用start的线程前的每一个操作】。例如在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
- 线程终止规则:【线程的最后一个操作】 happens-before 【它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止)】。也就是在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。
- 线程中断规则:【线程对其他线程的中断操作】 happens-before 【被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用)】。也就是【对线程interrupt()的调用】 happen—before【 发生于被中断线程的代码检测到中断时事件的发生】;
- 对象终结规则:【构造器中的最后一个操作】 happens-before 【析构器的第一个操作】;
- 传递规则: 如果操作 X happens-before 操作 Y,而操作 Y happens-before 操作 Z,那么操作 X happens-before 操作 Z。
Java 内存模型的底层实现
该部分主要是是在学习郑雨迪老师在极客时间专栏《深入理解Java虚拟机》第13小节时的理解与笔记。并且以下提到的缓存均指计算机组成原理中的CPU cache。
Java 内存模型是通过**内存屏障(memory barrier)**来禁止重排序的。
对于即时编译器来说,它会针对前面提到的每一个 happens-before 规则,向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。
这些内存屏障会限制即时编译器的重排序操作。以 volatile 字段访问为例,所插入的内存屏障将不允许 volatile 字段写操作之前的内存访问被重排序至其之后;也将不允许 volatile 字段读操作之后的内存访问被重排序至其之前。
然后,即时编译器将根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。以我们日常接触的 X86_64 架构来说,读读、读写以及写写内存屏障是空操作(no-op),只有写读内存屏障会被替换成具体指令。该具体指令的效果,可以简单理解为强制刷新处理器的写缓存。
写缓存是处理器用来加速内存存储效率的一项技术。在使用cpu缓存cache时,碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步至主内存(main memory)之中。
而强制刷新写缓存,将使得当前线程写入 volatile 字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该 volatile 字段的最新值。
锁,volatile 字段,final 字段与安全发布
锁,volatile 字段,final 字段与安全发布均为Java 内存模型涉及的几个关键词。
上文提到,锁操作同样具备 happens-before 关系。具体来说,解锁操作 happens-before 之后对同一把锁的加锁操作。实际上,在解锁时,Java 虚拟机同样需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
需要注意的是,锁操作的 happens-before 规则的关键字是同一把锁。也就意味着,如果编译器能够(通过逃逸分析)证明某把锁仅被同一线程持有,那么它可以移除相应的加锁解锁操作。因此也就不再强制刷新缓存。举个例子,即时编译后的 synchronized (new Object()) {},可能等同于空操作,而不会强制刷新缓存。
volatile 字段可以看成一种轻量级的、不保证原子性的同步,其性能往往优于(至少不亚于)锁操作。然而,频繁地访问 volatile 字段也会因为不断地强制刷新缓存而严重影响程序的性能。
在 X86_64 平台上,只有 volatile 字段的写操作会强制刷新缓存。因此,理想情况下对 volatile 字段的使用应当多读少写,并且应当只有一个线程进行写操作。
volatile 字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说,volatile 字段的每次访问均需要直接从内存中读写。
final 实例字段则涉及新建对象的发布问题。当一个对象包含 final 实例字段时,我们希望其他线程只能看到已初始化的 final 实例字段。因此,即时编译器会在 final 字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布(将实例对象写入一个共享引用中)重排序至 final 字段的写操作之前。在 X86_64 平台上,写写屏障是空操作。
新建对象的安全发布(safe publication)问题不仅仅包括 final 实例字段的可见性,还包括其他实例字段的可见性。
当发布一个已初始化的对象时,我们希望所有已初始化的实例字段对其他线程可见。否则,其他线程可能见到一个仅部分初始化的新建对象,从而造成程序错误。
总结
- Java 内存模型通过定义了一系列的 happens-before规则,让程序开发者能够轻易地表达不同线程的操作之间的内存可见性。
- happens-before规则有两种“作用”,一是帮你我这样的程序员正确使用JVM提供的工具编写正确的多线程代码。二是让JVM程序员实现相对应的规则。
- happens-before概念最早在一篇论文中被提出来,在那篇论文中happens-before的语义是因果关系。在现实世界里,如果 A 事件是导致 B 事件的起因,那么 A 事件一定是先于(Happens-Before)B 事件发生的,这个就是 Happens-Before 语义的现实理解。
- Java 内存模型是通过内存屏障来禁止重排序的。对于即时编译器来说,内存屏障将限制它所能做的重排序优化。对于处理器来说,内存屏障会导致缓存的刷新操作,也就是令CPU缓存失效。volatile关键就是使用内存屏障来实现可见性与重排序的。