多线程与高并发12-JMM和底层实现原理

JMM基础-计算机原理

Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。Java1.5版本对其进行了重构,现在的Java仍沿用了Java1.5的版本。Jmm遇到的问题与现代计算机中遇到的问题是差不多的

物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义

根据《Jeff Dean在Google全体工程大会的报告》我们可以看到
image.png

计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的

举个例子(以下案例仅做说明,并不代表真实情况)

如果从内存中读取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加上计算时间一共需要262144*100+250000 = 26 464 400 纳秒,这就存在着数量级上的差异了。

而且现实情况中绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作是基本上是无法消除的(无法仅靠寄存器来完成所有运算任务)。早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的 高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了

image.png

在计算机系统中,寄存器划是L0级缓存,接着依次是L1,L2,L3(接下来是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0寄存器是L1一级缓存的缓存,L1是L2的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集

image.png

在现代CPU上,一般来说L0, L1,L2,L3都集成在CPU内部,而L1还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3

物理内存模型带来的问题

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致

image.png

处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果

处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(步骤A1,B1),然后从内存中读取另一个共享变量(步骤A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(步骤A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果

从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1

缓存一致性协议:如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等,缓存一致行协议多是采用总线锁,锁住总线相应缓存行,使得其他线程无法操作,因此对性能是有一定影响的

伪共享

image.png

  • 缓存行:前面我们已经知道,CPU中有好几级高速缓存。但是CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache的Cache Line大小都是64Bytes。Cache Line可以简单的理解为CPU Cache中的最小缓存单位,今天的CPU不再是按字节访问内存,而是以64字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存

一个缓存行可以存储多个变量(存满当前缓存行的字节数);而CPU对缓存的修改又是以缓存行为最小单位的,在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)

为了避免伪共享,我们可以使用数据填充的方式来避免,即单个数据填充满一个CacheLine。这本质是一种空间换时间的做法
示例代码:

public class T02_CacheLinePadding {
    private static class Padding {
        public volatile long p1, p2, p3, p4, p5, p6, p7;
    }

    private static class T extends Padding {
        public volatile long x = 0L;
    }

    public static T[] arr = new T[2];

    static {
        arr[0] = new T();
        arr[1] = new T();
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(()->{
            for (long i = 0; i < 1000_0000L; i++) {
                arr[0].x = i;
            }
        });

        Thread t2 = new Thread(()->{
            for (long i = 0; i < 1000_0000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start)/100_0000);
    }
}

但是这种方式在Java7以后可能失效,Java8中已经提供了官方的解决方案,Java8中新增了一个注解@sun.misc.Contended

加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended才会生效

Java内存模型(JMM)

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
image.png

Java内存模型带来的问题

可见性问题

image.png

左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中

在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至主内存中。但是什么时候最新的值会被刷新至主内存中是不太确定,一般来说会很快,但具体时间不知

要解决共享对象可见性这个问题,我们可以使用volatile关键字或者是加锁

竞争问题

线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中
image.png

如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。 要解决上面的问题我们可以使用java synchronized代码块

重排序

重排序类型

除了共享内存和工作内存带来的问题,还存在重排序的问题:在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型

1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2)指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3)内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

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上的线程都能读取到这些数据的最新版本。

内存屏障底层分为读屏障和写屏障,JMM又对读写屏障做了各种组合,分为了Loadload、Storestore、LoadStore、StoreLoad

happens-before

happens-before是JVM规定重排序必须遵守的规则

Happens-Before规则

JMM为我们提供了以下的Happens-Before规则:

1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作

2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁

3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读

4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作

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

7 )线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生

volatile详解

volatile的内存语义

  • 通过缓存一致性协议保证变量的线程间可见性
  • 通过内存中的读写屏障避免指令重排序
volatile的内存屏障

在Java中对于volatile修饰的变量,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序问题。

volatile写

image.png

  • storestore屏障:对于这样的语句store1; storestore; store2,在store2及后续写入操作执行前,保证store1的写入操作对其它处理器可见(也就是说如果出现storestore屏障,那么store1指令一定会在store2之前执行,CPU不会store1与store2进行重排序)
  • storeload屏障:对于这样的语句store1; storeload; load2,在load2及后续所有读取操作执行前,保证store1的写入对所有处理器可见(也就是说如果出现storeload屏障,那么store1指令一定会在load2之前执行,CPU不会对store1与load2进行重排序)
volatile读

image.png

在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个loadstore屏障

  • loadload屏障:对于这样的语句load1; loadload; load2,在load2及后续读取操作要读取的数据被访问前,保证load1要读取的数据被读取完毕(也就是说,如果出现loadload屏障,那么load1指令一定会在load2之前执行,CPU不会对load1与load2进行重排序)
  • loadstore屏障:对于这样的语句load1; loadstore; store2,在store2及后续写入操作被刷出前,保证load1要读取的数据被读取完毕(也就是说,如果出现loadstore屏障,那么load1指令一定会在store2之前执行,CPU不会对load1与store2进行重排序

volatile的实现原理

通过unsafe.cpp的lock:前缀,调用系统的总线锁,锁住总线和缓存

锁的内存语义

synchronized的实现原理

Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnterMonitorExit指令来实现

synchronized使用的锁是存放在Java对象头里面
image.png

synchronized锁的状态

一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率

各种锁的比较

image.png

JDK对锁的更多优化措施

逃逸分析

如果证明一个对象不会逃逸方法外或者线程外,则可针对此变量进行优化:

同步消除synchronization Elimination,如果一个对象不会逃逸出线程,则对此变量的同步措施可消除

锁消除和粗化

  • 锁消除:虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上不可能发生共享数据竞争,则会去掉这些锁
  • 锁粗化:将临近的代码块用同一个锁合并起来

消除无意义的锁获取和释放,可以提高程序运行性能

你可能感兴趣的:(jmm,缓存,伪共享,内存屏障,锁)