Skip to content

同步机制

更新: 1/25/2026 字数: 0 字 时长: 0 分钟

volatile 是 Java 虚拟机提供的一种轻量级的同步机制,它主要解决了 JMM(Java Memory Model)中的三大特性问题:

  1. 保证可见性:当一个线程修改了 volatile 变量的值,这个新值会立即被刷新到主内存中;当其他线程读取这个 volatile 变量时,它们会强制从主内存中获取最新值,而不是使用自己工作内存中的旧副本。
  2. 不保证原子性volatile 变量的读写操作本身是原子的(单个读或单个写),但复合操作(如 i++,它包含 读取 ii 加 1写入 i 三个步骤)不保证原子性。在复合操作中,仍然可能存在竞态条件。
  3. 保证有序性volatile 关键字可以禁止 JVM 编译器和 CPU 对其修饰的变量进行指令重排序。它通过插入内存屏障来确保特定操作的执行顺序。

性能对比:

  • volatile 变量进行读操作,与普通变量的读取性能差异不大。
  • volatile 变量进行写操作,会相对慢一些,因为 JVM 需要插入内存屏障,这会增加一些开销。然而,这种开销通常比使用 synchronizedLock 来加锁的开销要小得多。

synchronized 为什么能保证有序性和可见性?

  • 有序性:synchronized 块内的代码,由于在任何时刻只有一个线程能获取锁并执行,因此在逻辑上形成了单线程执行的错觉。对于单线程,指令重排序会确保不改变程序的最终执行结果。所以,synchronized 隐式地保证了其保护的代码块的有序性。
  • 可见性:
    • 线程在获取锁时,会清空工作内存中所有共享变量的副本,强制从主内存中重新读取最新的值。
    • 线程在释放锁时,必须把其工作内存中对共享变量的最新修改刷新到主内存中。通过这种机制,synchronized 确保了共享变量在加锁和解锁时的可见性。

指令重排

指令重排是编译器和处理器为了优化程序性能,在不改变单线程程序执行结果的前提下,调整指令执行顺序的一种行为。

volatile 修饰的变量,可以禁用针对该变量的指令重排。

指令重排示例 1 (单线程):

java
public void mySort() {
    int x = 11; // 语句 1
    int y = 12; // 语句 2
    x = x + 5;  // 语句 3 (依赖于 x)
    y = x - x;  // 语句 4 (依赖于 x)
}
  • 可能的执行顺序:1 -> 2 -> 3 -> 42 -> 1 -> 3 -> 4
  • 不能出现 4 -> 3 -> 2 -> 1,因为语句 3 依赖语句 1 的 x 初始化,语句 4 依赖语句 3 的 x 更新。这种依赖关系会阻止重排。

指令重排示例 2 (多线程与可见性):

java
int num = 0;
boolean ready = false;
// 线程 1 执行此方法(消费者)
public void actor1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}
// 线程 2 执行此方法(生产者)
public void actor2(I_Result r) {
    num = 2;        // 写入 num (语句 A)
    ready = true;   // 写入 ready (语句 B)
}
  • 情况一:线程 1 先执行,ready = false,结果 r.r1 = 1
  • 情况二:线程 2 先执行 num = 2,但还没来得及执行 ready = true,线程 1 执行,ready 仍为 false,结果 r.r1 = 1
  • 情况三:线程 2 先执行 num = 2ready = true (且都同步到主内存),线程 1 执行,进入 if 分支,结果 r.r1 = 4
  • 情况四:线程 2 执行 ready = true (语句 B) 先于 num = 2 (语句 A) 或者 num 的写入未同步。此时线程 1 看到 readytrue,但 num 仍为旧值 0,结果 r.r1 = 0。这就是指令重排和可见性问题导致的错误。

底层原理

缓存一致

使用 volatile 修饰的共享变量,JVM 会在底层通过插入特定的内存屏障 (Memory Barrier / Memory Fence) 来实现其语义。

  • 写操作:当一个线程修改 volatile 变量时:
    1. JVM 会在该写指令后插入一个写屏障 (Store Barrier / sfence)
    2. 写屏障会强制把当前 CPU 缓存中所有已修改的数据(包括这个 volatile 变量)都立即刷新到主内存中。
    3. 同时,这个操作会通过总线广播一个消息,使得其他 CPU 核心中所有对应的缓存行都失效
  • 读操作:当一个线程读取 volatile 变量时:
    1. JVM 会在该读指令前插入一个读屏障 (Load Barrier / lfence)
    2. 读屏障会强制当前 CPU 废弃自己缓存中对应的旧数据,并重新从主内存中加载最新数据

