面试总被问 Java内存模型和 volatile,为什么总答不到点子上?

0 阅读8分钟

前言

上周在做多线程业务汇总功能开发时,我心中产生了两个疑问:

  1. 多线程之间如何实现通信?也就是线程间依靠什么机制进行数据交换。
  2. 多线程之间如何实现同步?也就是如何管控不同线程间任务执行的先后顺序。

查资料后了解,线程间主流的通信机制分为两种:共享内存消息传递而 Java 采用的正是共享内存模型


一、Java内存模型

Java 线程间的通信由Java 内存模型(Java Memory Model,JMM)  统一控制,JMM 的核心作用是定义一个线程对共享变量的写入操作,何时对其他线程可见

1、实现原理

2、主内存和本地内存区别

JMM 规范了线程与主内存之间的抽象交互关系:

  • 所有线程共享的变量都会存储在主内存中;
  • 而每个线程都拥有独立的本地内存,用于存放共享变量的工作副本。

2.1、主内存(JMM 抽象)

  • 对应硬件:计算机物理内存(RAM 内存条)  为主
  • 可以近似理解:JMM 主内存 ≈ 硬件物理内存
  • 存放所有共享变量,是所有线程共享的区域。

2.2、本地内存(JMM 抽象)

完全不是一块独立的物理内存,它是JMM 虚构的抽象合集, 包含了硬件里这些东西:

  • CPU 高速缓存(L1、L2、L3 缓存)
  • CPU 寄存器
  • 硬件写缓冲区
  • 编译器指令重排序、CPU 乱序执行优化

⚠️需要注意
✔️本地内存是 JMM 的抽象概念,并非物理真实存在,它涵盖了 CPU 缓存、硬件写缓冲区、CPU 寄存器,以及各类硬件和编译器的优化机制。
✔️按照 JMM 的严格规定,线程对共享变量的所有读写操作,必须在自身的本地内存中完成,不能直接读写主内存

3、线程之间如何通信?

结合1中的图可以看出,线程 A 与线程 B 若要实现通信,必须经过两个核心步骤:

  1. 线程 A 将自身本地内存中已更新的共享变量,刷新同步至主内存
  2. 线程 B 从主内存中,读取线程 A 已更新后的共享变量数据

⚠️注意
✔️线程之间无法直接访问彼此的本地内存,线程通信必须经由主内存中转
✔️JMM 正是通过规范主内存与各线程本地内存之间的数据交互规则,为 Java 程序提供内存可见性保障


二、主内存与工作内存

1、交互协议实现流程

⚠️注意
✔️读入主内存变量lock → read → load
✔️线程内使用 / 修改:use → assign
✔️写回主内存:store → write → unlock

2、八种原子操作

主内存与线程本地内存间的数据交互,具体如下:

  • lock(锁定) :作用于主内存变量,将变量标记为当前线程独占状态。
  • unlock(解锁) :作用于主内存变量,释放已处于锁定状态的变量,释放后其他线程才可对其加锁。
  • read(读取) :作用于主内存变量,将变量值从主内存传输到线程工作内存,供后续 load 操作使用。
  • load(载入) :作用于工作内存变量,把 read 从主内存读取到的值,存入工作内存的变量副本中。
  • use(使用) :作用于工作内存变量,将工作内存中的变量值传递给虚拟机执行引擎;只要虚拟机遇到需要读取变量值的字节码指令,就会触发该操作。
  • assign(赋值) :作用于工作内存变量,把执行引擎运算后得到的值,赋值给工作内存中的变量;虚拟机遇到变量赋值类字节码指令时,便会执行此操作。
  • store(存储) :作用于工作内存变量,将工作内存的变量值传输到主内存,供后续 write 操作使用。
  • write(写入) :作用于主内存变量,把 store 从工作内存传出的值,最终写入更新到主内存的变量中。

三、 锁的可见性原理

1、锁的获取与释放

  • 获取锁时:JMM 会将当前线程的本地内存置为无效,强制线程从主内存读取共享变量的最新值。
  • 释放锁时:JMM 会将当前线程本地内存中修改过的共享变量,强制刷新回主内存

⚠️**Synchronized关键字依托这一内存原理,实现了多线程访问共享资源时的互斥性与可见性**
✔️在获取锁前,线程会从主内存加载最新数据;
✔️释放锁时,线程会将修改后的数据同步回主内存,确保其他线程能看到最新值。


