转载 https://www.jianshu.com/p/34e7cc0fd53d
java 内存模型
摘要
文章内容有并发、内存模型、重排序、内存屏障、happens-before规则、as-if-serial语义、顺序一致性内存模型、volatile、锁、final。
并发
并发需要处理的两个关键问题:线程之间通信和同步
通信:是指线程之间以何种机制来交换信息;在命令式编程中,通信机制有两种,共享内存和消息传递。
同步:是指程序用于控制不同线程之间操作发生相对顺序的机制。
Java的并发采用的是共享内存模型,线程之间的通信总是隐式的进行。
java 内存模型
Java 线程之间的通信由Java 内存模型(简称JMM)控制。
主内存(main memory):存储了线程之间共享变量;是指堆、方法区。
本地内存(local memory):存储了该线程以读/写共享的副本;是一个抽象概念(并不真实存在),它涵盖了缓存、写缓冲区、寄存器以及其它的硬件和编译器优化。
重排序
重排序分三种类型:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语义的执行顺序。
- 指令级并行的重排序:现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
内存屏障指令
内存屏障指令:为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。
内存屏障指令分四类:
happens-before规则
happens-before:在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这俩操作之间必须存在happens-before关系。
happens-before规则:
- 程序顺序规则:一个线程中的每一个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器的解锁,happens-before 于任意后续对监视器的加锁。
- volatile变量规则:对于一个volatile域的写,happens-before 于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B, 且 B happens-before C ,那么A happens-before C。
注意,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。
数据依赖性
数据依赖性:如果两个操作访问同一变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
数据依赖性分三类:
- 写后读:a=1,b=a;
- 写后写:a=1,a=2;
- 读后写:a=b,b=1;
编译器和处理器在重排序时,会遵守数据依赖性。
as-if-serial 语义
as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
顺序一致性内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
volatile
volatile变量自身特性:
- 可进行:对于一个volatile变量的读,总能看到(任意线程)对于这个volatile变量的最后写入。
- 原子性(微弱):对于任意的单个volatile变量的du/写具有原子性,但是类似volatile++这种复合操作(两个操作,读取再写入)不具有原子性的。(文章中也举例测试过)
- 禁止重排序:为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序。
volatile内存语义:
- volatile写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
- volatile读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
下面是JMM针对编译器制定的volatile重排序规则表:
基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入StoreLoad屏障
- 在每个volatile读操作的后面插入一个LoadLoad屏障
- 在每个volatile读操作的后面插入一个LoadStore屏障
volatile 和 synchronize对比
在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。
volatile仅仅保证对单个volatile变量的读/写具有原子性;而synchronize锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。
锁
锁是java 并发编程中最重要的同步机制
锁的内存语义:
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
ReetrantLock锁:它的实现依赖于java同步器AbstractQueueSynchronizer(简称AQS),而AQS使用一个整型的volatile变量(命名为state)来维护同步状态。
ReetrantLock分为公平锁 和 非公平锁:
- 公平锁:是通过volatile实现同步的。公平锁在释放锁的最后写volatile变量state;在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变的对获取锁的线程可见。
- 非公平锁;通过CAS实现的,CAS是compareAndSet()方法的简称,意思是compare and swap。CAS实际上调用的JNI函数,也就是CAS依赖于本地方法实现。以Intel来说,对于CAS的JNI实现函数,它保证:(1)禁止该CAS之前和之后的读和写指令重排序。(2)把写缓冲区中的所有数据刷新到内存中。这两点具有内存屏障效果,实现了同volatile读和volatile写的内存语义一样的效果。
final
对于final域,编译器和处理器要遵守两个重排序规则:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
final域是基础数据类型时的重排序规则:
- 写final域:JMM禁止编译器把final域的写重排序到构造函数之外。编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
- 读final域:在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
final域是引用类型时的重排序规则:
- 写final域:构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
为什么final引用不能从构造方法内”逸出“?
前面我们提到过,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。
内存模型总结
JMM保证:如果程序是正确同步的,程序的执行将具有顺序一致性 。
JMM设计
从JMM设计者的角度来说,在设计JMM时,需要考虑两个关键因素:
- 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型(程序尽可能的顺序执行)来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化(对程序重排序,做尽可能多的并发)来提高性能。编译器和处理器希望实现一个弱内存模型。
JMM设计就需要在这两者之间作出协调。JMM对程序采取了不同的策略:
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不作要求(JMM允许这种重排序)。
吐槽,64位的long/double型具有原子性,真的这样吗?!
JMM不保证对64位的long型 和 double型变量读/写 操作具有原子性,是这样的吗?!
对于32位机器:当JVM在32位处理器上运行时,会把一个64位long/double型变量的写操作分成两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对于64位变量的写不具有原子性。
对于64位机器:(对于这个我查阅了资料)在一些32位机器上,如果要求对64位数据的写具有原子性,会有较大的开销,为了照顾这种机器,java语言规范鼓励但不强求JVM对64位long/double变量的写具有原子性。但是在查阅的过程中各有己见,我个人采纳了( 中 码农甲
的回答,也是最近回答2017-09-09),他的结论是在目前intel平台的x64 hotspot jvm中long/double型的访问是原子的(这正是我的环境),他在回答里链接 和 都详细地讨论和解释了64位读写long/double是原子的。
参考文献
完结。