内存屏障的三个主要作用

  1. 确保可见性:强制将修改写入主内存,并使其他缓存失效,保证读取最新值。
  2. 阻止指令重排序:内存屏障会像一道“栅栏”,确保屏障前后的指令不会跨越屏障进行重排序。
  3. JMM 层面:强制把缓存中的脏数据写回主内存,让缓存行中相应的数据失效(这是实现可见性的机制)。

内存屏障

保证可见性

  • 写屏障 (sfence):好比“提交保存”,在它之前的所有改动,都必须立刻“同步”到主内存。

    java
    public void actor2(I_Result r) {
        num = 2;      // 1. 修改普通变量
        ready = true; // 2. 修改 volatile 变量 -> 这里会插入【写屏障】
        // ------ 写屏障 (sfence) ------
    }
    • 如果没有屏障:CPU 为了快,可能会先执行 ready = true,后执行 num = 2(指令重排)。或者 num = 2 只停留在 CPU 的高速缓存里,没进主内存。
    • 有了屏障:它像一道墙。它保证 num = 2 必须先于 ready = true 完成,并且在 ready 写入的那一刻,num 的最新值也会被强制刷新到主内存。
  • 读屏障 (lfence):好比“刷新页面”,在它之后的所有读取,都必须去主内存拿“最新版”,而不是用自己手里的旧缓存。

    java
    public void actor1(I_Result r) {
        // ------ 读屏障 (lfence) ------ 
        // 当代码执行到 volatile 变量 ready 的读取时,会触发读屏障
        if(ready) {          
            r.r1 = num + num; // 这里的 num 保证是从主内存读到的最新值 2
        } else {
            r.r1 = 1;
        }
    }
    • 如果没有屏障:actor1 可能觉得自己的缓存里 num 还是 0,直接就用了。
    • 有了屏障:它保证在读取 ready 之后,CPU 必须强制让本地缓存失效,去主内存重新加载 num 的值。

保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

不保证原子性

  • volatile 变量的写屏障只保证它之后的读操作能读到最新结果,但不能防止其他线程的写操作跑到写屏障之前。
  • 有序性的保证也只是针对本线程内volatile 变量相关的代码不被重排序。
  • volatile 无法保证复合操作的原子性。例如 i++
java
volatile int i = 0;
// 线程 A: i++;
// 线程 B: i++;

i++ 对应的字节码(或汇编指令)至少包含三个步骤:

  1. getfield i:从主内存读取 i 的值到工作内存(由 volatile 保证可见性)。
  2. iadd:在工作内存中将 i 的值加 1。
  3. putfield i:将工作内存中修改后的 i 的值写回主内存(由 volatile 保证可见性和写屏障)。

如果两个线程同时执行 i++

  • 线程 A 读取 i (0),加 1 得到 1。
  • 上下文切换
  • 线程 B 读取 i (0,因为 A 的修改可能还没写回主内存,或者 A 刚读完还没写回),加 1 得到 1。
  • 线程 B 将 1 写回主内存。
  • 线程 A 将 1 写回主内存。

最终 i 的值是 1,而不是期望的 2。这就是因为 volatile 无法确保复合操作的原子性。

交互规则

对于 volatile 修饰的变量,JMM 规定了更严格的内存交互规则:

  1. 读操作规则:强制“实时拉取”

    对于 volatile 变量,JMM 规定 use 操作必须与 loadread 捆绑出现

    • 动作序列read(读取主存) load(载入工作内存) use(交给 CPU 指令)。
    • 规则限制:线程不允许直接使用工作内存中缓存的旧值。
    • 直观理解:这相当于在每次使用变量前,都强行点了一次“网页刷新”。即使你手里有副本,也必须去服务器重新下载最新版。
  2. 写操作规则:强制“即时推送”

    对于 volatile 变量,JMM 规定 assign 操作必须与 storewrite 连续执行

    • 动作序列assign(变量赋值) store(存储到工作内存) write(写回主存)。
    • 规则限制:线程不允许将赋值后的结果暂存在缓存中。
    • 直观理解:这相当于点击“保存”后,系统自动触发了“全服同步”。你的修改动作还没结束,主存里的值就已经变了。

