Java并发编程- 内存模型详解

3/8/2017来源:ASP.NET技巧人气:1361

本文分为四个部分来讲解:

java内存模型的基础, 主要介绍内存模型相关的基本概念; Java内存模型中的顺序一致性, 主要介绍重排序与顺序一致性内存模型; 同步原语, 主要介绍三个同步原语(synchronized, volatile, final)的内存语义及重排序规则在处理器中的实现; Java内存模型的设计, 主要介绍Java内存模型的设计原理, 及其与处理器内存模型和顺序一致性内存模型的关系;

Java内存模型基础

并发编程模型的两个关键问题

线程之间如何通信; 线程之间如何同步;

线程通信机制主要有两种: 共享内存和消息传递. Java的并发采用的是共享内存模型.

Java内存模型(JMM)的抽象结构

在Java中, 所有实例域, 静态域和数组元素都存储在堆内存中, 堆内存在线程之间共享. 局部变量, 方法定义参数, 和异常处理参数不会在线程之间共享, 它们不会有内存可见性问题, 也不受内存模型的影响.

JMM定义了线程和主内存(Main Memory)之间的抽象关系, 属于语言级的内存模型:

线程之间的共享变量存储在主内存中, 每个线程又有一个私有的本地内存(Local Memory, 实际上就是Java虚拟机栈, 寄存器, 处理器高速缓存等), 本地内存中存储了该线程已操作过的共享变量的副本. 本地内存是JMM的一个抽象概念, 并不真实存在, 因为它涵盖了缓存, 写缓冲区, 寄存器及其他硬件和编译器的诸多优化的集合.

如果线程A和线程B要通信的话, 必须要经历下面两个步骤:

线程A把本地内存更新过的共享变量刷新到主内存中; 线程B到主内存去读取线程A之前已更新过的共享变量.

可以看出, JMM通过控制主内存和每个线程的本地内存(包含缓存, 寄存器等等)之间的交互, 来为Java程序提供内存可见性的保证.

从源代码到指令序列的重排序

重排序主要是为了提高性能, 通常分为三种:

编译器优化的重排序. 原则是在不改变单线程程序语义的前提下, 重新安排语句的执行顺序; 指令级并行的重排序. 在不存在数据依赖性的时候, 处理器可以改变语句对应的机器指令的执行顺序, 甚至并行执行指令; 内存系统的重排序. 由于处理器使用高速缓存和读/写缓冲区, 这使得加载和存储操作看上去可能是在乱序执行.

Java从源代码到最终执行的指令序列, 会依次进行以上三种重排序. 其中1属于编译器重排序, 2和3属于处理器重排序. 重排序会导致内存可见性的问题. JMM通过设定重排序规则, 禁止特定的编译器重排序, 对于处理器重排序, 则是通过插入特定类型的内存屏障(Memory Barriers)指令, 来禁止特定类型的处理器重排序, 以确保在不同编译器和处理器平台下, 始终能为程序员提供一致的内存可见性保证.

内存屏障类型表

注意: 内存屏障要特别注意Store类型的屏障, 每个Store类型的屏障都对应着将线程私有的写缓冲写回到主存的操作, 也就是实现线程间可见性的操作

内存屏障实际上是通过限制单线程内指令的重排序来作用的.

JMM将内存屏障指令分为4种类型:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载先于Load2指令的装载(load2的装载是本线程内部的状态,其他线程的决定不了)
StoreStore Barriers Store1; StoreStore; Store2 确保Store1数据对其它处理器可见(将Store1及之前的Store操作数据刷入主内存中)先于Store2的存储(store2的存储是本线程内部的存储, 其他线程的存储决定不了)
LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据的装载先于Store2的存储(store2的存储是本线程内部的存储, 其他线程的存储决定不了)
StoreLoad Barriers Store1; StoreLoad; Load2 确保Store1数据对其它处理器可见(将Store1及之前的Store操作刷入主内存中)先于Load2的装载(load2的装载发生在线程私有内存内部)

其中, StoreLoad屏障是一个全能屏障, 因为它包含了其他所有屏障的效果, 但是开销大, 因为要把写缓冲区的所有数据全部刷新到内存中.

happens-before简介

在JMM中, 如果一个操作执行的结果要对另一个操作可见(通常指的是数据依赖性), 那么这两个操作之间必须要存在happens-before关系. 主要有以下规则:

程序顺序规则: 单线程中的某操作, happens-before于对其有数据依赖性的操作; 监视器锁规则: 对一个锁的解锁, happens-before于随后对这个锁的加锁; volatile变量规则: 对一个volatile域的写, happens-before于后续对这个域的读(实质上是通过缓存锁定的LOCK信号来实现的); 使所有处理器的相应地址的缓存行失效, 强制重新从共享主存中读取. 而且不允许两个线程同时更改同一个缓存行 传递性: A happens-before B happens-before C, 则 A happens-before C

重排序

重排序遵守一个统一的原则, 就是让重排序后的程序至少能够在单线程的情况下正确运行(意思是在单线程下和重排序前的运行结果相同).