四、volatile可见性原理

1、volatile 读写

  • volatile 写:当线程写入一个 volatile 变量时,JMM 会强制将该线程本地内存中的变量值,直接刷新到主内存。
  • volatile 读:当线程读取一个 volatile 变量时,JMM 会将该线程对应的本地内存置为无效,强制线程从主内存中读取变量的最新值。

⚠️注意
✔️读流程:read → load → use
✔️写流程:assign → store → write
✔️不会显式触发 lock/unlock 这两个操作。

2、volatile 不显式触发 lock/unlock怎么保持数据一致性呢?

为实现 volatile 写刷新主存、读清空本地缓存、禁止指令重排序 的内存语义,JVM 会在机器码中插入**内存屏障**。
在 x86 平台下,写入 volatile 变量时,会生成带有 lock 前缀的汇编指令,例如 lock addl

该硬件 lock 基于总线锁和缓存一致性协议实现和 JMM 的 lock 原子操作不是一回事

它有三个核心作用:

  • 强制将写缓冲区、CPU 缓存中的数据立刻刷新到主内存;
  • 禁止指令重排序;
  • 触发缓存一致性协议,使其他 CPU 缓存副本失效,保障可见性。

3、volatile为什么禁止指令重排序?

如果不禁止,就算能刷新内存,如果允许指令重排序,代码执行顺序乱了,业务逻辑就直接出错了。

a = 1; 
volatile flag = true;

如果允许指令重排序, CPU 可能擅自把顺序改成:

volatile flag = true;  // 先执行这行 
a = 1;                 // 后执行这行

别的线程一看到 flag=true,以为 a=1 已经完成,但实际上 a 还没赋值,直接拿到脏数据 逻辑错乱

⚠️注意
✔️刷新内存只能保证别人能看到最新值
✔️禁止重排序是保证代码按你写的顺序执行,不乱跳、不颠倒逻辑

4、volatile和 synchronized在内存模型的区别

4.1、synchronized
阻塞其他线程,拿不到锁就阻塞排队,有竞态、有上下文切换。

⚠️举个例子
✔️好比进房间办事:一个人进去办 5 分钟,外面所有人原地排队、挂机等待,啥也干不了。

4.2、volatile + 硬件 lock 前缀
硬件层面串行化,只是一瞬间锁定总线 / 缓存行做内存同步
不会阻塞其他线程,所有线程依旧可以同时跑,只是内存数据立刻可见、顺序不乱

⚠️硬件 lock(volatile)
✔️锁的是内存总线一瞬间,指令干完马上释放,线程不阻塞、不挂起,继续跑
✔️volatile 的 lock好比过独木桥:每个人一秒快速冲过去,桥瞬间被占,但过完马上空出来,后面人不用排队睡觉,只是稍微等一下下继续走。


五、面试题

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

面试里一旦被问到这个问题,很多小伙伴很容易把Java 内存模型(JMM)  和Java 内存结构搞混。一答题就跑偏到堆、虚拟机栈、GC 垃圾回收这些内容上,最后答得和面试官真正想问的完全不是一回事。

其实面试中问到 Java 内存模型,根本不是考内存分区,主要考察的都是多线程、Java 并发相关的知识点。

2、volatile为什么不会重排序、不会被插队?

因为加了 lock  (lock addl ) 前缀后:

  • 硬件把这整条指令的所有内部微操作,封装成一个不可分割的原子单元
  • 硬件层面串行化,全程独占总线 / 缓存行,做完才放行
  • 别的 CPU 只能等这一条指令硬件执行完,但只是硬件内存层面短暂等待
  • 不是 Java 线程被挂起、阻塞、进队列

六、总结

JMM 定义了线程与主内存的数据交互规范,依靠八大原子操作完成变量读写同步。

synchronized 通过加锁、释放锁,既能保证线程互斥,又能刷新内存、保障可见性。

volatile 借助内存屏障禁止指令重排序,写变量强制刷新到主存,读变量清空本地缓存;底层硬件 lock 只是瞬时锁定总线做缓存同步,和 JMM 的 lock 原子操作并非同一概念,它只实现可见性和有序性,不会阻塞线程,也无法保证自增这类复合操作的原子性。