Java `Memory Model` (JMM) `Happens-Before` 规则与并发编程中的可见性、有序性保证

各位靓仔靓女,晚上好!我是你们今晚的并发编程向导,今天咱们要聊聊Java内存模型(JMM)中的“Happens-Before”规则。这玩意儿听起来挺高大上,但其实就是告诉你,在并发编程中,哪些操作“一定”发生在哪些操作之前。搞清楚它,你才能写出靠谱的多线程代码,避免那些神出鬼没的Bug。

一、开场白:并发编程的灵魂拷问

想象一下,你和你的小伙伴同时往一个账户里存钱。你存了100,他存了200。理想情况下,账户最终应该有你们的总和,也就是300。但如果你们的代码没写好,并发执行的时候,可能出现各种奇葩情况:

  • 账户最后只有100?
  • 账户最后竟然是0?
  • 偶尔是300,偶尔不是?

这些都是并发编程中常见的“数据竞争”问题。问题的根源在于:

  1. 可见性: 你的小伙伴存了钱,你真的能立即看到账户的变化吗?
  2. 有序性: 你的代码是按照你写的顺序执行的吗?编译器和CPU可能会优化你的代码,导致执行顺序和你想象的不一样。

JMM就是用来解决这些问题的。它定义了一套规则,告诉编译器和CPU,哪些事情必须保证可见性,哪些事情必须保证有序性。而“Happens-Before”规则,就是这套规则的核心。

二、什么是Happens-Before?(别被名字吓到)

“Happens-Before”并不是说A操作真的在时间上发生在B操作之前,而是一种偏序关系,它表达的是一种可见性和有序性的保证。如果A happens-before B,那么:

  1. A操作的结果对B操作可见(可见性)。
  2. A操作的执行顺序在B操作之前(有序性)。

你可以把Happens-Before理解为一种承诺:JMM承诺,如果A happens-before B,那么A的执行结果一定会被B看到,而且A一定在B之前执行(从B的角度来看)。

三、Happens-Before 规则,一个都不能少

JMM定义了一系列Happens-Before规则,这些规则就像并发编程的法律,你必须遵守。下面我们来一一解读:

  1. 程序顺序规则(Program Order Rule): 在一个线程内,按照程序代码的顺序,书写在前面的操作 happens-before 书写在后面的操作。

    • 人话: 你代码怎么写的,就怎么执行。
    int a = 1; // 操作A
    int b = 2; // 操作B
    System.out.println(a + b); // 操作C

    在这个例子中,A happens-before B,B happens-before C。所以,a一定先赋值,b一定后赋值,最后才能计算a+b。

  2. 管程锁定规则(Monitor Lock Rule): 对一个锁的解锁 happens-before 后面对同一个锁的加锁。

    • 人话: 你解了锁,别人才能拿到锁,才能看到你解锁之前的操作。
    private static int x = 0;
    private static final Object lock = new Object();
    
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            synchronized (lock) { // 加锁
                x = 10; // 操作A
            } // 解锁
        });
    
        Thread thread2 = new Thread(() -> {
            synchronized (lock) { // 加锁
                System.out.println(x); // 操作B
            }
        });
    
        thread1.start();
        thread1.join(); // 等待thread1执行完毕
        thread2.start();
    }

    在这个例子中,thread1解锁 happens-before thread2加锁。所以,thread2一定能看到thread1对x的修改。如果没有这个规则,thread2可能读到x的旧值。

  3. volatile 变量规则(Volatile Variable Rule): 对一个 volatile 变量的写操作 happens-before 后面对这个 volatile 变量的读操作。

    • 人话: 你写了volatile变量,别人就能立即看到。
    private volatile static int x = 0;
    
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            x = 10; // 操作A (volatile写)
        });
    
        Thread thread2 = new Thread(() -> {
            System.out.println(x); // 操作B (volatile读)
        });
    
        thread1.start();
        Thread.sleep(100); // 确保thread1先执行
        thread2.start();
    }

    在这个例子中,thread1对x的volatile写 happens-before thread2对x的volatile读。所以,thread2一定能看到thread1对x的修改。volatile保证了可见性。

  4. 线程启动规则(Thread Start Rule): Thread 对象的 start() 方法 happens-before 此线程的每一个动作。

    • 人话: 线程启动之后,才能执行里面的代码。
    private static int x = 0;
    
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            x = 10; // 操作A
        });
    
        thread.start(); // 操作B
        thread.join();
        System.out.println(x);
    }

    在这个例子中,thread.start() happens-before 线程中x=10的操作。所以,x一定会被赋值。

  5. 线程终止规则(Thread Termination Rule): 线程中的所有操作 happens-before 此线程的终止(Thread.join()或者Thread.isAlive()的返回值)。

    • 人话: 线程结束之后,才能知道它干了什么。
    private static int x = 0;
    
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            x = 10; // 操作A
        });
    
        thread.start();
        thread.join(); // 操作B
        System.out.println(x);
    }

    在这个例子中,线程中x=10的操作 happens-before thread.join()。所以,主线程一定能看到线程对x的修改。

  6. 线程中断规则(Thread Interruption Rule): 对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生(通过 isInterrupted() 方法)。

    • 人话: 你中断了线程,它才知道自己被中断了。
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                // 执行一些任务
                System.out.println("Running...");
            }
            System.out.println("Interrupted!");
        });
    
        thread.start();
        Thread.sleep(100);
        thread.interrupt(); // 操作A
        thread.join();
    }

    在这个例子中,thread.interrupt() happens-before 线程中isInterrupted()的判断。所以,线程最终会退出循环。

  7. 对象终结规则(Finalizer Rule): 一个对象的初始化完成(构造函数执行结束) happens-before finalize() 方法的开始。

    • 人话: 对象构造完成,才能被垃圾回收。这个规则比较少用,了解即可。
  8. 传递性(Transitivity): 如果 A happens-before B,B happens-before C,那么 A happens-before C。

    • 人话: Happens-Before关系可以传递。

    这个规则非常重要,它可以把多个Happens-Before关系串联起来,形成更复杂的可见性和有序性保证。

