Java并发编程---Java内存模型

Java并发编程—Java内存模型

文章目录

  • Java并发编程---Java内存模型
    • 前言
    • 什么是JMM
      • JMM抽象结构
    • 为什么要有JMM
      • 顺序一致性模型
      • 重排序
    • JMM提供的保障
      • 同步和内存屏障
      • Happens-Before规则
      • as-if-serial语义
    • 同步原语的内存语义
      • Volatile
        • 基本概念
        • 内存语义
        • JMM的实现
        • 底层实现
      • Synchronized
        • 基本概念
        • 内存语义(相对于JUC的Lock接口而言)
        • 底层原理
      • Final
        • 基本概念
        • 内存语义
        • JMM的实现
    • 单例模式与延迟初始化
      • 基于显式同步
      • 双重检查锁定(DCL)
      • 基于volatile
      • 提前初始化
      • 基于延迟初始化占位类
      • 基于枚举

前言

第一章讲述了Java线程间通信方式是通过共享内存变量的方式进行显式的同步、隐式的通信,那么在多线程共享内存时,如何保证它们的访问顺序(如保证线程A对共享变量a的写入在线程B的读取之前执行)? 这就是JMM需要完成的任务了。

什么是JMM

Java内存模型(JMM)是一个语言级的内存模型,它描述和规定了在不同平台上编译器和处理器的重排序规则,进而提供了一种统一的内存可见性保证。

JMM抽象结构

在Java中,所有的实例域、静态域和数组元素都在堆上开辟内存存储,在线程之间进行共享,多线程对于共享变量的访问,如果不进行正确的同步会存在线程安全问题;java虚拟机栈(局部变量表等)、程序计数器等是线程私有变量,不存在线程安全问题。

如下图:每个线程有一个本地内存,存储了主内存中共享变量的副本,本地内存包括:缓存、写缓冲区、寄存器等。

Java并发编程---Java内存模型_第1张图片

为什么要有JMM

JMM决定一个线程对共享变量的写入何时对另一个线程可见。

顺序一致性模型

顺序一致性模型是一个理论参考模型,它提供了极强的内存可见性保证:

  1. 一个线程中的所有操作必须按照程序的顺序执行;
  2. 所有的线程都只能看到一个单一的操作执行顺序;
  3. 每个操作都是原子性的且立即对其他线程可见;
  4. 只有一个单一的全局内存,所有线程的操作都在该内存中操作。

重排序

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

  1. 编译器优化的重排序:保证单线程程序语义;
  2. 处理器重排序:指令级并行;内存系统重排序。

在顺序一致性模型中,不论程序是否对共享变量进行同步访问,都不会存在线程安全性问题。
由于顺序一致性模型的效率太低(对主内存访问的互斥性、内存的访问速度远远低于处理器时钟频率),所以每个处理器都会有自己的寄存器和写缓冲区,而且编译器和处理器都会对指令进行重排序以提高处理效率。
在这种情况下,程序对内存操作顺序对于程序员是不可见的,因此Java语言规范定义了JMM通过以下两种手段为程序员提供内存可见性保证:

  1. 控制主内存与每个线程的本地内存的交互;
  2. 限制特定的编译器和处理器的重排序规则。

JMM提供的保障

同步和内存屏障

JMM会在生成指令序列的适当位置插入内存屏障来禁止特定类型的处理器重排序。
内存屏障:

  1. LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕;
  2. StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见;
  3. LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕;
  4. StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

Happens-Before规则

JMM提供了先行发生(Happens-Before)规则,来描述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在Happens-Before关系。

  1. 程序顺序规则:一个程序中的每个操作都先行发生于该线程中后续的所有操作;
  2. 监视器锁规则:对一个监视器锁的释放先行发生于后续对该锁的获取;
  3. volatile变量规则:对于一个volatile变量的写操作先行发生于所有后续对该变量的读;
  4. 传递性:如果A先行发生B,B先行发生C,那么A先行发生C。

Happens-Before规则是JMM提供的最主要的内存可见性概念,而禁止特定的编译器重排序和在字节码中插入内存屏障禁止特定的处理器重排序是JMM实现内存可见性的具体方法。

as-if-serial语义

