当前位置: 首页>编程日记>正文

Java并发编程之内存模型

一、什么是JMM

JMM即Java内存模型(Java memory model),JSR-133规范中指出JMM是一组规范或者规则,这个规范决定一个线程对共享变量的写入何时对另一个线程可见。简单来说,Java多线程通讯是通过共享内存实现的,但共享内存通讯会存在一系列如可见性、原子性、顺序性等问题,JMM就是围绕着其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射Java语言就是volatile、synchronized、final等关键字。


Java并发编程之内存模型,第1张
内存模型.png
  • 在Java中,不同线程拥有各自的私有本地内存(工作内存),当线程需要读取或修改某个共享变量时,不能直接去操作主内存中的共享变量,而是需要将这个共享变量读取到线程的工作内存的变量副本中,当该线程修改其变量副本的值后,其它线程并不能立刻读取到新值,需要将修改后的值刷新到主内存中,其它线程才能从主内存读取到修改后的值。

  • 计算机基于高速缓存的存储交互很好地解决了处理器与内存的速度不匹配的问题,但引入一个新的问题就是缓存一致性,所以需要一种协议保证数据的一致性,常用协议有MSI、MESI、MOSI及Dragon Protocol等。

顺序一致性内存模型

顺序一致性内存模型是一个理想化的理论参考模型,提供了极强的内存可见性。它有两大特性:

  • 1.一个线程中的所有操作必须按照程序的顺序来执行
  • 2.不管程序是否同步,所有的线程都只能看到一个单一的操作执行顺序,每个操作都必须原子执行且立刻对其他所有线程可见

JMM和处理器内存模型或者其它内存模型在设计时通常是以顺序一致性内存模型为参照。只是为了提升执行性能,JMM和处理器内存模型会对顺序一致性模型做一些放松,以便做一些处理器和编译器优化。内存模型越松,处理器和编译器可以做更多的优化来提升性能。

指令重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为3种:

  • 1.编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 2.指令级并行的重排序:现代多CPU采用指令级并行技术来将多条指令重叠执行,如果不存在依赖关系,处理器可以改变指令的执行顺序
  • 3.内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行

二、实现原理

happens-before 原则

JSR-133通过happens-before 原则来阐述操作之间的内存可见性,如果一个操作执行结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这两个操作可以在同一个线程内,也可以在不同线程内。
规则如下:

  • 1.程序顺序规则:在同一个线程中,书写在前面的操作happens-before后面的操作。
  • 2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • 3.传递性:如果A happens-before B且B happens-before C,那么A happens-before C。
  • 4.volatile变量规则:对一个volatile域的写,happens-before于任意后续对此变量的操作。
  • 5.start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • 6.join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  • 7.程序中断规则:对线程interrupted()方法的调用happens-before于被中断线程的代码检测到中断时间的发生。
  • 8.对象finalize规则:一个对象的初始化完成(构造函数执行结束)happens-before于它的finalize()方法的开始

as-if-serial 语义

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

as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变,。两个这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

内存屏障: CPU是通过内存屏障指令达到禁止指令重排序

JMM通过语法指令建立happens-before关系,编译器在生成字节码时,会在指令序列中插入内存屏障来防止指令重排序最终生成内存屏障指。常用语法如下:

1.volatile

声明volatile变量被修改时,会将修改后的变量直接写入主存中,并且将其他线程中该变量的缓存置为无效,从而让其它线程对该变量的引用直接从主存中获取数据,这样就保证了变量的可见性。

编译器遇到volatile变量,会在指令序列中插入内存屏障,防止前后指令重排序

volatile修饰的变量i, i++、i+=这类的操作在多线程下都是不能保证变量的原子性的,简单说volatile保证可见性,禁止指令重排序,但不保证原子性

2.锁

锁的释放/获取 与 volatile写/读有相同的语义:

  • 线程A释放锁,实际上是线程A向接下来要获取锁的线程发出了消息(线程A对共享变量的修改)
  • 线程B获取锁,实际上是线程B接收了某个线程发出的消息(对共享变量的修改)
  • 线程A释放锁,随后线程B获取锁,这个过程实际上是线程A通过主内存向B发送消息

1.线程解锁前必须把共享变量的值刷回主内存
2.线程加锁前必须从主内存读取最新的值到工作内存

3.final

对于final域,编译器和处理器都要遵守两个重要排序规则:

  • 1.在构造函数内对一个final域写入,与随后把这个被构造对象赋值给一个引用变量,这两个操作禁止重排序
  • 2.初次读一个含有final域的对象的引用变量,与随后初次读这个final域,这两个操作禁止重排序

三、案例分析

线程安全的单例模式

public class Singleton {
    private static Singleton singleton;
    private Singleton(){}
    public synchronized static Singleton getInstance() {  
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }  
}

每次获取实例都需要加锁,且锁synchronized 本身存在较大性能开销,所以引出双重检查锁定的方案,如下:

public class Singleton {
    private static Singleton singleton;
    private Singleton(){}
    public static Singleton getInstance() {
        if (singleton == null) {                //第一次检查
            synchronized (Singleton.class){     //加锁
                if (singleton == null) {        //第二次检查
                    singleton = new Singleton();//问题的根源
                }
            }
        }
        return singleton;
    }
}

双重检查模式解决每次需要加锁的问题,只要对象创建了,后面获取就不需要加锁了,把锁的开销降到最低(缩小锁的范围),似乎看起来很完美,但这是个错误的优化!
原因分析singleton = new Singleton() 语句不是一个原子指令,可以分成三行伪代码:

memory = allocate();  //1:分配对象的内存空间
ctorInstance(memory)  // 2:初始化对象
singleton = memory    // 3:初始化对象

第2行与第3行伪代码可能会被重排序,编程3在2之前,导致代码读取到singleton不为null时,singleton引用的对象有可能还没有完成初始化。比如:当线程A已经将内存地址赋给引用时,但实例对象并没有完全初始化,同时线程B判断singleton已经不为null,就会导致B线程访问到未初始化的变量从而产生错误。

有两种解决方案

1.基于volatile的解决方案

public class Singleton {
    private volatile static Singleton singleton;  //singleton声明为volatile
    private Singleton(){}
    public static Singleton getInstance() {
        if (singleton == null) {                //第一次检查
            synchronized (Singleton.class){     //加锁
                if (singleton == null) {        //第二次检查
                    singleton = new Singleton();//问题的根源
                }
            }
        }
        return singleton;
    }
}

2.基于类初始化解决方案

public class InstanceFactory {
    private static class Singleton{
        public static Singleton singleton = new Singleton();
    }
    public static Singleton getInstance(){  //导致Singleton类被初始化
        return Singleton.singleton;
    }
}

Java语言规范定义,对于每个类或接口C,都有一个唯一的初始化锁LC与之对应,JVM在类初始化期间会获取这个初始化锁,保证初始化只会执行一次,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。


相关文章: