# 什么是 Java 中的原子性、可见性和有序性?

10 min read
Table of Contents

什么是 Java 中的原子性、可见性和有序性?

Java 中的原子性、可见性和有序性

【核心定义】

原子性、可见性和有序性是 Java 内存模型(JMM)定义的、用于保证多线程环境下程序正确执行的三个核心特性,它们是理解并发编程中线程安全问题的基石。

【关键要点】

  1. 原子性:指一个或多个操作作为一个不可分割的整体执行,要么全部成功,要么全部失败,中间状态对外不可见。

    • 原理简述:JMM 直接保证对基本数据类型(除 long/double 外)的读写操作具有原子性。对于更大范围的原子性保证,需要通过 synchronized 关键字或 Lock 接口来实现。
  2. 可见性:指当一个线程修改了共享变量的值后,其他线程能够立即得知这个修改。

    • 原理简述:JMM 通过在主内存与线程工作内存之间同步变量的规则来实现可见性。关键字 volatile、synchronized 和 final 可以保证可见性。
  3. 有序性:指程序执行的顺序按照代码的先后顺序执行。但在多线程环境下,由于编译器和处理器会进行指令重排序,可能导致程序执行顺序与代码顺序不一致。

    • 原理简述:JMM 通过 happens-before 规则来禁止特定类型的指令重排序,从而为程序员提供有序性的保证。volatile 和 synchronized 关键字可以保证有序性。

【深度推导/细节】

这三个特性共同解决了多线程环境下的核心矛盾:数据竞争和指令重排序带来的不确定性。

  • 原子性的冲突场景:经典的“i++”问题。该操作包含读取、计算、写入三个步骤,不具备原子性。多线程并发执行时,可能导致最终结果小于预期。

    • 逻辑拆解:
      • Step 1: 线程 A 读取变量 i 的原始值(例如 0)。
      • Step 2: 线程 B 也读取变量 i 的原始值(0)。
      • Step 3: 线程 A 执行 i+1 操作,并将结果(1)写回主内存。
      • Step 4: 线程 B 执行 i+1 操作,并将结果(1)写回主内存。
      • 最终结果:i 的值为 1,而不是正确的 2。
  • 可见性的冲突场景:线程“死循环”。一个线程修改了共享变量的值,但另一个线程却一直读取到旧值。

    • 逻辑拆解:
      • Step 1: 主内存中共享变量 flag = false。
      • Step 2: 线程 A 将 flag 读取到自己的工作内存。
      • Step 3: 线程 A 在自己的工作内存中将 flag 改为 true,但未及时刷新到主内存。
      • Step 4: 线程 B 从主内存读取 flag,得到的仍然是 false,导致其无法退出循环。
  • 有序性的冲突场景:单例模式下的“双重检查锁定(DCL)”问题(在旧版本 JDK 中)。

    • 逻辑拆解:
      • 对象初始化 new Instance() 并非原子操作,可能被重排序为:1) 分配内存空间,2) 将引用指向内存地址(此时对象尚未初始化),3) 初始化对象。
      • 如果发生重排序,线程 A 执行到步骤 2 后,线程 B 可能判断引用不为 null,从而直接返回一个未初始化完成的对象,导致程序错误。

【关联/对比】

  • synchronized vs volatile:

    • synchronized 关键字可以同时保证原子性、可见性和有序性。它通过加锁和解锁机制,使得临界区内的操作成为一个原子块,并在解锁前将变量刷新到主内存,同时禁止块内的指令重排序。
    • volatile 关键字只能保证可见性和有序性(禁止指令重排序),但不保证原子性。它通过内存屏障(Memory Barrier)实现,强制将对 volatile 变量的读写操作与主内存同步。
  • Java 内存模型(JMM) vs 物理内存/缓存:

    • JMM 是一个抽象概念,定义了线程和主内存之间的抽象关系。每个线程有自己的工作内存,存储了该线程使用到的变量的主内存副本。
    • 物理架构上,工作内存可能对应着 CPU 的高速缓存(L1, L2)和寄存器,而主内存对应着物理内存。JMM 的规则屏蔽了底层硬件的差异。

『面试官追问』

  1. long 和 double 类型的读写为什么不是原子的?在什么情况下是原子的?

    • 在 32 位 JVM 上,对 64 位的 long 和 double 变量的读写操作可能被拆分为两个 32 位的操作。如果多个线程同时操作,可能读到“半个变量”(高 32 位来自一次写入,低 32 位来自另一次写入)。
    • 如果该变量被声明为 volatile,则其读写操作具有原子性。
  2. happens-before 原则具体有哪些规则?

    • 程序次序规则:一个线程内,按照代码顺序,前面的操作 happens-before 于后面的操作。
    • 管程锁定规则:对一个锁的解锁 happens-before 于随后对这个锁的加锁。
    • volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后续对这个变量的读操作。
    • 线程启动规则:Thread.start() 调用 happens-before 于该线程内的任何操作。
    • 线程终止规则:线程中的所有操作都 happens-before 于其他线程检测到该线程已经终止(如 Thread.join() 返回)。
    • 线程中断规则:对线程 interrupt() 的调用 happens-before 于被中断线程检测到中断事件。
    • 对象终结规则:一个对象的初始化完成 happens-before 于它的 finalize() 方法的开始。
    • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  3. final 关键字如何保证可见性?

    • 在构造函数中对一个 final 域的写入,与随后将被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
    • 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
    • 这确保了其他线程在拿到对象引用时,一定能看到在构造函数中初始化的 final 域的值,从而保证了可见性。

【直击痛点】

  • 为什么 volatile 不能替代 synchronized? 这是最常见的混淆点。关键在于 volatile 不保证复合操作的原子性。例如,对于 volatile int count; 执行 count++,这个“读-改-写”操作本身不是原子的,volatile 只能保证每次读到的都是最新值,但无法阻止多个线程同时读到同一个旧值并进行修改,从而导致更新丢失。而 synchronized 可以将整个 count++ 块锁住,保证同一时刻只有一个线程执行该操作。

  • 指令重排序一定会导致问题吗? 不一定。在单线程环境下,指令重排序遵守 as-if-serial 语义,即不管怎么重排序,程序的执行结果必须与代码顺序执行的结果一致。因此,重排序是为了优化性能,且对单线程程序是透明的。问题只出现在多线程环境下,因为线程间的交互破坏了 as-if-serial 的前提。

【版本差异】

  • DCL(双重检查锁定)问题的修复:
    • 在 JDK 1.5 之前,即使使用 volatile 声明单例实例,DCL 依然可能因指令重排序而失败。
    • 从 JDK 1.5(JSR-133) 开始,增强了 volatile 的内存语义。对一个 volatile 域的写操作,会与后续对这个域的读操作建立 happens-before 关系,并且会禁止与这个写操作相关的指令重排序。因此,在 JDK 1.5+ 中,使用 private volatile static Instance instance; 可以正确实现线程安全的 DCL 单例。
My avatar

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


More Posts

# 什么是 Java 中的 ABA 问题?

6 min read

【核心定义】 ABA 问题是指在并发编程中,一个共享变量的值从 A 变为 B,然后又变回 A,而使用 CAS(Compare-And-Swap)操作的线程会误认为该变量从未被修改过,从而导致逻辑错误的一种现象。

Read

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

10 min read

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

Read

Comments