不管编译器和处理器怎么重排序,单线程程序的执行结果会保持一致,此为串行化(as-if-serial)语义。
串行化语义规定了编译器和处理器不能对存在数据依赖的操作进行重排序。

同步原语的内存语义

Java中有三个同步原语,它们确保了共享变量在多线程之间的内存可见性:

Volatile

基本概念

如果一个共享变量被volatile修饰,JMM保证所有线程看到的该变量的值都是一致的。
该关键字保证了被修饰的共享变量在多线程之间的:

  1. 可见性:对一个volatile变量的读总是能够看到任意线程对该变量最后的写入(更新);换句话说,即当一个线程修改了一个volatile变量,随后读取这个volatile变量的线程能够立即看到修改后的值;
  2. 原子性:对于任意单个volatile变量的读/写都具有原子性,即使是一个64位的double变量;但是类似i++的符合操作不具备原子性,主要是因为i++是多个指令操作,而volatile不具备临界区的互斥性,无法将符合操作组装成原子操作。

对于volatile变量的符合操作,可以通过配合自旋CAS将其封装为原子操作,详见后续的原子操作类解析

内存语义

对于一个Volatile变量的写操作Happens-Before于后续任意对该变量的读取。

  1. 写内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存刷新到主内存中;
  2. 读内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存失效,从而从主内存中读取最新的值。

JMM的实现

JMM通过在volatile变量的读写操作前后插入相应的内存屏障来禁止特定的处理器重排序:

  1. 在写操作前面插入SS屏障;
  2. 在写操作后面插入SL屏障;
  3. 在读操作后面插入LL屏障;
  4. 在读操作后面插入LS屏障;

具体的屏障插入我也不甚熟悉,暂且如此记录,待以后了解。

底层实现

volatile变量在汇编代码时插入Lock指令:

  1. 引起处理器缓存行回写到内存;通过缓存锁定来原子地更新内存;
  2. 一个处理器的缓存行回写到内存会导致其他处理器上相同内存地址的缓存行无效:处理器嗅探总线上传播的数据来检查自己缓存行里的数据是否过期;

Synchronized

基本概念

锁是Java并发编程中最重要的同步机制,其不仅保证了共享变量在多线程间的可见性,而且保证了临界区(对共享变量的访问)操作在多线程间的互斥性和原子性。
同时,一个锁的释放相当于该线程通过主内存向获取同一个锁的线程发送了一个消息(隐式通信)。

内存语义(相对于JUC的Lock接口而言)

对一个锁的释放Happens-Before于后续任意对该锁的获取。

  1. 锁释放内存语义:当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中;相当于Volatile变量的写的内存语义
  2. 锁获取内存语义:当线程获得锁时,JMM会把该线程对应的本地内存置为无效,从而从主内存中读取最新的共享变量值;相当于Volatile变量的读取的内存语义

底层原理

Java的Synchronized同步原语,主要利用Java对象头和ObjectMonitor(对象监视器)实现,对象头记录锁标识和锁类型(偏向锁、轻量级锁、互斥锁即重量级锁),对象监视器记录拥有锁的线程和在该锁上的线程等待队列以及阻塞队列。具体请看深入理解Java并发之synchronized实现原理。

Final

基本概念

关键字Final正如其义:最终的。
被其修饰的变量是不能被修改的,当然,如果该变量是引用类型,那么其引用的对象可以修改;且final域只能在声明时或构造函数里对其进行初始化;
final修饰的方法是不可被重写的;
final修饰的类是不可被继承的,如String类。

内存语义

Final关键字保证了被其修饰的域在被其他线程访问时已经是完成初始化的了,即其他线程不会因为重排序而读取到未完成初始化的final域。前提是保证构造函数中不会发生this逸出。

  1. 写final域内存语义:在构造函数里对一个final域的写入,Happens-Before于随后将被构造对象的引用赋值给一个引用变量;
  2. 读final域内存语义:初次读取一个包含final域的对象引用,Happens-Before于随后初次读取这个final域。

JMM的实现

写final域的重排序规则:

  1. JMM禁止编译器把final域的写重排序到构造函数之外;
  2. JMM在final域的写之后,构造函数return之前插入SS屏障,以限制处理器将final域的写重排序到构造函数之外。

