一、JMM 基础-计算机原理
1、概念
Java 内存模型即 Java Memory Model,简称 JMM。JMM 定义了 Java 虚拟机 (JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM 是隶属于 JVM 的。Java1.5 版本对其进行了重构,现在的 Java 仍沿用了 Java1.5 的版本。Jmm 遇到的问题与现代计算机中遇到的问题是差不多的。
2、计算机结构辨析
根据《Jeff Dean 在 Google 全体工程大会的报告》我们可以看到
算能快速进行,当运算结束后再从缓存同步回内存之中,这样 处理器就无须等待缓慢的内存读写了。计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的。
(以下案例仅做说明,并不代表真实情况。)
如果从内存中读取 1M 的 int 型数据由 CPU 进行累加,耗时要多久?
做个简单的计算,1M 的数据,Java 里 int 型为 32 位,4 个字节,共有 1024*1024/4 = 262144 个整数 ,则 CPU 计算耗时:262144 0.6 = 157 286 纳秒, 而我们知道从内存读取 1M 数据需要 250000 纳秒,两者虽然有差距(当然这个 差距并不小,十万纳秒的时间足够 CPU 执行将近二十万条指令了),但是还在 一个数量级上。但是,没有任何缓存机制的情况下,意味着每个数都需要从内存 中读取,这样加上 CPU 读取一次内存需要 100 纳秒,262144 个整数从内存读取 到 CPU 加上计算时间一共需要 262144100+250000 = 26 464 400 纳秒,这就存在 着数量级上的差异了。
而且现实情况中绝大多数的运算任务都不可能只靠处理器“计算”就能完成, 处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个 I/O 操作是 基本上是无法消除的(无法仅靠寄存器来完成所有运算任务)。早期计算机中
cpu 和内存的速度是差不多的,但在现代计算机中,cpu 的指令速度远超内存的 存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所 以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高 速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复 制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样 处理器就无须等待缓慢的内存读写了。
在计算机系统中,寄存器划是 L0 级缓存,接着依次是 L1,L2,L3(接下来 是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本 也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都 可以看做是更下一层的缓存,即:L0 寄存器是 L1 一级缓存的缓存,L1 是 L2 的 缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一 层的数据的子集
二、物理模型带来的问题
-
缓存一致性问题
上面说过,在内存和cpu之间存在cache,这就可能导致多个线程对统一内存数据进行cache,最终都去刷新主存数据,那我们以谁的数据为准呢。为了 解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根 据协议来进行操作,这类协议有 MSI、MESI(Illinois Protocol)、MOSI、Synapse、 Firefly 及 Dragon Protocol 等。 -
伪共享问题
前面我们已经知道,CPU 中有好几级高速缓存。但是 CPU 缓存系统中是以 缓存行(cache line)为单位存储的。目前主流的 CPU Cache 的 Cache Line 大小都 是 64Bytes。Cache Line 可以简单的理解为 CPU Cache 中的最小缓存单位,今天的 CPU 不再是按字节访问内存,而是以 64 字节为单位的块(chunk)拿取,称为一个 缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存。
一个缓存行可以存储多个变量(存满当前缓存行的字节数);而 CPU 对缓 存的修改又是以缓存行为最小单位的,在多线程情况下,如果需要修改“共享同 一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。
上图虽然看起来x、y分别是独立存在与线程1、2的,但是都被缓存在同一缓存行中,这样就会带来2个问题:1、线程1操作X和线程2操作Y都要进行对缓存行进行同步。2、当有其他线程来读取X、Y的时候,缓存行的数据就会缓存失效,就要去内存中读取,这是非常耗时的。
Java解决伪共享一般使用数据填充,jdk8提供了@sun.misc.Contended来避免伪共享问题。下面用code演示伪共享问题
package cn.enjoy.controller.weigongxiang;
/**
* 类说明:伪共享
*/
public class FalseSharing implements Runnable
{
public final static int NUM_THREADS =
Runtime.getRuntime().availableProcessors();
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
/*数组大小和CPU数相同*/
// private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
private static VolatileLongPadding[] longs = new VolatileLongPadding[NUM_THREADS];
//private static VolatileLongAnno[] longs = new VolatileLongAnno[NUM_THREADS];
static{
/*将数组初始化*/
for (int i = 0; i < longs.length; i++){
longs[i] = new VolatileLongPadding();
}
}
public FalseSharing(final int arrayIndex){
this.arrayIndex = arrayIndex;
}
public static void main(final String[] args) throws Exception{
final long start = System.nanoTime();
runTest();
System.out.println("duration = " + (System.nanoTime() - start));
}
private static void runTest() throws InterruptedException{
/*创建和CPU数相同的线程*/
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++){
threads[i] = new Thread(new FalseSharing(i));
}
for (Thread t : threads){
t.start();
}
/*等待所有线程执行完成*/
for (Thread t : threads){
t.join();
}
}
/*访问数组*/
public void run(){
long i = ITERATIONS + 1;
while (0 != --i){
longs[arrayIndex].value = i;
}
}
public final static class VolatileLong {
public volatile long value = 0L;
}
// long padding避免false sharing
// 按理说jdk7以后long padding应该被优化掉了,但是从测试结果看padding仍然起作用
public final static class VolatileLongPadding {
public long p1, p2, p3, p4, p5, p6, p7;
public volatile long value = 0L;
volatile long q0, q1, q2, q3, q4, q5, q6;
}
/**
* jdk8新特性,Contended注解避免false sharing
* Restricted on user classpath
* Unlock: -XX:-RestrictContended
*/
@sun.misc.Contended
public final static class VolatileLongAnno {
public volatile long value = 0L;
}
}
可以看到使用数据填充之后的访问效率要高3倍左右
三、java内存模型和导致的问题
java内存模型
从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间 的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内 存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地 内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存 器以及其他的硬件和编译器优化。
导致的问题
1、可见性问题
左边 CPU 中运行的线程从主存中拷贝共享对象 obj 到它的 CPU 缓存,把对 象 obj 的 count 变量改为 2。但这个变更对运行在右边 CPU 中的线程不可见,因 为这个更改还没有 flush 到主存中。
在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获 取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同 样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至 主内存中。但是什么时候最新的值会被刷新至主内存中是不太确定,一般来说会 很快,但具体时间不知。
要解决共享对象可见性这个问题,我们可以使用 volatile 关键字或者是加锁。
2、竞争问题
如果这两个加 1 操作是串行执行的,那么 Obj.count 变量便会在原始值上加 2,最终主存中的 Obj.count 的值会是 3。然而图中两个加 1 操作是并行的,不管 是线程 A 还是线程 B 先 flush 计算结果到主存,最终主存中的 Obj.count 只会增 加 1 次变成 2,尽管一共有两次加 1 操作。 要解决上面的问题我们可以使用 java synchronized 代码块。
3、重排序问题
除了共享内存和工作内存带来的问题,还存在重排序的问题:在执行程序时, 为了提高性能,编译器和处理器常常会对指令做重排序。
重排序分 3 种类型。
- 1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以 重新安排语句的执行顺序。
- 2)指令级并行的重排序。现代处理器采用了指令级并行技术 (Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依 赖性,处理器可以改变语句对应机器指令的执行顺序。
- 3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和 存储操作看上去可能是在乱序执行。
数据依赖性
不能重排序存在数据依赖的操作。
很明显,A 和 C 存在数据依赖,B 和 C 也存在数据依赖,而 A 和 B 之间不存 在数据依赖,如果重排序了 A 和 C 或者 B 和 C 的执行顺序,程序的执行结果就 会被改变。
很明显,不管如何重排序,都必须保证代码在单线程下的运行正确,连单线 程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个 as-if-serial 的概念。
as-if-serial
as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行 度),(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必 须遵守 as-if-serial 语义。
控制性依赖
在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执 行结果。
内存屏障
Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类 型的处理器重排序,从而让程序按我们预想的流程去执行。
1、保证特定操作的执行顺序。
2、影响某些数据(或则是某条指令的执行结果)的内存可见性。
编译器和 CPU 能够重排序指令,保证最终相同的结果,尝试优化性能。插 入一条 Memory Barrier 会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
Memory Barrier 所做的另外一件事是强制刷出各种 CPU cache,如一个 Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此, 任何 CPU 上的线程都能读取到这些数据的最新版本。
jdk提供了4中内存屏障,StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他 3 个屏障的 效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支 持)。
happens-before
happens-before 关系保证正确同步的多线程程序 的执行结果不被改变。在 JMM 中,如果 一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系 。
四、volatile 详解
volatile 的内存语义
内存语义:可以简单理解为 volatile,synchronize,atomic,lock 之类的在 JVM
中的内存方面实现原则。
volatile 写的内存语义如下:
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新 到主内存。
volatile 读的内存语义如下:
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下 来将从主内存中读取共享变量
volatile 内存语义的实现
volatile 的实现原理
通过对 OpenJDK 中的 unsafe.cpp 源码的分析,会发现被 volatile 关键字修饰
的变量会存在一个“lock:”的前缀。
Lock 前缀,Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock
会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。
同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写 回内存的操作会使在其他 CPU 里缓存了该地址的数据无效。
在具体的执行上,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。在 Lock 锁住总线的时候, 其他 CPU 的读写请求都会被阻塞,直到锁释放。
final 的内存语义
在构造线程的类时,我们有种方式就是让类中所有的成员变量都不可变,利 用的就是 final 关键字,那么这个 final 为何可以做到呢?重排序这种优化动作对 构造方法,一样也是存在的。这就说明,一个成员变量加了 final 关键字后,JMM 一定是做了相关处理的。
-
在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
—写 final 域的重排序规则可以确保在对象引用为任意线程可见之前, 对象的 final 域已经被正常的初始化了,而普通域不具有这样的保证。 -
初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两 个操作之间不能重排序。读 final 域的重排序规则可以确保在读一个对象的 final 域之前,一定 会先读包含这个 final 域的对象的引用。
-
final 域为引用类型
能保证在读取引用的成员之前,一定是初始化了的。 -
final 引用不能从构造函数内逃逸
final 语义的实现
会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。
读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个
LoadLoad 屏障
锁的实现
锁的内存语义
当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主 内存中。
当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被 监视器保护的临界区代码必须从主内存中读取共享变量。
synchronized 的实现原理
无锁状态,偏向锁状态,轻量级锁状态和重量级锁的转换和实现流程
java中锁的实现是通过Monitor对象来进行操作的。java对象头中的MarkWord存储的是对象的hashCode和锁的一些信息。
偏向锁:偏向于第一个访问锁的线程,如果在运行过程中, 同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步 的,减少加锁/解锁的一些 CAS 操作(比如等待队列的一些 CAS 操作),这种 情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占 锁,则持有偏向锁的线程会被挂起,JVM 会消除它身上的偏向锁,将锁恢复到标 准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的 运行性能。
1、查看是否是可偏向的、如果是的话,检测线程id是否是当前线程,如果是则执行同步代码块
2、否则通过CAS去竞争锁,竞争成功则将线程改为当前线程,执行同步块
3、竞争失败,当到达全局安全点 (safepoint)时获得偏向锁的线程被挂起,偏向锁会升级为轻量级锁、然后阻塞的线程会继续执行。(撤销偏向锁的时候会导致 stop the word)
偏向锁的适用场景:始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去 执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级 为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致 stop the word 操 作。
轻量级锁
1、线程会将对象中的mark word拷贝一份放到自己的工作内存(Lock Record)
2、然后通过CAS去将mark word指向本地的Lock Record。成功则就获取了锁。继续执行同步块
3、失败则会进行自旋操作,重复尝试获取锁。
4、失败多次之后,轻量级锁就会膨胀为重量级锁。重量级线程指针指向竞争线程,竞 争线程也会阻塞,等待轻量级线程释放锁后唤醒他。锁标志的状态值变为“10”, Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也 要进入阻塞状态。
各个锁的优点
JDK 对锁的更多优化措施
- 逃逸分析
如果证明一个对象不会逃逸方法外或者线程外,则可针对此变量进行优化:
同步消除 synchronization Elimination,如果一个对象不会逃逸出线程,则对此变 量的同步措施可消除。 - 锁消除和粗化
锁消除:虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上不可 能发生共享数据竞争,则会去掉这些锁。
锁粗化:将临近的代码块用同一个锁合并起来。
消除无意义的锁获取和释放,可以提高程序运行性能。