java并发(3)内存模型

作者: JimmieYang | 来源:发表于2020-01-29 13:36 被阅读0次

    基础知识

    并发编程引发的问题

    并发编程需要关注两个问题.

    1. 线程之间是如何通信的?
    2. 线程之间是如何同步数据的?

    在现有的通信机制中, 有两大类. 共享内存消息传递.

    进程之间的通信, 主要是通过 消息传递(如各种IPC方式), 而线程之间的通信主要是通过共享进程内存来实现的.

    通信问题解决了,那多线程数据是如何同步的呢? 这就要交给我们的 Java内存模型来解决.

    为什么需要同步?

    需要同步是因为,多线程并发的过程中,可能出现各个线程中 数据不一致的情况.

    为什么会出现这种情况呢? 主要有两方面的原因.

    1. 在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序,导致程序执行对其他线程表现不一致.

    2. JMM中本地变量和主存变量之中,刷新可能存在时间差,导致数据不一致.

    理想的模型-顺序一致性内存模型

    顺序一致性内存模型是一个被理想化的理论参考模型, 它为程序提供了机枪的内存可见性保证.它有两大特性:

    1. 一个线程中的所有操作必须按照程序的顺序执行.
    2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序.在顺序一致性内存模型中,每个操作都必须是原子执行且立即对所有线程可见.

    这种模型,需要避免处理器优化和编译器重排序等,并且需要实时操作内存数据, 会造成程序执行效率低下.
    是一种理想而不现实的模型.

    Java内存区域

    在介绍内存模型之前,需要先了解一下 Java中的内存区域划分. 如下图:

    详细介绍可以查看 Java运行时数据区详解 这边文章.

    Java运行时内存区域.png

    硬件内存架构

    硬件内存模型.png

    上图是CPU与内存交互的简易图. 计算机在执行程序的时候,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存(RAM).

    由于CPU的运行速度和内存不是一个级别的,如果CPU直接从内存中读写数据,会导致CPU每次操作内存可能需要耗费许多额外的等待时间. 于是才有了CPU缓存, 缓存的速度是介于内存和CPU之间.

    如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。

    寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能.

    在多核架构,一个核修改主存后,其他核心并不知道数据已经失效,继续傻傻的使用,轻则数据计算错误,重则导致死循环、程序崩溃等。

    缓存一致性协议

    多核CPU硬件架构厂商,设计之初就预测到多线程操作数据不一致的问题,因此出现了——缓存一致性协议
    不同的CPU硬件生产厂商,具体的实现不一样。Intel的MESI协议最出名。
    在MESI协议中,每个Cache line有4个状态,可用2个bit表示,它们分别是:

    状态 描述
    M(Modified) 这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
    E(Exclusive) 这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。
    S(Shared) 这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。
    I(Invalid) 这行数据无效。

    在MESI协议中,每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作。每个Cache line所处的状态根据本核和其它核的读写操作在4个状态间进行迁移

    Java内存模型

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

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

    JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,是一种抽象的概念.

    JMM.png

    对于线程内部的本地数据, 是不受JMM影响的,也不会存在内存可见性问题.
    JMM主要是针对 多线程中,对共享内存数据进行可见性约束的规则.

    JMM数据传递.png

    本地内存A和本地内存B由主内存中共享变量x的副本。假设初始时,这3个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

    从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

    JMM 与硬件模型

    多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,都可以存在于 CPU的寄存器,CPU缓存 或者是 RAM中,他们之间是相互交叉的.

    JMM与硬件模型.png

    重要的概念

    原子性 : 表示不可被中断的一个或一系列操作.一旦开始,就一直运行到结束,中间不会有任何线程切换(context switch)。

    可见性 : 是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值.

    有序性 : 由于指令的执行,会经过编译器和处理的重排序,有序性是指从指令上的执行结果上看,指令的执行顺序是有序的.根据as-if-serial语义,单线程中,程序的结果不能被改变.在多线程并发中, 提供 happens-before规则来支持有序性.

    深入理解JMM

    从上面的内容我们可以了解到 JMM中,可能存在的两个问题.

    1. 每个线程存在着工作内存,工作内存中存放着主内存中共享变量的副本. 副本刷新主内存可能存在时间差,导致并发时出现数据不一致问题.

    2. 处于优化,指令重排可能会对数据无关的指令打乱执行顺序,会对多线程传递产生各线程中数据不一致的问题.

    而JMM就是要解决这两个问题, 来实现多线程中的数据一致性.

    指令重排

    重排序主要分为3中类型.

    1. 编译器优化的重排序.

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

    1. 指令级并行的重排序.

    由于现代处理器都是多核的, 处理可能采用指令级并行技术来讲多条指令重叠执行,如果不存在数据依赖性,则处理器可能改变语句对应的执行顺序.

    1. 内存系统的重排序.

    由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是乱序执行的.

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

    上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

    JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

    数据依赖性

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

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

    上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

    前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

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

    意思是,大部分情况下,并发的重排序,需要开发者根据 JMM规则和Java提供的相关关键字功能手动实现线程安全.

    重排序引发的问题

    public class ReOrderedTest {
        private static int x = 0, y = 0;
        private static int a = 0, b = 0;
    
        public static void main(String[] args) throws InterruptedException {
            int i = 0;
            for (; ; ) {
                i++;
                x = 0;
                y = 0;
                a = 0;
                b = 0;
                Thread A = new Thread(() -> {
                    //由于线程A先启动,下面这句话让它等一等线程B. 读着可根据自己电脑的实际性能适当调整等待时间.
                    shortWait(100000);
                    a = 1; // 1
                    x = b; // 2
                });
    
                Thread B = new Thread(() -> {
                    b = 1; // 3
                    y = a; // 4
                });
                A.start();
                B.start();
                A.join();
                B.join();
                String result = "第" + i + "次 (" + x + "," + y + ")";
                if (x == 0 && y == 0) {
                    System.err.println(result);
                    break;
                }
            }
        }
        private static void shortWait(long interval) {
            long start = System.nanoTime();
            long end;
            do {
                end = System.nanoTime();
            } while (start + interval >= end);
        }
    }
    

    这个例子中, 按我们正常的代码逻辑,是不可能跳出死循环的.

    只有当 代码2先于代码1执行, 且代码4先于代码3执行 才能退出循环.
    而真实运行的结果是, 当运行次数足够多时,满足下图的执行熟顺序后,是可以跳出循环的.

    重排序问题.png

    而上诉程序,运行一定时间,就会退出循环. 这说明,重排列在某种情况下,会影响并发过程中的可见性.

    内存屏障

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

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

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

    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
    

    上诉代码中, A与B不存在依赖关系, A和C 以及B与C 存在依赖关系.
    所以 A与B可以被重排, A与C,B与C不可以被重排.

    那么重排后的顺序可能是: 1. A->B->C2. B->A->C.

    as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

    happens-before规则

    从JMM设计者的角度,在设计JMM时,需要考虑两个关键因素。

    • 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。

    • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

    由于这两个因素互相矛盾,设计JMM时的核心目标就是找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。

    JMM遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

    由此引入了 happens-before相关的规则.

    定义

    1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

    2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

    1,2的定义看似矛盾,其实是针对不同的群体来说的.

    1 是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。

    2 是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)

    规则

    • 程序顺序原则:一个线程内保证语义的串行性(as-if-serial).对于单线程来讲,必须保证重排后的结果与重排前一致。
    • volatile规则:volatile变量的写,先发生于后续对这个变量的读.这保证了volatile变量的可见性.
    • 监视锁规则:对于一个锁的解锁,先发生于随后对这个锁的加锁. 否则随后的加锁将会失败.
    • 传递性:A先于B,B等于C,那么A必然先于C.
    • 线程启动规则:Thread对象的start()方法先发生于此线程的其他任意动作。
    • 线程终止规则:线程的所有操作都先发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
    • 线程中断规则:对线程interrupt()方法的调用先发生于被中断线程的代码检测到中断时事件的操作。
    • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先发生于它的finalize()方法的开始

    通过规则我们可以看出, JMM并大部分情况下不会自动帮我们处理多线程并发的可见性问题. 而是需要程序员通过 JMM定义的规则来实现 多线程同步和多线程数据一致性的效果.

    总结

    JMM是对java多线程通信的一种抽象模型,它是一种规则与规范,并不是真实存在的.我们可以通过JMM来了解Java中如何实现安全的线程同步和通信.

    • JMM规定了一系列的规则, 如 as-if-serial,happens-before语义等来规范程序员和编译器以及处理器在模型中的各自职责.
    • 而JMM底层通过 一定的 禁止编译器指令重排, 以及插入内存屏障指令来禁止处理器指令重排等手段,来保证在程序员正确同步多线程的情况下,保证多线程中的数据可见性.
    • 最后JMM对编译器和处理器的限制是要尽可能地放松,不能全部禁止指令重排,它遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

    待续

    volatile , synchronized, final 关键字 在多线程同步中的作用.

    引用

    1. Java并发编程的艺术
    2. 从多核硬件架构,看Java内存模型
    3. 全面理解Java内存模型(JMM)及volatile关键字

    相关文章

      网友评论

        本文标题:java并发(3)内存模型

        本文链接:https://www.haomeiwen.com/subject/zlmmthtx.html