跳转至

12 JMM and Troubleshooting

说明

本文档仅涉及部分内容,仅可用于复习重点知识

1 Java Memory Model

JMM 是 Java 虚拟机规范的一部分,它是一个抽象的概念,不是物理上的内存划分。它的核心目标是定义一套规则,来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,从而解决多线程环境下的可见性、原子性和有序性问题

在现代多核处理器中,为了提高性能,每个 CPU 核心都有自己的高速缓存(Cache)。线程在执行时,会把主内存(Main Memory)中的数据拷贝一份到自己的工作内存(Working Memory,可以理解为 CPU 缓存和寄存器)中

这带来了三个核心问题:

  1. 可见性 (Visibility):一个线程修改了共享变量的值,但这个新值只存在于它自己的工作内存中,还没有被写回主内存。此时,其他线程读取这个变量时,读到的仍然是主内存中的旧值,导致数据不一致
  2. 原子性 (Atomicity):一个或多个操作,要么全部执行且执行过程不被任何因素打断,要么就都不执行。像 i++ 这样的操作在 Java 中看起来是一行,但实际上它包含了“读取 i 的值”、“将值加 1”、“将新值写回”三个步骤,它不是原子的,在多线程环境下可能被中断
  3. 有序性 (Ordering):为了优化性能,编译器和处理器可能会对指令进行重排序(Instruction Reordering)。在单线程环境下,重排序不会影响最终结果。但在多线程环境下,这种重排序可能会打乱程序原有的逻辑顺序,导致意想不到的错误

JMM 的存在,就是为了给开发者提供一套保证,告诉我们如何编写代码才能确保在多线程环境下的可见性、原子性和有序性

JMM 定义了线程和主内存之间的抽象关系:

  1. 主内存 (Main Memory):是所有线程共享的区域,存储了所有的实例字段、静态字段和构成数组对象的元素
  2. 工作内存 (Working Memory):是每个线程私有的数据区域。它存储了该线程使用的变量在主内存中的副本拷贝。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量

线程间的通信必须通过主内存来完成。线程 A 把其工作内存中更新过的共享变量的值刷新到主内存中,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量

1.1 Happens-Before

为了让开发者更容易地理解和编写线程安全的代码,JMM 提出了一套先行发生(Happens-Before)原则。这是判断数据是否存在竞争、线程是否安全的主要依据

