Java内存模型(JMM):一个可能知道但没具体了解的概念

1,649 阅读13分钟

写个文章是因为一次字节面试中,问到java内存模型了解吗?我答了一些堆、方法区、虚拟机栈什么的。然后说这个不是。我一脸蒙蔽。。。之后了解到JMM,才知道自己有多蠢,原来是这些东西,原来这些叫JMM。

所以,现在写一篇文章总结一下。

1. Java内存模型的概念

大家都知道java是通过java虚拟机来跨平台运行。但,它是怎么实现的呢,有没有什么规则?

:不同计算机操作系统对内存模型操作不一样,这时候就要有统一的规范来完成操作。所以就要通过JAVA内存模型(Java Memory Model,JMM

  • 它是一种JAVA虚拟机规范
  • 它屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

!!!下面2,3,4,5,6段落都是有关JAVA内存模型的相关规范或者规则。!!!

文章最后做总结

2. 内存规范:主内存和工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则

  • 关注在虚拟机中把变量值存储到内存从内存中取出变量值这样的底层细节。

这个段落的目标:针对的是线程之间可以共享的变量

变量根据是否可以共享划分为:线程私有的和线程公有的。

  • 线程私有:局部变量与方法参数
  • 线程公有:实例字段、静态字段和构成数组对象的元素

Java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。如图所示:

这一部分要和JAVA内存区域作区分。

  • 内存区域分为:虚拟机栈、本地方法栈、程序计数器(加粗为线程之间隔离的)、方法区、堆区、直接内存

3. 内存操作

主内存与工作内存之间具体的交互协议,

  • 即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节。

Java内存模型中定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。(简单看看就行

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

Java设计团队,将Java内存模型的操作简化为read、write、lock和unlock四种,但这只是语言描述上的等价化简,Java内存模型的基础设计并未改变。

4. volatile型变量的规则

volatile定义的变量有两个特性:

  • 保证此变量对所有线程的可见性
  • 禁止指令重排序优化

4.1 可见性

1. 概念:当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成

  • 普通变量写入读取流程:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见。

2. 线程安全

在并发中,并不一定是线程安全。

Java里面的运算操作(这里指的是a=b+1,类似这种,不是a=1这样)符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。

网上有很多利用线程对一个变量10000次,但是最后结果不是10000*线程数

变量的++操作在字节码中分解为三个部分(此处并不严谨,代表意思为分成多步骤),这样会导致线程不安全(单独的读写是安全的)。

  • 读值
  • 改值
  • 存值

4.2 禁止指令重排序

1. 概念:是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是说指令任意重排,处理器必须能正确处理指令依赖情况保障程序能得出正确的执行结果。

注意:在同一个线程的方法执行过程中无法感知到指令重排序,但是其实其中的一些执行顺序发生了改变但保证结果不变

  • 因为Java内存模型中定义“线程内表现为串行的语义”。

2. 禁止指令重排序的例子:单例模式懒汉

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

代码解释:假如没有volatile修饰,在new Singleton的时候,对instance已经赋予了内存空间,但是内存中没有东西。此时有另一个线程获取单例去使用,发现这个内存中没有对象无法使用(就是初始化一半),发生了线程安全的问题。

原理解释:在lock addl $0x0,(%esp)这条汇编语句中增加lock修饰。

  • 它将本处理器的缓存写入了内存,该写入动作也会引起别的处理器或者别的内核无效化其缓存。
  • 它相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置,注意不要与第3章中介绍的垃圾收集器用于捕获变量访问的内存屏障互相混淆),因为要把CPU缓存写回内存,所以lock之前的指令要做完,并且在lock指令之后的操作之前,这就是屏障。

3. 使用原则

  • volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下volatile的总开销仍然要比锁来得更低
  • 我们在volatile与锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景的需求。

4.3 总结

  1. 要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改。
  2. 要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改。
  3. 要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同

5. long和double的特殊规则

对于上面的Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性。

但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,

  • 允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”

经过实际测试,在目前主流平台下商用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机,譬如比较常用的32位x86平台下的HotSpot虚拟机,对long类型的数据确实存在非原子性访问的风险。

  • 编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。

6. happens-before原则

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点,这是因为Java语言中有一个“先行发生”(Happens-Before)的原则。

Java内存模型下一些天然的先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。存在8中规则:

  1. 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。
  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

7.总结

7.1 原子性

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的。

  • 例外就是long和double的非原子性协定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况。

如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供synchronized关键字,因此在synchronized块之间的操作也具备原子性。

  • 通过lock和unlock操作,但虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。

7.2 可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

  • volatile的可见性是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
  • synchronized同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的。
  • final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。

7.3 有序性

Java程序中天然的有序性可以总结为:

  • 如果在本线程内观察,所有的操作都是有序的;
  • 如果在一个线程中观察另一个线程,所有的操作都是无序的。

前半句是指线程内似表现为串行的语义,后半句是指指令重排序现象和工作内存与主内存同步延迟现象。

  • volatile关键字本身就包含了禁止指令重排序的语义。
  • synchronized则是由一个变量在同一个时刻只允许一条线程对其进行lock操作这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

这篇文章参考:《深入理解Java虚拟机(第3版)》