深入理解Java中的JMM(Java Memory Model):解决多线程下的内存可见性

好的,我们开始今天的讲座,主题是深入理解Java中的JMM(Java Memory Model):解决多线程下的内存可见性。

在多线程编程中,我们经常会遇到一些看似“莫名其妙”的问题,比如一个线程修改了变量的值,另一个线程却迟迟无法看到最新的值。这些问题往往与Java内存模型(Java Memory Model,简称JMM)有关。JMM定义了Java程序中各个变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量的底层细节。理解JMM是编写正确、高效并发程序的关键。

一、为什么需要JMM?

要理解JMM存在的必要性,我们需要考虑以下几个因素:

  1. CPU缓存: CPU的运行速度远快于主内存的访问速度。为了平衡这种差异,CPU引入了高速缓存(Cache)。每个CPU核心都有自己的高速缓存,用于存储频繁访问的数据。

  2. 指令重排序: 为了优化性能,编译器和处理器可能会对指令进行重排序。指令重排序是指在不改变程序执行结果的前提下,调整指令的执行顺序。

  3. 多处理器架构: 现代计算机通常是多处理器架构,每个处理器都有自己的CPU和高速缓存。

这些因素结合在一起,就可能导致多线程程序出现内存可见性问题。

二、内存可见性问题

考虑以下示例代码:

public class VisibilityExample {
    private static boolean ready = false;
    private static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (!ready) {
                // 自旋等待
            }
            System.out.println("number = " + number);
        });

        t1.start();

        Thread.sleep(100); // 保证t1先启动

        number = 42;
        ready = true;
    }
}

这段代码的预期行为是:线程t1等待ready变为true,然后打印number的值,应该是42。但是,实际运行结果可能会出乎意料:

  • 程序可能永远循环下去,无法打印任何内容。
  • 程序可能打印出number = 0

这两种情况都源于内存可见性问题。

原因分析:

  1. 缓存不一致性: 主线程修改了readynumber的值,这些修改可能只发生在主线程的CPU缓存中,而线程t1可能从自己的CPU缓存中读取ready的值,或者从过期的主内存副本中读取,导致它无法看到ready的最新值,从而永远循环。

  2. 指令重排序: 编译器或处理器可能会对number = 42;ready = true;进行重排序,先执行ready = true;,再执行number = 42;。这样,线程t1可能看到ready变为true,但number仍然是0。

三、JMM的抽象模型

为了解决上述问题,JMM定义了一个抽象的内存模型,它描述了线程如何与主内存交互。JMM将内存划分为两部分:

  • 主内存 (Main Memory): 所有线程共享的内存区域,存储着Java程序中所有变量的实例。
  • 工作内存 (Working Memory): 每个线程独有的内存区域,存储着该线程使用的变量的副本。工作内存是JMM的一个抽象概念,实际可能对应CPU的寄存器和高速缓存。

线程不能直接访问主内存中的变量,必须将变量从主内存复制到自己的工作内存中,然后才能对变量进行操作。操作完成后,再将变量的修改写回主内存。

JMM定义了线程与主内存之间的交互协议,包括以下操作:

操作 描述
read 从主内存读取变量的值到线程的工作内存。
load read操作从主内存中读取到的变量值放入工作内存中的变量副本。
use 将工作内存中的变量值传递给执行引擎。
assign 将执行引擎处理的结果赋值给工作内存中的变量。
store 将工作内存中的变量值写入主内存。
write store操作从工作内存中得到的变量值放入主内存中的变量副本。
lock 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

JMM的同步规则:

JMM还定义了一些同步规则,用于保证程序的正确性。这些规则规定了线程何时可以读取和写入共享变量。这些规则包括:

  1. 程序次序规则 (Program Order Rule): 在一个线程中,按照代码的顺序,前面的操作先行发生于后面的操作。

  2. 管程锁定规则 (Monitor Lock Rule): 对一个锁的解锁操作先行发生于后续对这个锁的加锁操作。

  3. volatile变量规则 (Volatile Variable Rule): 对一个volatile变量的写操作先行发生于后续对这个volatile变量的读操作。

  4. 线程启动规则 (Thread Start Rule): Thread.start()方法先行发生于该线程的任何操作。

  5. 线程终止规则 (Thread Termination Rule): 线程的所有操作先行发生于该线程的终止检测。可以通过Thread.join()方法结束、isAlive()的返回值等手段检测到线程已经终止执行。

  6. 线程中断规则 (Thread Interruption Rule): 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

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

  8. 传递性 (Transitivity): 如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

四、解决内存可见性问题:volatile关键字

