JVM(七)JMM内存模型

1.JMM产生背景和定义

JMM(Java内存模型)源于物理机CPU架构的内存模型,最初用于解决MP(多处理器架构)系统中的缓存一致性问题,而JVM为了屏蔽与各个硬件平台和操作系统对内存访问机制的差异化,提出了JMM的概念。Java内存模型是一种虚拟机规范,JMM规范了Java虚拟机与计算机内存时如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

2.Java内存模型和操作系统内存模型的关系

内存模型和操作系统内存模型的关系

Java内存模型的主要目标是定义程序中各个变量的访问规则,此处提到的变量只包含实例对象、静态对象和构成数组对象的元素(堆和方法区中共享的部分),局部变量和方法参数是线程私有的,不会共享。当然不会存在数据竞争问题。

JMM规定了所有变量存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,由于它特殊性的操作顺序规定,所以看起来如同直接在主内存中读写访问一般)。不同线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

对于JMM与JVM本身的内存模型,参照《深入理解Java虚拟机》的解释,主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分。如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存对应于Java堆中对象的实例数据部分,而工作内存则对应虚拟机中的局部变量表,寄存器对应着操作数栈。

3.Java内存的抽象结构

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

内存模型示意图

线程A与线程B之间要通信的话,必须要经历两个步骤。

  • 1.线程A把本地内存中更新过的共享变量刷新到主内存中去。
  • 2.线程B到主内存中区读取线程A之前已经更新过的共享变量。

4.指令重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分为以下3种

  • 编译器优化的重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令并行的重排:处理器采用指令级并行技术来将多条指令并行执行,如果不存在后一个执行语句依赖前面执行语句的情况,处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统的重排:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去是乱序执行的。因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

其中编译器的重排优化属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,这些重排优化可能会导致程序出现内存可见性问题。

5.JMM内存模型作用

JMM是围绕着程序执行的原子性、有序性、可见性展开的,它是如何保证这三种特性的呢?
我们先了解下这三种特性。

1.原子性

在Java中,java内存模型只保证对基本数据类型变量的读取和赋值操作是原子性操作(long、double)除外,即这些操作是不可被中断的,要么全部执行,要么不执行。例如下面代码

x = 10;   
y = x;
x++;
x = x + 1;

只有x= 10;是原子性操作,其他三个语句都不是原子性操作。

x = 10;是直接将数值10赋给x,也就是说线程执行这个语句会直接将数值10写入到工作内存中。
y = x ;包含两个操作,它先要去读取x的值,再将x的值赋值给y写入工作内存。虽然读取x的值以及将y的值写入工作内存这两个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的 x++和x = x+1;包括三个操作,读取x的值,进行加1操作,写入新的值。

所以,上面4个语句中有x = 10;的操作具备原子性。也就是说,只有简单的读取、赋值才是原子操作。(而且必须是将基础类型常量赋值给某个变量,变量之间的相互赋值不是原子操作)

2.可见性

每个线程会在自己的工作内存来操作共享变量,操作完成之后将工作内存中的副本写回主内存,并且在其他线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其他线程是不可见的。

当一个线程修改了共享变量的值,其他线程能够立即得知这个修改后的值,JMM通过在变量修改后将新值同步回主内存,其他线程在变量读取前从主内存读取新值刷新到工作内存。这种依赖主内存作为传递媒介的方法来实现可见性。

volatile之可见性

当一个共享变量被volatile修饰时,它会保证当前线程修改的值立即被更新到主存。
其他线程工作内存中的变量会强制立即失效,当其他线程需要读取时,回去主内存中读取最新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

3.有序性

在Java内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程的执行,却会影响到多线程并发执行的正确性。

例子:指令重排导致DCL失效

public class Singleton {
     // 指向自己实例的私有静态引用
    private static Singleton instance = null;
    // 私有的构造方法
    private Singleton() { }
    // 以自己实例为返回值的静态的公有方法,静态工厂方法
    public static Singleton getInstance() {
            // 被动创建,在真正需要使用时才去创建
            if(instance == null) {
                //同一时刻只有一个线程进入同步块执行创建对象
                synchronzied(Singleton.class) {
                    if(instance == null) {
                        instance = new Singleton();
                    }
                }
            }
        return instance;
    }
}

