当前位置 博文首页 > RtxTitanV的博客:Java并发总结之Java内存模型

    RtxTitanV的博客:Java并发总结之Java内存模型

    作者:[db:作者] 时间:2021-07-07 10:06

    本文主要参考《深入理解Java虚拟机》和《Java并发编程的艺术》对Java内存模型进行简单总结。

    一、CPU和缓存一致性

    1.CPU高速缓存

    为了解决CPU处理速度和内存处理速度不对等的问题,就是在CPU和内存之间增加高速缓存。当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

    而随着CPU处理能力的不断提升,一层缓存渐渐地无法满足要求了,就衍生出多级缓存。按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L3),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。在有了多级缓存之后,程序的执行就变成了,当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。

    单核CPU只含有一套L1,L2,L3缓存,而多核CPU每个核心都含有一套L1,甚至L2缓存,共享L3或者L3和L2缓存。通常单CPU双核的缓存结构如下:
    1

    2.缓存一致性

    在多处理器系统中,每个处理器都有自己的高速缓存,它们共享同一主存(Main Memory),当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,要解决缓存一致性问题,需要各个处理器访问缓存时都遵循缓存一致性协议。

    二、Java内存模型(Java Memory Model,JMM)

    Java内存模型是由Java虚拟机规范定义的一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,实现了Java程序在各种平台下都能达到一致的内存访问效果的规范。从JDK5开始,Java使用新的JSR-133内存模型。

    1.主内存与工作内存

    Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,指包括了实例域、静态域和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享,自然就不会出现竞争问题。

    JMM定义了线程和主内存之间的抽象关系:所有变量都存储在主内存(Main Memory)中,每个线程都有一个私有的工作内存(Working Memory),工作内存中存储了被该线程使用到的存储在主内存中的变量的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量。不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存完成。线程间共享变量值的传递过程

    1. 线程1把工作内存1中更新过的变量刷新到主内存中去。
    2. 线程2到主内存中去读取线程1之前已更新过的变量更新到工作内存2中去。

    JMM通过控制主内存与每个线程的工作内存之间的交互来提供内存可见性保证。Java内存模型图如下:
    2

    2.内存间交互操作

    主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节。Java内存模型定义了以下八种操作来完成,虚拟机实现时必须保证下面提及的每种操作都是原子的、不可再分的(对于double和long类型的变量,load、store、read、write操作在某些平台有例外):

    • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
    • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
    • read(读取):作用于主内存的变量,它把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
    • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
    • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
    • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
    • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
    • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

    如果要把一个变量从主内存中复制到工作内存,那就要顺寻地执行read和load操作,如果把变量从工作内存同步回主内存,就要顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间可以插入其他指令。

    Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则

    • 不允许read和load、store和write操作之一单独出现。
    • 不允许一个线程丢弃它的最近assign操作,即变量在工作内存中改变了之后必须把该变化同步到主内存中。
    • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
    • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
    • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现。
    • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值。
    • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
    • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

    这8种内存访问操作很繁琐,后文会使用一个等效判断原则,即先行发生(happens-before)原则来确定一个内存访问在并发环境下是否安全。

    3.对于long和double类型变量的特殊规则

    Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、 store、read和write这4个操作的原子性,这点就是所谓的long和double的非原子性协定(Nonatomic Treatment of double and long Variables)。

    如果在多线程环境中共享一个没有被volatile修饰的可变long或double类型的变量时,并且对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。

    4.原子性、可见性与有序性

    原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store 和write,我们大致可以认为对long和double类型以外基本数据类型的访问读写是具备原子性的。

    如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorentermonitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是synchronized关键字,因此在synchronized块之间的操作也具备原子性。

    在Java语言中,long和double类型以外的任何类型的变量的写操作都是原子操作。所以对于基本类型和引用类型的写操作(不是指初始化)都是本身具有原子性的。

    可见性(Visibility):可见性是指一个线程对共享变量值的修改,能够及时地被其他线程看到。Java内存模型是通过在一个线程在变量修改后将新值同步回主内存,另一个线程在变量被读取前从主内存读后刷新到工作内存这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

    除了volatile变量外,synchronizedfinal也可以实现内存可见性。synchronized的可见性是由对一个变量执行unlock操作之前,必须把变量值同步回主内存实现的,final的可见性是指被final关键字修饰的字段在构造器中一旦初始化完成,并且没有发生this逃逸(其它线程通过this引用访问到初始化了一半的对象),那么其它线程就能看见final字段的值。

    共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。(共享意味着变量可以被多个线程访问)

    有序性(Ordering):如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

    Java语言提供了volatilesynchronized来保证线程之间操作的有序性,volatile关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则决定了持有同一个锁的同步块只能串行地进入。

    5.先行发生原则(happens-before)

    先行发生原则是Java内存模型中定义的两项操作之间的偏序关系,想要保证一个操作执行的结果对另一个操作可见(无论这两个操作是否在同一个线程中执行),那么这两个操作之间必须满足happens-before关系。如果两个操作之间缺乏happens-before关系,那么JVM可以对它们任意的重排序。

    happens-before原则定义如下

    1. 如果一个操作happens-before于另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
    2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,并不影响先行发生原则的正确性,那么这种重排序并不非法,也就是说JMM允许这种重排序。

    注意:

    • 1是JMM对程序员的承诺,若A happens-before B,则A的结果对B可见并且A的执行顺序在B之前,这只是JMM对程序员的保证。
    • 2是JMM对编译器和处理器重排序的约束规则,JMM遵循只要不改变程序执行结果(指单线程和正确同步的多线程程序),编译器可以随意优化,而程序员也不关心这两个操作是否被重排序,程序员只关心程序执行时的语义不能被改变,即执行结果不能变,所以happens-before和as-if-serial语义本质上一样。
    • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
    • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
    • as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

    Java内存模型中的先行发生关系

    • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
    • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。 这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
    • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
    • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
    • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
    • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
    • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
    • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,可以得出操作A先行发生于操作C的结论。

    注意:

    • 这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从这些规则推导,它们就缺乏happens-before关系,没有顺序性保障,JVM可以对它们随意地进行重排序。
    • 时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

    三、重排序

    编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。重排序分3种类型

    • 编译器优化重排序(编译器重排序):编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    • 指令级并行重排序(处理器重排序):现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
    • 内存系统重排序(处理器重排序):由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

    从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:

    源代码 -> 编译器优化重排序 -> 指令级并行重排序 -> 内存系统重排序 -> 最终执行的指令序列

    1.处理器重排序

    现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作重排序。常见处理器允许的重排序类型的列表

    Load-LoadLoad-StoreStore-StoreStore-Load数据依赖
    sparc-TSONNNYN
    x86NNNYN
    ia64YYYYN
    PowerPCYYYYN
    • sparc-TSO是指以TSO(Total Store Order)内存模型运行时,sparc处理器的特性。
    • x86包括x64及AMD64。
    • 由于ARM处理器的内存模型与PowerPC处理器的内存模型非常类似,本文将忽略它。

    2.内存屏障

    为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM对内存屏障指令的分类

    屏障类型指令示例说明
    LoadLoad BarriersLoad1; LoadLoad; Load2确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
    StoreStore BarriersStore1; StoreStore; Store2确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
    LoadStore BarriersLoad1; LoadStore; Store2确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
    StoreLoad BarriersStore1; StoreLoad; Load2确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

    StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。

    3.数据依赖性

    如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分三种类型

    名称代码示例说明
    写后读a = 1; b = a;写一个变量之后,再读这个位置
    写后写a = 1; a = 2;写一个变量之后,再写这个变量
    读后写a = b; b = 1;读一个变量之后,再写这个变量

    上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

    注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

    4.as-if-serial语义

    as-if-serial语义:编译器和处理器为了提高并行度,不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

    为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。例如以下示例:

    double pi = 3.14; // A
    double r = 1.0; // B
    double area = pi * r * r; // C
    

    示例的数据依赖关系如下:
    3
    根据图中的依赖关系可知,在最终的指令序列中,C不能重排序在A和B之前,但A和B之间没有依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该示例的两种执行顺序:
    4
    as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题,即读线程不一定能看到写线程写入的值。

    5.重排序对多线程的影响

    在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中重排序可能会破坏多线程程序的语义,改变程序的执行结果,产生内存可见性问题。例如以下代码:

    class ReorderExample {
        int a = 0; 
        boolean flag = false;
        public void writer() {
            a = 1;                   // 1
            flag = true;             // 2
        }
        Public void reader() {
            if (flag) {             // 3
                int i = a * a;      // 4
    	        .....
    	    }
        }    
    }
    

    示例中flag用来标识a是否被写入,假设有两个线程A和B,A执行writer()方法,B执行reader()方法,由于操作1和2,操作3和4之间没有数据依赖性,因此操作1和2,3和4之间可以重排序,如果操作1和2发生重排序,可能会出现以下执行结果:
    5
    线程A先执行了操作2写入了变量flag,线程B执行操作3,由于条件为真,线程B将执行操作4,会读取变量a,但此时变量a还未被线程A写入。如果操作3和4发生重排序,可能会出现以下执行结果:
    6
    操作3和4之间存在控制依赖关系,会影响指令序列执行的并行度,编译器和处理器会采取猜测执行的方式克服控制相关性对并行度的影响。以上示例线程B可以提前读取变量a并将计算结果临时保存到重排序缓冲中,当操作3条件为真时,在将结果写入变量i中。根据以上两种情况的结果来看,重排序破坏了多线程程序的语义。

    cs