顺序一致性

即所有操作具有全序关系, 是一个理想化的模型. 但是JMM天然并不能保证顺序一致性, 需要通过同步原语(Synchronized, volatile, final)来辅助完成.

volatile的内存语义

volatile作用于一个filed上, 能够确保它的可见性. 例如, 现在有一个filed名为l, 我们定义PRivate volatile long l, 就相当于定义:

private long l; public synchronized long get() { return this.l; } public synchronized set(long l) { this.l = l; }

volatile写-读与内存屏障

从内存语义的角度来说, volatile的写和锁的释放有相同的内存语义; volatile的读与锁的获取有相同的语义.

volatile底层实际上是通过内存屏障的方式来确保了可见性, 以下是volatile附近的内存屏障的情况:

在每个volatile写操作的前面插入一个StoreStore屏障; 在每个volatile写操作的后面插入一个StoreLoad屏障; 在每个volatile读操作后面插入一个LoadLoad屏障; 在每个volatile读操作后面插入一个LoadStore屏障;

实际使用中volatile常用做if或者循环的标识位.

定义成volatile的变量, 能够在线程间保持可见性, 能够被多线程同时读(注意: 内存屏障只是限制了单线程内的语句排序), 但是同时只能被一个线程写.

锁的内存语义

当线程释放锁时, JMM会把该线程对应的本地内存中的共享变量刷新到主内存中去; 当线程获取锁时, JMM会把该线程对应的本地内存置为无效, 临界区代码必须从主内存重新读取共享变量;

在底层的实现上

在锁的释放上, 公平锁和非公平锁最后都需要写一个volatile变量state; 在锁的获取时, 公平锁会读volatile变量, 非公平锁会用CAS更新volatile变量.

所以锁的释放与volatile的写, 锁的获取同时具有volatile读写的语义.

concurrent包的实现

concurrent包的基础就是volatile变量的读/写, 以及CAS. CAS兼具volatile变量读写的内存语义

final域的内存语义

final域的写之后, 会插入一个StoreStore屏障 final域的读之前, 会插入一个LoadLoad屏障

只要被构造的对象的引用在构造函数中没有逸出, 那么基于上述两条规则, 就不需要使用同步, 就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值. 如果逸出了, 那么可能会引起重排序, 导致引用在final域初始化之前被其他线程获取, 导致获得未经初始化的final域的值.

happens-before

最实用的三种happens-before

1. volatile写, happens-before后续volatile读;

volatile-happens-before

以下是一个例子:

/** * 下面一段语句, 能够保证1 happens before 4, 也就是无论运行多少次, 结果都输出100 * * <p>所以结论是, volatile变量非常适合作为循环的标识位. * * Created by yihao.cong@Outlook.com on 16-11-4. */ public class VolatileHappensBefore { private volatile static boolean ready = true; private static int number = 1; private static class ReaderThread extends Thread { @Override public void run() { // 3. 子线程读volatile变量 while (VolatileHappensBefore.ready) { // 这里是LoadLoad+LoadStore屏障 } // 这里是LoadLoad+LoadStore屏障 // 4. 子线程读共享变量 out.println(VolatileHappensBefore.number); } } public static void main(String[] args) throws InterruptedException { ReaderThread readerThread = new ReaderThread(); readerThread.start(); Thread.sleep(100); /*下面语句复现的是volatile写读的happens-before规则*/ // 1. 主线程修改共享变量 VolatileHappensBefore.number = 100; // 这里是StoreStore屏障 // 2. 主线程写volatile变量 VolatileHappensBefore.ready = false; // 这里是StoreLoad屏障 // 如此一来能够保证只要volatile变量的修改能够读到, 那么之前的修改一定能够被读到 } }

2. start()规则: 如果线程A执行操作ThreadB.start(), 那么线程AThreadB.start()操作happens-before线程B中的任何操作;

thread-start-happens-before

3. join()规则: 如果线程A执行操作ThreadB.join()并成功返回, 那么线程B中的任意操作happens-before与线程A在ThreadB.join()操作的成功返回.

thread-join-happens-before

单例模式 - 双重检查锁定与延迟初始化

双重检查锁定其实是错误的, 因为可能一个实例还没有被完全初始化, 就返回了引用. 导致外层的检查失效, 使得其他线程获得一个不完整的对象引用.

替代方案1: 使用volatile关键字修饰单例对象, 确保可见性, 不会让写了一半的对象被其他线程读到; 替代方案2: 基于类的初始化方案; 替代方案3(推荐): 使用enum进行单例的初始化;

总结

CAS操作同时具有volatile的读写语义, 也就是之前之后的代码都不能重排序. 底层是通过一个lock指令, 进行缓存锁定, 确保读-改-写操作的原子性. 缓存一致性和缓存锁定说的是同一件事, 都是lock指令造成的缓存锁定(或者说独占仅那一个地址的主存和缓存). 只有volatile写操作或者是CAS(一种内置的复合操作)才会触发lock