并发02--JAVA内存模型(JMM)

一、什么是JMM

(一)JMM定义

  JMM 规范对应的是“[JSR-133. JavaMemory Model and ThreadSpecification]”,《Java 语言规范》的 [$17.4. Memory Model章节]

  JMM 规范明确定义了不同的线程之间,通过哪些方式,在什么时候可以看见其他线程保存到共享变量中的值;以及在必要时,如何对共享变量的访问进行同步。这样的好处是屏蔽各种硬件平台和操作系统之间的内存访问差异,实现了 Java 并发程序真正的跨平台,另外一方面,也使得相同的代码在各种不同的Java JVM的实现中执行结果是一致的。

  简单来说,所有的对象(包括内部的实例成员变量)、static 变量、数组,都必须存放到堆内存中,而局部变量、方法的形参/入参、异常处理语句的入参不允许在线程之间共享,所以不受内存模型的影响,因为其在线程栈内部进行处理的。当多个线程同时对一个变量访问时【读取/写入】,这时候只要有某个线程执行的是写操作,都可能导致出现不一致,这种现象就称之为“冲突”。

  冲突的产生是因为当前线程被其他线程影响或感知,例如读取、写入、同步操作、外部操作等等。 其中同步操作包括:对 volatile 变量的读写,对管程(monitor)的锁定与解锁,线程的起始操作与结尾操作,线程启动和结束等等。 外部操作则是指对线程执行环境之外的操作,比如停止其他线程等等。

  JMM 规范的是线程间的交互操作,而不管线程内部对局部变量进行的操作。

  总体总结起来,就是JVM在内部对整个内存进行了各种不同的划分来存放各种不同类型的数据,最后使用JMM的规范对对象的共享和在线程间可以传递的变量进行统一的规范和管理,一方面能够让我们更高效的使用JVM内存,另一方面,在多核环境下,能够让我们的系统在多核多CPU的情况下,程序更高效,同时对于各种并发产生的结果可预期。

(二)JMM采用的共享内存模型

  在并发编程中,需要解决两个问题:线程间如何通信&线程间如何同步(控制不同线程操作顺序的机制)

  解决这两个问题的方案有两种:共享内存&消息传递

    共享内存:通过使用共享内存,隐式通信和同步;这里程序员必须显式的指定某个方法或代码块要在线程间互斥执行

    消息传递:通过发消息来通信和同步;由于接收消息必须在发送消息之后,因此算是隐式的设置了同步

  而JAVA采用的是共享内存模型。

        

  如上图所示,JMM定义了线程和主内存之间的关系:线程之间的共享内存都存储在共享内存中,每个线程有自己的本地内存,本地内存存储了该线程以读/写共享变量的副本。

  JMM是通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证(例如有共享变量x=1,线程A和线程B都要修改这个共享变量,那么线程A将x设置为2,同时将x回写到共享内存,那么线程2拿到的就是x=2)

二、指令重排序

  1、重排序

  处理器对代码的执行顺序并非按照程序编写的源代码顺序执行,而是编译器和处理器会对源代码做重排序,处理器按照重排序后的结果执行,从而提高性能。

  重排序分为三类:

    编译器优化的重排序:编译器在不改变单线程程序语义的情况下重排序

    指令并行重排序:如果不存在数据依赖,处理器可以重排序

    内存系统重排序:处理器使用缓存和读写缓存区,这使得加载和存储操作看起来乱序。

  从JAVA源代码到最终执行的指令序列所经历的重排序如下:

        

  其中1是属于编译器的重排序,2和3属于处理器的重排序。

  2、happends-before

    happends-before是JMM最核心的概念,JMM为了平衡程序员(要求强内存模型,保证内存可见性)和处理器(要求若内存模型,处理器可以自行优化)的需求,设计了happends-before。

    happends-before定义:

      a、如果一个操作happends-before另一个操作,那么第一个操作的结果将对第二个操作结果可见,且第一个操作必须在第二个操作之前。

      b、两个操作之间存在happend-before关系,并不意味着java平台具体的实现必须按照happends-before的顺序来执行。如果重排序的结果与happends-before的执行结果一致,那么重排序并不非法。

    上述a是对程序员做出的承诺:如果A happends-before B,那么A的操作必须对B可见,且A的操作在B之前。

    b是对处理器的约束原则:只要不改变程序运行结果,编译器和处理器可以自行优化(这里的程序指的是单线程程序或正确同步的多线程程序)

    happends-before规则

      程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;

      锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;

      volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;

      传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;

      线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;

      线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

      线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;

      对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

  3、as-if-serial

    不管怎么重排序,单线程的执行结果不能被改变。所有的重排序都要遵循这个原则。