双端检锁

DCL 机制

Double-Checked Locking:双端检锁机制

DCL 是一种用于实现单例模式的优化手段,旨在兼顾懒惰初始化高并发性能

java
public final class Singleton {
    private Singleton() { } // 私有构造器
    private static Singleton INSTANCE = null; // 单例实例

    public static Singleton getInstance() {
        // 第一次检查 (无需同步,性能高)
        if(INSTANCE == null) { 
            // 首次访问时才进入同步块
            synchronized(Singleton.class) { // 对类对象加锁,确保只有一个线程能初始化
                // 第二次检查 (在同步块内再次检查,防止多线程排队等待时重复初始化)
                if (INSTANCE == null) { 
                    INSTANCE = new Singleton(); // 实例化单例
                }
            }
        }
        return INSTANCE;
    }
}

实现特点

  • 懒惰初始化:只有在第一次调用 getInstance() 时才创建单例实例。
  • 高并发性:一旦单例被创建,后续对 getInstance() 的调用都会直接通过第一个 if 检查返回 INSTANCE,无需进入同步块,性能很高。
  • 问题点:看似完美,但这种没有 volatile 修饰的 DCL 在多线程环境下不一定是线程安全的,其根本原因在于指令重排

为什么不对 INSTANCE 加锁?

  • INSTANCE 在初始化之前是 null,而 synchronized 需要一个非 null 的对象引用作为锁对象。
  • 即使 INSTANCE 被赋值了,如果用 INSTANCE 做锁,那么在 INSTANCE 重新赋值时,锁对象会变化,可能导致问题。

DCL 问题

INSTANCE = new Singleton(); 这行代码看起来是一个简单的操作,但在 JVM 层面,它至少可以分解为以下三个步骤:

  1. memory = allocate():为 Singleton 对象分配内存空间。
  2. ctorInstance(memory):调用 Singleton 的构造方法,初始化对象(填充字段)。
  3. instance = memory:将 instance 变量指向刚刚分配并初始化的内存地址。
  • 指令重排问题:JVM 允许这三个步骤发生指令重排,例如,执行顺序可能是 1 -> 3 -> 2
  • 重排后的顺序:先分配内存(1),然后将 instance 指向这块内存(3),但此时对象尚未完成初始化(构造方法还没执行完)。
  • 多线程问题
    1. 线程 A 执行 1 -> 3,此时 INSTANCE 已经不为 null,但对象还未完全初始化。
    2. 线程 A 暂时离开 CPU 调度,线程 B 进来。
    3. 线程 B 第一次 if (INSTANCE == null) 检查,发现 INSTANCE 不为 null,于是直接返回 INSTANCE
    4. 线程 B 拿到的 INSTANCE 是一个半初始化对象,当它尝试使用这个对象时,可能会访问到未初始化的字段,导致程序出错(空指针异常或其他逻辑错误)。

解决方法

指令重排只会保证串行语义的执行一致性(即单线程内执行结果不变),但并不会关心多线程间的语义一致性

为了解决 DCL 的指令重排问题,需要引入 volatile 关键字:

java
public final class SingletonDemo { // [修正] 类名规范化
    private SingletonDemo() { }
    // 使用 volatile 修饰 INSTANCE,确保写入 INSTANCE 后立即刷新到主内存,
    // 并禁止 INSTANCE = new SingletonDemo(); 这行代码发生指令重排。
    private static volatile SingletonDemo INSTANCE = null; 
  
    public static SingletonDemo getInstance() {
        if(INSTANCE == null) {
            synchronized(SingletonDemo.class) {
                if (INSTANCE == null) { 
                    INSTANCE = new SingletonDemo(); // 这一行会因 volatile 获得内存屏障保护
                }
            }
        }
        return INSTANCE;
    }
}

volatile 的作用

  • INSTANCE = new SingletonDemo(); 发生写操作时,volatile 会插入写屏障,确保在将 INSTANCE 指向内存地址之前,SingletonDemo 对象已经完全初始化完毕。
  • 同时,volatile 保证了 INSTANCE 的可见性,即一旦 INSTANCE 被赋值,其他线程就能立即看到并读取到这个完全初始化的最新值。

通过这种方式,volatile 关键字彻底解决了 DCL 在多线程环境下的线程安全问题。

贡献者

The avatar of contributor named as LI SIR LI SIR

页面历史