• 并发概念模型:JMM(JAVA内存模型)


    一.简介

        在计算机执行程序时,每条指令都是在CPU中执行的,而执行指令的过程中必定会涉及到内存中数据的读取和写入,以往的计算机运行过程中数据都是存放在主内存中的,由于CPU的执行速度非常的快,导致相比数据的读取和写入的速度显得非常的慢这样会导致CPU执行效率也会大大的降低,由于这些因素的存在,慢慢的出现了CPU的高速缓存。当程序在运行过程中,会将运算需要的数据从主内存复制一份到高速缓存中,后续直接通过高速CPU缓存对数据进行写和读这样就可以大大的提高了程序的运行速度,凡事都有好有坏,在提升速度的同时也引发了并发的问题。

    1 i= i +1;

    这段代码相信是个码农都写过,自增的操作。在单线程中这段代码没有任何的问题,每次增加都可以获取增加后的数字。但是在多线程中,比如三个线程执行这句代码,此时主内存中的值为1,每个线程内部的CPU高速缓存中的值也是1,这时线程1进行自增操作,值会变成2,然后写回高速缓存中但没写回主内存中,但是此时线程2执行了,线程2中的高速缓存中的值还是1,这时线程2就拿着自己的1又进行了一次自增的操作还是获取到了2,写回自己的高速缓存中,导致明明增加了两次但最终获取的值可能只增加了一次。这就是缓存不一致的问题。而上面的主内存与CPU高速缓存之间的关系就是下面要讲的JMM模型。

    二、JMM(JAVA内存模型)

        其实没有所谓的真正的Java内存模型,JMM也是大牛们进行抽象出来的一个模型使得开发者们能更好的理解Java并发中线程之间的通信过程。那么大牛们抽象出来的JMM又是什么样子的?

        在Java中,所有的实例、静态域和数组都是存放在堆内存中的,堆内存又是线程之间共享的。局部变量等不会在线程中共享也就是每个线程独立拥有,这些变量不会存在并发问题也不会受到JMM的影响。Java线程之间的通信是由JMM进行控制的,JMM决定了一个线程对共享变量的写入何时对另外一个线程可见。也就上面很经典的,线程之间的共享变量存在在主内存中,当线程需要使用的时候就会自动去复制一份到线程本地的缓存中,所有的读和写后续都会操作的是这个缓存中的数据。下面可以看一下网络上介绍的JMM抽象模型图:

    上图很清楚的展示了线程之间通信的关系:如果线程A想要和线程B进行通信,首先线程A中的共享变量副本改变以后从本地内存中刷新回主内存中。其次线程B想读取到线程A中改变的值就必须重新去主内存中读取一份数据将自己本地的数据覆盖掉这样后续操作的就是线程A中改变过后的变量。下面展示一下整个执行的过程:

    从一开始的例子中可以总结出可能发生两种情况:

      第一种:线程A进行了自增操作,把值写入本地内存A中,还没来得及刷新回主内存中此时B已经进行了操作,B拿到的是自己本地内存B中的值导致自增两次但是结果只加了一次,也就导致了线程不安全。

      第二种:线程A进行了自增操作,把值写入本地内存A中,同时将新的值刷新回主内存中,此时B获取主内存的数据发现已经自增过一次就调用A线程返回的最新结果再进行一次调用,运行的结果正常。

    其实整体的流程很简单就是A变更了值通知了B,B知道你变了就用最新的值进行下一次的操作。只不过这个过程中必须经过主内存。

    上面是对JMM的一个抽象模型的阐述以及具体的在底层的线程通信的一个流程。也就说想保证并发安全就必须保证一个共享变量每次的变化都必须被其他线程看到。那么满足安全又有什么要求呢?下面就是一些满足并发安全特性的需求:

      第一:原子性保证

        什么是原子性,俗一点的来说就是整个操作一步就能完成无法再继续拆分成多步骤去进行。精确的来说就是一个过程要么大家一起执行,谁如果中途退出了那么大家就都退出了。

         一个很经典很广为流传的例子就是生活中的银行转账,我转给朋友100元这个操作涉及到了很多,首先我的账户肯定会减少100,光我的账户减少了100没用啊,朋友的账户得多出100元,不然等于这100白白的被银行吃了不是吗?这个过程就是一个原子性操作,要么我的账户扣钱,朋友账户多钱,要么就我得账户不扣钱,朋友的账户不多钱。这两个操作就组成了一组原子性操作保证了安全性。

     

      第二:可见性保证

        什么是可见性,上面JMM的解释看完就一目了然了,无非就是一个共享变量在某个线程的改变下发生了变化,这个变化对于其它所有的线程必须可见。

        可见性也很好理解,比如下面这一段代码

    1 int i =0;
    2 i = 10;
    3 //上面线程1执行,下面线程2执行
    4 j = i;

    首先给i进行赋值为0,其次更新其值为10,然后将i的值赋值给j,这个过程中假设线程1开始执行初始化赋值以及将10赋值给i,在这个过程中线程1还没来得及将值刷新回主内存中,此时线程2开始执行,那么线程2读到的可能只是其初始值0,这样就是一个线程不安全的结果。

     

      第三:有序性保证

        有序性是三个保证中最难理解的那一个,因为这个涉及到了处理器和编译器层的重排序。先说什么是有序性,单线程中,代码的执行都是有先后顺序的,但是有了重排序以后呢,在执行结果不变的情况下编译器会对代码进行一个重排序的优化。这边就先了解一下这也是影响程序并发安全的一个点,下面再谈重排序。

    在并发中,保证了以上的三个特性,你的程序就是一个并发安全的程序。而开发人员开发并发项目也就是不断的在维护上面的三个特性来保证自己的程序并发安全。

    三、重排序

        上面谈到了重排序,重排序是编译器和处理器在指令级别做出的一种优化方式,重排序一共有三类:

          第一种:编译器优化的重排序。编译器在不改变单线程语义的前提下,可以重新确定程序的执行顺序。

          第二种:指令级别并行重排序。现代处理器采用了指令级并行技术将多条CPU指令重叠执行,如果不存在数据依赖性以及某些先天的顺序执行操作那么处理器就可以改变程序的执行顺序。

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

    也就是说当我们开发完,部署上线,程序从源代码执行过程中到最终执行完成都会经历三个阶段的重排序:

    上面的这些重排序可能会导致多线程程序中出现内存可见性问题。当然也不是随意进行重排序的,处理器和编译器在满足不破坏程序原有的执行结果已经语义的情况下才会进行重排序,这种重排序在单线程过程中不会造成程序出现问题,但是在多线程情况下就会有很大的影响。现代的处理器都使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线的持续运行,他可以避免由于处理器停顿下来等待向内存写入数据而产生的延误。对于缓存区而言,它提高了处理器的执行效率,但是同样带来了一些问题:处理器对于内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。比如说下面这个代码:

    1 //处理器A执行
    2 a = 1;//A1
    3 x = b;//A2
    4 //处理器B执行
    5 b = 2;//B1
    6 y = a;//B2

     假设处理器A与处理器B都按照顺序执行那么最终的结果应该是x=1,y=2,但是得到的结果却可能是x=y=0,具体原因如下:

    按照流程走应该是写回本地内存,然后再写回主内存,然后再去主内存中读取相关的变量,这时获取的结果就是x=1,y=2但是由于重排序的存在可能还没来得及写回主内存中就已经开始读取数据此时读取到的就是x=y=0这个结果了。由于写缓冲区仅对自己的处理器可见,他会导致处理器执行内存操作的顺序可能会与实际操作的顺序不一样。那么什么来保证多处理器中的并发安全性呢?Java中提供了内存屏障指令来禁止一些处理器层的重排序,内存屏障有四种:

    最后一种屏障是一种全面性屏障,它能保证任何情况下的线程安全,当然除了内存屏障还有先天的一些执行顺序,这些顺序是无法被重排序给破坏的,也就是著名的happens-before原则:

        在JMM中,如果一个操作执行的结果需要对另外一个操作可见,那么这两个操作之间必须存在happens-before原则,牢记这些原则也是可以简化并发开发。下面详列出happens-before规则

           程序顺序规则:在单线程中,按照代码的顺序执行,前面的操作先执行于后面的操作。

           管理锁定规则:一个unlock()操作happens-before之后任意线程获取这个锁的操作。

           volatile变量规则:对一个volatile变量的写操作happens-before之后任何线程对这个变量的读操作。

           线程启动规则:Thread对象的start()方法happens-before之后线程的任何其他操作。

           传递性规则:如果操作A happens-before 操作B,操作B happens-before 操作C,那么操作A必定happens-before操作C。

    这边需要的注意的一点:两个操作之间具体happens-before原则并不代表前一个操作必须在后一个操作前面执行,他保证的只是前一个操作产生的任何结果对于后续的操作其都是可见的。下面可见看一下happens-before与JMM的关系图:

    对于重排序编译器和处理器本身就有一个限定就是as-if-serial语义,也就是上面说过的程序运行时进行重排序操作但是其重排序不是随意进行重排序的,他必须满足不影响程序的结果以及语义。这也是as-if-serial语义的核心:不管你程序如何进行重排序但是不能影响我程序原本的结果。为了遵守as-if-serial语义编译器和处理器不会对存在数据以来关系的操作做重排序,因为这种重排序会影响程序执行的结果。比如下面的一段代码:

    1 int a = 1;
    2 int b = 2;
    3 int c = a+b;

    很简单的一个操作就是定义两个变量,而第三个变量就是第一和第二个变量的值,在单线程中int a =1;与int b = 2;这两个操作可能会进行重排序可能先定义b也有可能先定义a,但是最后一步肯定是最后执行的。这就是as-if-serial语义的作用。假设把上面的操作分为三步,其过程就是:

    A操作与B操作之间是不存在以来关系的这两个操作分别定义了两个变量,但是C操作依赖于A也依赖于B,通过这个依赖,编译器最终判断C不能排在A之前也不能排在B之前,如果允许进行重排序那么这个操作就会发生变化,但是A与B之间毫无关系就可以任意的进行重排序,而且重排序以后也不会影响程序运行的结果。最终执行的顺序就可能存在下面两种:

    重排序还涉及到一个并发安全的设计模式,也是初级面试的时候经常会被问到的手写单例模式,通常我们都会写简单的单例模式但是这可能并不是面试官想要看到的,厉害点的可能会写出下面的代码:

     1 package demo;
     2 
     3 public class Singleton {
     4     private volatile static Singleton singleton;
     5 
     6     private Singleton() {
     7     }
     8 
     9     public static Singleton getInstance() {
    10         if (singleton == null) {
    11             synchronized (Singleton.class) {
    12                 if (singleton == null) {
    13                     singleton = new Singleton();
    14                 }
    15             }
    16         }
    17         return singleton;
    18     }
    19 }

    上面的代码能够实现一个安全的单例模式,并且这个单例模式被称为双重校验锁模式。但是有些面试者会粗心,会把volatile关键字忘记了,漏掉了volatile关键字表面上看是没有错误的。但是实际运行中会发生并发不安全的情况。这里涉及到的就是重排序:

      如果没有volatile关键字,当进行new Singlton对象时会发生一个问题,可能对象还有完全构造完此时有其它的线程引用了这个创建的对象。这里其实有两个问题:

        第一:synchronized关键字的确能保证可见性,为什么还需要volatile关键字保证可见性呢。

        第二:当第一个线程执行到singleton = new Singleton()时,这个过程是分步骤的并不是一个原子性的操作,实例化对象其实可以被分为三步:

           1、分配内存空间

           2、将对象指向刚刚分配的内存空间

           3、初始化对象

        这三步中第二第三步是可以进行重排序的,在多线程的情况下当一个线程进入执行new操作其它线程返回的可能是一个还未初始化完全的对象,因为其它线程还是可以进行getInstance()方法当new操作执行还未全执行完时其它线程拿到的就是未实例化完全的对象造成线程不安全。而volatile关键字就能保证是一个完全初始化后的对象。

    四.顺序一致性

        顺序一致性方便并发编程而建立的规定。其阐述了两个部分:

           第一、每个线程内部的所有操作必定按照程序的顺序来进行执行。

           第二、线程执行的交错顺序可以是任意的,但是所有线程看见的整个程序的总体执行顺序是一致的。

       在刚接触并发时,很多人会误以为并发就是多个线程在同时运行,其实这个想法是不对的,CPU每次只能执行一个线程,通过一个类似指针的东西在线程中不断的切换,当某个线程获取CPU资源时就轮到这个线程进行执行,同一时间只有一个线程能获取到CPU的资源。比如说两个线程一个线程A和一个线程B,A中存在三个操作,B中存在三个操作,而每个线程的操作顺序都是1->2->3这样的。如果这个并发操作是经过同步的就会形成这样的执行顺序:

    如果这两个线程是未同步后的,那么其执行过程就是下面的执行过程:

    未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1 -> A1 -> A2 -> B2 -> A3 -> B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

    五.总结

        JMM是语言级别的内存模型,了解JMM有助于对并发编程更加深入的理解。JMM内存可见性对于三种JAVA程序的影响:

          单线程程序:单线程程序不会出现内存可见性问题,编译器,runtime和处理器会共通确保单线程程序的执行结果与该线程在顺序一致性模型中的执行结果相同。

          正确同步过后的程序:正确同步的多线程程序的执行将具有一致性,通过锁或者lock的操作JMM通过这些语义来禁止或者限制编译器和处理器中非安全的重排序。

          未同步的程序:JMM只提供基本的安全性比如happens-before以及单线程内的as-if-serial语义。

        下图展示三种程序执行的结果

    ================================================================================== 

    不管岁月里经历多少辛酸和艰难,告诉自己风雨本身就是一种内涵,努力的面对,不过就是一场命运的漂流,既然在路上,那么目的地必然也就是前方。


    ==================================================================================

  • 相关阅读:
    Java生鲜电商平台-物流配送的设计与架构
    五分钟学Java:如何学习Java面试必考的网络编程
    五分钟学Java:如何学习Java面试必考的网络编程
    Java原来还可以这么学:如何搞定面试中必考的集合类
    五分钟学Java:如何学习Java面试必考的JVM虚拟机
    先搞清楚这些问题,简历上再写你熟悉Java!
    MySql/Oracle和SQL Server的分页查
    Java面试题之int和Integer的区别
    Java基本数据类型转换
    Shiro-Subject 分析
  • 原文地址:https://www.cnblogs.com/wait-pigblog/p/9372545.html
Copyright © 2020-2023  润新知