三、内存语义

  但是如果要是多线程运行,那么由于指令重排和线程的读写缓存问题,会造成执行结果异常,因此就需要使用volatile、锁、final来处理

  1、volatile的内存语义

    可见性:对一个volatile修饰的变量的读,总能看到任意线程对这个volatile修饰的变量最后的写入

    原子性:对于任意由volatile修饰的变量的读和写操作都是原子操作;但是对于volatile++这样的操作是非原子性的。

    当写一个volatile修饰的变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到内存中(只要有一个共享变量是volatile修饰,所有的共享变量都会被刷新到主内存中)

    当读一个volatile修饰的变量时,JMM会把该线程对应的内存置为无效,线程将从主内存读取共享变量。

  2、锁的内存语义

    当锁释放时,JMM会把该线程对应的共享内存刷新到主内存中

    当获取锁时,JMM会把该线程对应的本地内存置为无效

    由以上两点可见,释放锁的内存语义和volatile的写拥有相同的内存语义,获取锁的内存语义和volatile的读有相同的内存语义。

  3、final的内存语义

    写final重排序:禁止把final域的写重排序到构造函数之外。这样可以确保在每次引用为任意线程可见之前,对象的final域已经正确初始化。

    读final重排序:在一个线程中,初次读对象的引用和读该对象中的fianl域之间禁止重排序。

    为什么要将fianl的写限制在构造方法之内:比如一个线程看到一个整形的final域为0(还未初始化,默认值),过一段时间去读取时,发现已经变为1(被初始化完成后的值),因此会造成获取final修饰的值不一致的问题,为了修复该问题,限制了final修饰的写必须在构造方法之内。

四、双重检查锁定与延迟初始化

  对于很多场景,都会延迟加载类,来降低初始化类和创建对象的开销,待使用时在延迟加载。

  其实这里就是我们常说的单例模式。  

public class InitDemo {
    private static InitDemo instance;

    public static InitDemo getInstance(){
        if(instance == null){
            instance = new InitDemo();
        }
        return instance;
    }

    public static InitDemo getInstance1(){
        synchronized (InitDemo.class){
            if(instance == null){
                instance = new InitDemo();
            }
        }
        return instance;
    }

    public static InitDemo getInstance2(){
        if(instance == null){
            synchronized (InitDemo.class){
                if(instance == null){
                    instance = new InitDemo();//有问题
                }
            }
        }
        return instance;
    }
}

以上写法都是有问题的:

  getInstance方法的问题:非线程安全,可能会创建多个实例(俗称:懒汉式)

  getInstance1方法的问题:每次使用都加锁,性能消耗大

  getInstance2方法的问题:同样是非线程安全的

解决方案:

解决方案一:使用volatile

public class InitDemo {
    private volatile static InitDemo instance;

    public static InitDemo getInstance(){
        if(instance == null){
            synchronized (InitDemo.class){
                if(instance == null){
                    instance = new InitDemo();//有问题
                }
            }
        }
        return instance;
    }
}

由于使用了volatile,因此多线程(都是第一次访问)创建对象时,就可以保证线程安全。

解决方案二:基于类初始化方式

public class InitDemo {
    private static class InitDemoFactory{
        public static InitDemo instance = new InitDemo();
    }

    public static InitDemo getInstance(){
        return InitDemoFactory.instance;
    }
}

优缺点对比:

  基于类加载模式的方案,代码更简洁;但是只能对静态字段实现延迟加载

  使用volatile,除了可以对静态字段做延迟加载外,还可以对实例字段实现延迟加载。

posted @ 2020-06-10 10:10  李聪龙  阅读(251)  评论(0编辑  收藏  举报