# 什么是 Java 内存模型(JMM)?

10 min read
Table of Contents

什么是 Java 内存模型(JMM)?

Java 内存模型(JMM)面试标准答案

【核心定义】

Java 内存模型(JMM)是一个规范,它定义了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量的底层细节,其核心目标是解决多线程环境下,由于内存可见性、指令重排序等问题导致的并发安全问题。

【关键要点】

  1. 抽象的内存结构:JMM将内存抽象为主内存和工作内存。主内存存储所有共享变量,每个线程拥有自己私有的工作内存,存储该线程使用到的共享变量的副本。线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存。
  2. 内存间交互操作:JMM定义了8种原子性操作来规范主内存与工作内存之间的交互:lock(锁定)、unlock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)。这些操作必须满足一系列规则,如read/load、store/write必须成对出现。
  3. 三大核心特性:
    • 原子性:由JMM直接保证的原子性操作包括read、load、assign、use、store、write。对于更大范围的原子性保证(如i++),需要依赖synchronized或Lock。
    • 可见性:指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。JMM通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现,具体依赖volatile、synchronized、final关键字。
    • 有序性:指程序执行的顺序按照代码的先后顺序执行。JMM允许编译器和处理器进行指令重排序以优化性能,但提供了volatile、synchronized和happens-before规则来保证线程间的有序性。

【深度推导/细节】

核心矛盾:硬件性能优化(如高速缓存、指令重排序)与程序并发正确性之间的矛盾。

逻辑拆解:

  1. 可见性问题根源:现代CPU有多级缓存,线程的工作内存可类比CPU缓存。当一个线程更新了缓存中的副本,若未写回主内存,或其他线程的缓存未失效,就会导致脏读。
  2. 有序性问题根源:编译器和处理器会进行指令重排序(在不改变单线程程序执行结果的前提下)。但在多线程环境下,重排序可能导致意想不到的结果。
    • 经典案例:双重检查锁定(DCL)与指令重排序
      if (instance == null) { // Step 1: 第一次检查
      synchronized (Singleton.class) { // Step 2: 加锁
      if (instance == null) { // Step 3: 第二次检查
      instance = new Singleton(); // Step 4: 问题所在!
      }
      }
      }
      instance = new Singleton(); 这行代码并非原子操作,它可能被重排序为:
      • Step 1: 分配内存空间
      • Step 2: 将内存地址赋值给instance引用 (此时instance不为null,但对象尚未初始化!)
      • Step 3: 初始化对象 若线程A执行完Step 2后发生上下文切换,线程B在第一次检查时发现instance不为null,便会直接返回一个未初始化完成的对象,导致程序错误。解决方法是使用volatile修饰instance,禁止相关重排序。
  3. JMM的解决方案:通过happens-before规则(一组偏序关系)来定义“可见性”和“有序性”的保证。只要操作A happens-before 操作B,那么A的结果对B可见,且A的执行顺序排在B之前。

【关联/对比】

  • JMM vs JVM运行时内存区域:
    • JVM内存区域(堆、栈、方法区)描述的是数据存储的物理或逻辑区域,是“静态”的。
    • JMM描述的是多线程环境下,变量访问的规则和约定,是“动态”的、抽象的。工作内存涵盖了栈、寄存器甚至缓存,主内存主要对应堆中的共享变量部分。
  • synchronized vs volatile:
    • synchronized:同时保证原子性、可见性、有序性。通过锁的获取与释放,隐式包含了对主内存的同步操作(进入同步块前清空工作内存,退出前将变量刷回主内存)。
    • volatile:只保证可见性和有序性(禁止指令重排序),不保证原子性。其实现原理是在汇编指令中加入内存屏障(Memory Barrier)。
  • final关键字的特殊规则:被final修饰的字段,在构造器中被正确初始化后(没有this引用逃逸),对其他线程是立即可见的,无需同步。

『面试官追问』

  1. happens-before原则具体包括哪些?

    • 程序次序规则:一个线程内,书写在前面的操作happens-before后面的操作。
    • 管程锁定规则:一个unlock操作happens-before后续对同一个锁的lock操作。
    • volatile变量规则:对一个volatile变量的写操作happens-before后续对这个变量的读操作。
    • 线程启动规则:Thread.start() happens-before 此线程的每一个动作。
    • 线程终止规则:线程中的所有操作都happens-before对此线程的终止检测。
    • 线程中断规则:对线程interrupt()的调用happens-before被中断线程检测到中断事件。
    • 对象终结规则:一个对象的初始化完成happens-before它的finalize()方法的开始。
    • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  2. volatile如何保证可见性?底层原理是什么?

    • 当写一个volatile变量时,JVM会向处理器发送一条Lock前缀的指令,这个指令有两个关键作用:
      1. 将当前处理器缓存行的数据立即写回系统内存。
      2. 这个写回操作会使其他CPU里缓存了该内存地址的数据无效(缓存一致性协议,如MESI)。
    • 因此,后续其他线程在读取该变量时,会发现自己的缓存无效,会重新从主内存中读取最新值。
  3. 什么是内存屏障?volatile如何通过内存屏障禁止重排序?

    • 内存屏障是一组CPU指令,用于控制特定条件下的重排序和内存可见性问题。
    • JVM为volatile读写插入以下屏障:
      • 在每个volatile写操作前插入StoreStore屏障,之后插入StoreLoad屏障。
      • 在每个volatile读操作后插入LoadLoad屏障和LoadStore屏障。
    • 这些屏障阻止了屏障两侧的指令进行重排序,并强制刷新缓存。
  4. 为什么volatile不能保证原子性?举例说明。

    • volatile的读写操作本身是原子的,但复合操作(如i++)不是。i++实际上分为read、increment、assign三个步骤,volatile只能保证每个步骤的可见性,但不能保证这三个步骤作为一个整体不被其他线程打断。
    • 解决方案:使用java.util.concurrent.atomic包下的原子类(基于CAS),或使用synchronized。
  5. 从JDK 5开始,volatile的语义被增强,具体增强了什么?

    • 在旧的JMM(JDK 5之前)中,volatile变量并不能完全防止指令重排序。
    • JDK 5使用新的JSR-133内存模型,严格限制了volatile变量与普通变量的重排序,并正式确立了happens-before关系,使volatile的语义(尤其是禁止重排序的语义)变得真正严格和有效。这也是DCL问题必须用JDK 5及以上版本的volatile才能正确解决的原因。
My avatar

文章看完了,不打算去别的篇章转转吗?👀 感谢你的耐心阅读!如果你有问题想探讨,或者发现了文中的 Bug,请务必通过页脚的链接联系我


More Posts

Comments