volatile关键字是解决内存可见性问题的一种常用方法。当一个变量被声明为volatile时,JMM会保证:

  1. 可见性:volatile变量的写操作会立即刷新到主内存,并且其他线程对volatile变量的读操作会从主内存中读取最新的值。

  2. 禁止指令重排序: volatile可以防止指令重排序,保证程序的执行顺序与代码的顺序一致。

修改之前的VisibilityExample代码,使用volatile关键字:

public class VolatileVisibilityExample {
    private static volatile boolean ready = false;
    private static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (!ready) {
                // 自旋等待
            }
            System.out.println("number = " + number);
        });

        t1.start();

        Thread.sleep(100); // 保证t1先启动

        number = 42;
        ready = true;
    }
}

现在,程序可以正确地打印出number = 42。因为ready变量被声明为volatile,主线程对ready的修改会立即刷新到主内存,并且线程t1可以从主内存中读取到ready的最新值。同时,volatile禁止了指令重排序,保证了number = 42;一定在ready = true;之前执行。

volatile的实现原理:

volatile的实现原理基于内存屏障(Memory Barrier)。内存屏障是一种CPU指令,用于强制刷新缓存,保证内存的可见性。当编译器遇到volatile关键字时,会在生成代码时插入内存屏障。不同平台和JVM实现可能使用不同的内存屏障指令。

volatile的局限性:

volatile只能保证单个变量的原子性操作。对于复合操作,volatile无法保证原子性。例如:

public class VolatileNotAtomic {
    private static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count++; // count++ 不是原子操作
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("count = " + count); // 结果可能小于 10000
    }
}

即使count被声明为volatile,程序的结果仍然可能小于10000。因为count++操作实际上包含了三个步骤:

  1. 读取count的值。
  2. count的值加1。
  3. 将结果写回count

这三个步骤不是原子性的,在多线程环境下,可能会发生竞态条件。

五、解决原子性问题:synchronized和Lock

要解决复合操作的原子性问题,可以使用synchronized关键字或Lock接口。

synchronized:

synchronized关键字可以用来修饰方法或代码块,保证同一时刻只有一个线程可以访问被synchronized修饰的代码。

修改之前的VolatileNotAtomic代码,使用synchronized关键字:

public class SynchronizedAtomic {
    private static volatile int count = 0;

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

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increment();
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("count = " + count); // 结果总是 10000
    }
}

现在,程序的结果总是10000。因为increment()方法被声明为synchronized,保证了count++操作的原子性。

Lock:

Lock接口提供了比synchronized更灵活的锁定机制。Lock接口的常用实现类包括ReentrantLock

修改之前的VolatileNotAtomic代码,使用ReentrantLock

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockAtomic {
    private static volatile int count = 0;
    private static Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    lock.lock();
                    try {
                        count++;
                    } finally {
                        lock.unlock();
                    }
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("count = " + count); // 结果总是 10000
    }
}

同样,程序的结果总是10000。lock.lock()获取锁,lock.unlock()释放锁,保证了count++操作的原子性。

synchronized和Lock的区别:

特性 synchronized Lock
灵活性 较差,隐式锁 更好,显式锁,可以实现更复杂的锁定策略。
可中断性 不可中断,除非抛出异常或正常结束。 可以中断,可以通过lockInterruptibly()方法响应中断。
公平性 非公平锁(默认),也可以设置为公平锁。 可以设置为公平锁或非公平锁。
性能 在JDK 1.6之后,性能得到了很大提升,甚至优于Lock。 在某些情况下,性能可能优于synchronized,特别是在竞争激烈的情况下。
异常处理 自动释放锁,即使发生异常。 需要手动释放锁,必须在finally块中释放锁,防止死锁。

六、Happens-Before原则

Happens-Before原则是JMM的核心概念,它定义了两个操作之间的happens-before关系,如果一个操作happens-before另一个操作,那么第一个操作的结果对第二个操作可见。

Happens-Before关系并不意味着第一个操作必须在第二个操作之前执行,它仅仅意味着第一个操作的结果对第二个操作可见。编译器和处理器可以对指令进行重排序,只要不违反Happens-Before原则即可。

例如,根据程序次序规则,在一个线程中,前面的操作happens-before后面的操作。这意味着,如果在一个线程中,先执行number = 42;,再执行ready = true;,那么number = 42;的结果对ready = true;可见。

七、总结

JMM定义了Java程序中各个变量的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量的底层细节。理解JMM是编写正确、高效并发程序的关键。通过使用volatile关键字、synchronized关键字或Lock接口,可以解决多线程环境下的内存可见性和原子性问题。Happens-Before原则是JMM的核心概念,它定义了两个操作之间的happens-before关系,如果一个操作happens-before另一个操作,那么第一个操作的结果对第二个操作可见。

发表回复

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