看似简单的一行赋值语句:instance = new Singleton();其实内部已经转为多条指令。

memory = allocate();    //分配内存
ctorInstance(memory); //初始化对象
instance = memory;     //设置instance引用指向刚才分配的内存地址

但是有可能经过指令重排后如下

memory = allocate();    //分配内存
instance = memory;     //设置instance引用指向刚才分配的内存地址
ctorInstance(memory); //初始化对象

可以看到指令重排后,instance指向分配好的内存放在了前面,在线程A初始化完成这段内存之前,线程B虽然进不去同步代码块,但是在同步代码块之前的判断就会发现instance不为空,此时线程B获得一个不完整(未初始化)的 Singleton 对象进行使用就可能发生错误。

volatile之有序性

原理:volatile的可见性和有序性都是通过加入内存屏障来实现。
会在写之后加入一条store屏障指令,将本地内存中值刷新到主内存。
会在读之前加入一条load屏障指令,从主内存中读取共享变量。

synchronized 之有序性
  • JMM中有一条关于synchronized的规则:一个变量在同一时刻只允许一条线程对其进行lock。这条规则决定了同步块中的操作相当于单线程执行。
  • as if serial :不管怎么重排序,单线程的执行结果不能被改变。编译器和处理器都必须遵守as if serial语义。也就是说上面的重排序不会影响到单线程程序的执行,所以堆外就表现出了有序性。
4.JMM提供的解决方案

在理解了原子性、可见性、顺序性后,我们看看JMM如何保证这三种特性。

  • 1.除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized 或者lock接口,保证程序执行的原子性。
  • 2.而工作内存与主内存同步延迟导致的可见性问题,可以使用synchronized 或者volitile 关键字解决,他们都可以使一个线程修改后的变量对其他线程立即可见。
  • 3.对于指令重排导致的有序性问题,可以使用volitile 或者synchronized关键字解决,volatile另外一个作用是禁止指令重排,synchronized会变成单线程操作 as if serial保证了结果的一致性。
  • 4.除了靠synchronized 和volatile 关键字来保证有序性、原子性、可见性外,JMM内部还定义了一套happens-before原则来保证多线程环境下两个操作的原子性、可见性、顺序性。

6.happens-before

倘若在开发中,仅靠synchronized和volatile来保证顺序性、原子性、可见性。那么编写并发程序会十分麻烦。在Java内存模型中,还提供了happens-before原则来辅助保证程序执行的原子性、可见性、有序性的问题。它是判断数据是否存在竞争,线程是否安全的依据。happens-before原则内容如下:

  • 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  • 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  • volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  • 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
  • 传递性 A先于B ,B先于C 那么A必然先于C
  • 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
  • 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  • 对象终结规则 对象的构造函数执行,结束先于finalize()方法

上述8条原则无需手动添加任何同步手段(synchronized|volatile)即可达到效果。

7.volatile 内存语义

  • 保证被volatile修饰的共享变量对所有线程总是可见的。也就是当一个线程修改了一个被volatile修饰的共享变量的值,新值总是可以被其他线程立即得知。
  • 禁止指令重排优化。
1.可见性

当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中
当读取一个volatile变量,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读的方式实现对其他线程可见。(内存语义实现是通过内存屏障)

2.禁止重排优化

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序的执行现象。
内存屏障(Memory Barrier)又称内存栅栏,是一个CPU命令,它的作用有两个:

  • 1.保证特定操作的执行顺序。
  • 2.保证某些变量的内存可见性。

由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条内存屏障指令重排序,Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据。
总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。


7.总结

JMM就是一组规则,这组规则解决并发编程可能出现的线程安全问题。并提供了内置解决方案(happens-before原则)及外部可使用的同步手段(synchronized和volatile),确保程序在多线程环境下的原子性、可见性、顺序性。

你可能感兴趣的:(JVM(七)JMM内存模型)