读final域的重排序规则:
初次读取对象引用于初次读取该对象包含的final域,这两个操作之间存在间接依赖关系。
编译器和大多数处理器都会遵循间接依赖,而不会对这两个操作进行重排序。
为了防止有的处理器对此进行重排序,JMM在读final域的前面插入一个LL屏障。

单例模式与延迟初始化

早期的JVM在性能上存在一些待优化的地方,因此会需要推迟一些高开销的对象初始化操作,并且只有在当使用这些对象时才进行初始化。如下例:

public class UnSafeInit {
    private static Resource resource;

    public static Resource getInstance(){
        //当resource为null时进行初始化
        if (resource == null){
            resource = new Resource();
        }
        return resource;
    }

    public static class Resource {
        //将构造器私有化,避免外部函数调用构造器初始化单例对象
        private Resource(){
        }
    }
}

该例代码在单线程程序中没有问题,是一个良好的延时初始化操作,但是在多线程环境运行时,由于多个线程同时访问,会存在多个线程之间看到的共享变量resource不一致的情况,所以可能出现,多个线程持有的resource对象不一致。

基于显式同步

由于上例未对共享变量的访问进行同步,导致多线程访问出现线程安全问题。所以要对方法进行同步:

public static synchronized Resource getInstance(){
        //当resource为null时进行初始化
        if (resource == null){
            resource = new Resource();
        }
        return resource;
}

双重检查锁定(DCL)

然而上例代码在每次被调用时都会进行加锁解锁的操作,引起线程阻塞和上线文切换,导致了额外的性能开销。为了减少这种开销,出现了下例的双重检查锁定方式:

//进入方法时不加锁
    public static  Resource getInstance(){
        //当检测到resource不为null时也不用加锁,大幅降低锁带来的性能开销
        if (resource == null){
            //当检测到resource为null时进入同步块
            synchronized (UnSafeInit.class){
                //再次检查resource为null时才进行初始化
                if (resource == null){
                    resource = new Resource();
                }
            }
        }
        return resource;
}

这种方式看似很美好,即保证了只有一个线程完成了Resource的初始化操作,又降低了同步带来的性能开销;然而美好的事物往往在美丽的背后隐藏着危险:线程A获得了锁进行了Resource的初始化,线程B看到了Resource不为null,然而由于重排序的存在,线程B获得的Resource对象可能还没有完成初始化,即线程B看到对象处于无效或错误状态。

基于volatile

对于DCL导致的问题,归根结底还是对象的引用在构造器中逸出了,只需要做一点小小的改动,比如禁用构造期间编译器重排序,使对象在被真正完成初始化后才赋值给引用。

private static volatile Resource resource;
    //进入方法时不加锁
    public static  Resource getInstance(){
        //当检测到resource不为null时不用加锁,大幅降低锁带来的性能开销
        if (resource == null){
            //当检测到resource为null时进入同步块
            synchronized (UnSafeInit.class){
                //再次检查resource为null时才进行初始化
                if (resource == null){
                    resource = new Resource();
                }
            }
        }
        return resource;
}

此方法中,利用volatile关键字的Happens-Before规则,使所有线程都看到完成初始化后的对象引用。

提前初始化

public class SafeInit {
    private static Resource resource = new Resource();
    public static Resource getInstance(){
        return resource;
    }
}

在构造器中采用了特殊的方式来处理静态域,并提供了额外的线程安全保障。静态构造器是由JVM在类的初始化阶段执行,即在类被加载后被线程使用之前。由于JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类被正确加载,因此在静态初始化期间,内存写入操作对所有线程所见。
但是这种提前初始化的方式,没有起到延迟加载的作用,下面的方法利用这种无需显示同步的方法和占位类构造了线程安全的延迟初始化方法:

基于延迟初始化占位类

public class SafeInit {
    private static class ResourceHolder{
        public static Resource resource = new Resource();
    }

    public static Resource getInstance(){
        return ResourceHolder.resource;
}

JVM将推迟ResourceHolder的初始化操作,直到开始使用这个类时才初始化,并且由于通过一个静态初始化来初始化Resource,因此不需显示同步。
当任何一个线程第一次调用getResource方法时,才会使ResourceHolder被加载和初始化,此时静态初始化器将执行Resource的初始化操作。

基于枚举

详见 Java 单例模式的两种高效写法

你可能感兴趣的:(Java并发编程,Java并发编程总结和浅析)