四、Happens-Before规则的应用:解决并发问题

掌握了Happens-Before规则,我们就可以利用它来解决并发编程中的各种问题。

案例1:Double-Checked Locking(DCL)单例模式

DCL单例模式是一种常用的单例模式实现方式,但如果实现不当,可能会出现线程安全问题。

public class Singleton {
    private static 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. 初始化 Singleton 对象。
  3. 将 instance 指向分配的内存地址。

由于指令重排序的存在,步骤2和步骤3的顺序可能被打乱。如果线程A执行了步骤1和步骤3,但还没有执行步骤2,此时线程B判断instance不为null,直接返回instance,但此时instance指向的对象还没有被初始化,线程B就会拿到一个未初始化的对象。

如何解决?

使用 volatile 关键字:

public class Singleton {
    private volatile static Singleton instance; // 使用volatile

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatile 关键字可以禁止指令重排序,保证步骤2一定在步骤3之前执行。根据volatile变量规则,对volatile变量的写操作 happens-before 后面对这个 volatile 变量的读操作。这样就保证了线程B一定能拿到一个初始化完成的对象。

案例2:线程安全的计数器

实现一个线程安全的计数器,可以使用锁或者AtomicInteger。

使用锁:

public class Counter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {
            count++;
        }
    }

    public int getCount() {
        synchronized (lock) {
            return count;
        }
    }
}

在这个例子中,我们使用了锁来保证count的原子性操作。根据管程锁定规则,对一个锁的解锁 happens-before 后面对同一个锁的加锁。所以,每次increment之后,其他线程都能看到最新的count值。

使用AtomicInteger:

import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

AtomicInteger内部使用了CAS(Compare and Swap)操作,CAS操作是一种原子操作,可以保证count的原子性操作。AtomicInteger的get操作可以保证读取到最新的count值,因为它内部使用了volatile关键字。

五、总结:Happens-Before,并发编程的指南针

Happens-Before规则是JMM的核心,它为我们编写线程安全的代码提供了重要的保证。理解并掌握这些规则,可以帮助我们避免很多并发编程中的陷阱,写出更健壮、更可靠的多线程程序。

Happens-Before 规则 说明
程序顺序规则 在一个线程内,按照程序代码的顺序,书写在前面的操作 happens-before 书写在后面的操作。
管程锁定规则 对一个锁的解锁 happens-before 后面对同一个锁的加锁。
volatile 变量规则 对一个 volatile 变量的写操作 happens-before 后面对这个 volatile 变量的读操作。
线程启动规则 Thread 对象的 start() 方法 happens-before 此线程的每一个动作。
线程终止规则 线程中的所有操作 happens-before 此线程的终止。
线程中断规则 对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。
对象终结规则 一个对象的初始化完成 happens-before finalize() 方法的开始。
传递性 如果 A happens-before B,B happens-before C,那么 A happens-before C。

最后,记住: 并发编程是一门复杂的艺术,需要不断学习和实践。希望今天的讲座能帮助你更好地理解Java内存模型和Happens-Before规则,让你在并发编程的道路上越走越远!感谢大家的聆听!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注