JMM 定义了以下 8 条天然的 Happens-Before 规则,我们无需任何同步措施就能依赖它们:

  1. 程序次序规则 (Program Order Rule):在一个线程内,按照代码的先后顺序,书写在前面的操作先行发生于书写在后面的操作
  2. 管程锁定规则 (Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这就是 synchronized 保证可见性的原因
  3. volatile 变量规则 (Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。这就是 volatile 保证可见性的原因
  4. 线程启动规则 (Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作
  5. 线程终止规则 (Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,例如 Thread.join() 方法的返回
  6. 线程中断规则 (Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  7. 对象终结规则 (Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
  8. 传递性 (Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么可以得出操作 A 先行发生于操作 C

2 Volatile

volatile 是 Java 提供的一个轻量级的同步机制。当一个变量被声明为 volatile 时,JMM 会为这个变量提供两种特殊的保证:可见性和有序性

volatile 不保证原子性

2.1 Visibility

  • 写操作:当一个线程修改一个 volatile 变量的值时,JMM 会立即将这个修改后的新值从该线程的工作内存(CPU 缓存)强制刷新到主内存中
  • 读操作:当一个线程读取一个 volatile 变量时,JMM 会强制该线程使其本地工作内存中该变量的缓存失效,然后直接从主内存中重新读取最新的值

通过这一写一读的强制规定,volatile 确保了任何线程在任何时间点读取一个 volatile 变量,看到的都是其他线程对这个变量的最新写入结果,从而解决了多线程间的可见性问题

停止标志

一个线程负责执行任务,另一个线程负责在某个时刻停止它

class StoppableTask implements Runnable {
    // 如果没有 volatile,t2 可能永远看不到 stop 变为 true
    private volatile boolean stop = false;

    public void stop() {
        this.stop = true;
    }

    @Override
    public void run() {
        while (!stop) {
            // do some work...
        }
        System.out.println("任务停止了。");
    }
}

public class VolatileDemo {
    public static void main(String[] args) throws InterruptedException {
        StoppableTask task = new StoppableTask();
        Thread t1 = new Thread(task);
        t1.start();

        Thread.sleep(1000); // 让 t1 运行一会儿

        System.out.println("主线程请求停止任务...");
        task.stop(); // t2 (主线程) 修改 stop 变量
    }
}

如果没有 volatilet1 线程为了优化性能,可能会将 stop 变量缓存到自己的工作内存中。即使主线程修改了主内存中的 stoptruet1 也可能一直读取自己缓存中的 false,导致循环无法停止

2.2 Ordering

为了提高性能,编译器和处理器可能会对指令进行重排序。volatile 关键字可以禁止特定类型的指令重排序,从而保证一定程度的有序性

volatile 通过插入内存屏障 (Memory Barrier) 来实现这一点:

  1. 在对 volatile 变量的写操作之前,会插入一个“Store”屏障。这保证了所有在 volatile 写之前的普通写操作都已经完成,并且其结果对其他处理器可见
  2. 在对 volatile 变量的读操作之后,会插入一个“Load”屏障。这保证了所有在 volatile 读之后的普通读操作都能读到最新的值

简单来说,就是:当程序执行到 volatile 变量的读操作或写操作时,其前面的所有普通操作都已经执行完毕,且结果已对其他线程可见。其后面的所有普通操作都还没有执行

双重检查锁定

Double-Checked Locking,DCL 单例模式

class Singleton {
    // 必须使用 volatile
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton(); // 非原子操作
                }
            }
        }
        return instance;
    }
}

instance = new Singleton() 这行代码不是原子的,它大致分为三步:

  1. 分配内存空间
  2. 初始化对象(调用构造函数)
  3. instance 引用指向分配的内存地址

如果没有 volatile,步骤 2 和 3 可能会被重排序。一个线程可能先执行了第 3 步(instance 已经不为 null),但第 2 步(对象初始化)还没完成。此时另一个线程执行到第一次检查,发现 instance 不为 null,就会直接返回一个尚未完全构造好的对象,从而导致错误。volatile 禁止了这种重排序,保证了对象一定是在完全初始化之后,引用才会被赋值

2.3 Full Volatile Visibility Guarantee

当一个线程写入一个 volatile 变量时,JMM 会确保,该线程在写入 volatile 变量之前对所有其他普通变量的修改,都会随着这个 volatile 变量的写入而一起被刷新到主内存中

相应地,当一个线程读取这个 volatile 变量时,JMM 会确保,它也会从主内存中重新加载在 volatile 变量写入之前被修改过的那些普通变量的值

简单来说,volatile 变量的读/写操作就像一个内存同步的开关,它不仅同步自己,还顺便同步了在它之前发生的所有其他变量的修改

示例

public class FullVolatileVisibility {
    int a = 0;
    boolean flag = false; // 普通变量
    volatile int v = 0;   // volatile 变量

    public void writer() {
        a = 1;          // 1. 修改普通变量 a
        flag = true;    // 2. 修改普通变量 flag
        v = 1;          // 3. 修改 volatile 变量 v (内存屏障)
        System.out.println("Writer: a=" + a + ", flag=" + flag + ", v=" + v);
    }

    public void reader() {
        // 4. 读取 volatile 变量 v
        if (v == 1) {
            // 5. 如果读到了 v 的新值,那么 a 和 flag 的新值也一定可见
            System.out.println("Reader: a=" + a + ", flag=" + flag + ", v=" + v);
            // 在这里,a 的值保证是 1, flag 的值保证是 true
        } else {
            System.out.println("Reader: v 尚未更新");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        FullVolatileVisibility demo = new FullVolatileVisibility();

        new Thread(demo::writer).start();
        Thread.sleep(100); // 确保 writer 先执行
        new Thread(demo::reader).start();
    }
}

volatile 修饰类对象

volatile 修饰一个对象引用时,它只保证引用本身的可见性和有序性,而不保证对象内部字段的可见性和有序性

3 Singleton

单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点来获取这个唯一的实例

实现单例模式的两条基本规则:

  1. 将构造函数私有化:为了防止外部代码通过 new 关键字随意创建该类的多个实例。既然目标是单例,就必须把创建实例的权力控制在类的内部
  2. 提供一个静态方法来获取那个唯一的对象:既然构造函数是私有的,外部代码就需要一个公共的、统一的入口来获取这个唯一的实例。这个静态方法(通常命名为 getInstance())就扮演了这个角色

单例模式有两种主要实现方式,也对应着两种不同的实例化时机

3.1 Eager Initialization

类加载时实例化:在类被加载到 JVM 时,就立即创建单例的实例。这个实例作为一个静态成员变量存在,无论后续是否有人调用 getInstance(),它都已经存在了

优点:实现简单,并且是天然线程安全的,因为类的加载和初始化过程由 JVM 保证是线程安全的

缺点:如果这个单例对象很耗费资源,并且在程序运行期间一直没有被使用,那么就会造成内存浪费

public class EagerSingleton {
    // 1. 类加载时就创建实例
    private static final EagerSingleton INSTANCE = new EagerSingleton();

    // 2. 私有化构造函数
    private EagerSingleton() {}

    // 3. 提供全局访问点
    public static EagerSingleton getInstance() {
        return INSTANCE;
    }
}

3.2 Lazy Initialization

首次获取时实例化:不在类加载时创建实例,而是在 getInstance() 方法第一次被调用时才创建

优点:实现了延迟加载,避免了不必要的资源消耗。只有在真正需要时才创建实例

缺点:需要在多线程环境下手动处理线程安全问题,否则可能会创建出多个实例

public class LazySingleton {
    // 1. 使用 volatile 保证可见性和有序性
    private static volatile LazySingleton instance;

    // 2. 私有化构造函数
    private LazySingleton() {}

    // 3. 提供全局访问点
    public static LazySingleton getInstance() {
        // 第一次检查,避免不必要的同步
        if (instance == null) {
            // 同步块,保证线程安全
            synchronized (LazySingleton.class) {
                // 第二次检查,防止多个线程同时进入同步块
                if (instance == null) {
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }
}

第一次检查:synchronized 关键字虽然能保证线程安全,但它是一个重量级的操作。当一个线程进入 synchronized 代码块时,它需要获取锁,这可能会导致其他试图进入的线程被阻塞,从而引起上下文切换,带来性能开销

  1. 首次创建后:一旦单例对象 instance 被成功创建一次,它就不再是 null
  2. 后续调用:之后成千上万次的 getInstance() 调用,都会执行到这个第一次检查
  3. 性能提升:由于 instance 已经不是 null,这些后续的调用会直接通过这个 if 判断,然后立即返回已创建好的实例。它们完全不需要进入 synchronized 代码块,也就不需要去竞争锁,从而极大地提高了在高并发环境下的访问性能

第二次检查:第一次检查虽然优化了性能,但它本身不是线程安全的。在 instance 尚未被创建时,多个线程可能同时通过第一次检查

  1. 初始状态:instancenull
  2. 线程 A 和 线程 B 同时调用 getInstance()
  3. 它们都执行第一次检查 if (instance == null),发现结果为 true,于是它们都准备进入 synchronized 代码块
  4. 线程 A 首先获得了锁,进入了同步块
  5. 线程 B 没有获得锁,在同步块外面等待
  6. 线程 A 执行第二次检查 if (instance == null),发现结果为 true,于是它创建了一个 Singleton 实例,然后退出同步块并释放锁
  7. 此时,线程 B 终于获得了锁,进入了同步块
  8. 如果没有第二次检查,线程 B 会再次执行 instance = new LazySingleton(),从而创建了第二个 Singleton 实例。这就违背了单例模式的原则
  9. 如果有第二次检查:线程 B 在进入同步块后,会先执行第二次检查 if (instance == null)。但此时 instance 已经被线程 A 创建好了,不再是 null。因此,这个检查结果为 false,线程 B 不会执行创建实例的代码,直接跳出 if 语句,然后退出同步块

评论区

欢迎在评论区指出文档错误,为文档提供宝贵意见,或写下你的疑问