MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java内存模型与可见性问题

2024-04-074.3k 阅读

Java内存模型概述

在Java开发中,深入理解Java内存模型(Java Memory Model,JMM)是处理多线程编程时避免各种问题的关键。JMM定义了Java虚拟机(JVM)在计算机内存中的工作方式,它规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

从宏观角度看,JMM是一种抽象的规范,它描述了一组规则来控制Java程序中变量(实例字段、静态字段和构成数组对象的元素)如何存储和访问。JVM将计算机的物理内存抽象成不同的区域,其中主要涉及主内存(Main Memory)和工作内存(Working Memory)。

主内存是所有线程共享的内存区域,存储了Java对象的实例变量、静态变量等。而每个线程都有自己独立的工作内存,工作内存中保存了该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

例如,假设有两个线程 ThreadAThreadB,它们共享一个变量 sharedVariableThreadA 首先从主内存中读取 sharedVariable 的值到自己的工作内存中进行操作,操作完成后再将修改后的值写回到主内存。之后,ThreadB 要看到 ThreadAsharedVariable 的修改,就需要从主内存中重新读取该变量的值。

可见性问题的产生

可见性的定义

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在Java多线程编程中,可见性问题是经常出现的,它与JMM的工作方式紧密相关。

产生可见性问题的原因

由于每个线程都有自己的工作内存,当一个线程修改了共享变量在自己工作内存中的副本后,并不会立即将修改同步回主内存。而其他线程此时如果从主内存中读取该变量,就无法获取到最新的值,从而导致可见性问题。

下面通过一段简单的代码示例来演示可见性问题:

public class VisibilityProblem {
    private static boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (!flag) {
                // 线程1持续循环
            }
            System.out.println("线程1结束循环");
        }).start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        flag = true;
        System.out.println("主线程修改flag为true");
    }
}

在上述代码中,主线程启动了一个新线程,新线程在 while 循环中等待 flag 变为 true。主线程在休眠1秒后将 flag 设置为 true。按照预期,新线程应该在主线程修改 flag 后结束循环并输出相应信息。但实际上,在某些情况下,新线程可能会一直循环下去,这就是因为可见性问题导致的。新线程工作内存中的 flag 副本没有及时更新为主线程修改后的值,所以它一直以为 flag 还是 false

解决可见性问题的方法

使用 volatile 关键字

volatile 关键字是解决可见性问题的常用手段之一。当一个变量被声明为 volatile 时,它会具有以下特性:

  1. 保证可见性:对一个 volatile 变量的写操作会立即同步到主内存中,而对一个 volatile 变量的读操作会直接从主内存中读取,从而确保其他线程能够及时看到最新的值。
  2. 禁止指令重排序volatile 变量的读写操作不会与其他指令重排序,这有助于保证程序执行的顺序性。

下面修改前面的代码,使用 volatile 关键字来解决可见性问题:

public class VisibilitySolutionWithVolatile {
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (!flag) {
                // 线程1持续循环
            }
            System.out.println("线程1结束循环");
        }).start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        flag = true;
        System.out.println("主线程修改flag为true");
    }
}

在这个版本中,将 flag 声明为 volatile。这样,当主线程修改 flag 后,新线程能够立即感知到这个变化,从而结束循环并输出相应信息。

使用 synchronized 关键字

synchronized 关键字不仅可以用于实现线程同步,也能解决可见性问题。当一个线程进入 synchronized 块时,它会先从主内存中读取共享变量的值,而在退出 synchronized 块时,会将共享变量的值同步回主内存。

以下是使用 synchronized 解决可见性问题的示例代码:

public class VisibilitySolutionWithSynchronized {
    private static boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                synchronized (VisibilitySolutionWithSynchronized.class) {
                    if (flag) {
                        break;
                    }
                }
            }
            System.out.println("线程1结束循环");
        }).start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        synchronized (VisibilitySolutionWithSynchronized.class) {
            flag = true;
        }
        System.out.println("主线程修改flag为true");
    }
}

在上述代码中,新线程在 while 循环中通过 synchronized 块来检查 flag 的值,主线程也通过 synchronized 块来修改 flag。这样就保证了在 synchronized 块内对 flag 的操作都能正确地同步到主内存,从而解决了可见性问题。

使用 Lock 接口

java.util.concurrent.locks.Lock 接口提供了比 synchronized 更灵活的同步控制,同时也能解决可见性问题。Lock 接口的实现类,如 ReentrantLock,通过 lock()unlock() 方法来控制对共享资源的访问。

当一个线程调用 lock() 方法获取锁时,它会将主内存中的共享变量值刷新到自己的工作内存中。而当线程调用 unlock() 方法释放锁时,会将工作内存中的共享变量值同步回主内存。

以下是使用 ReentrantLock 解决可见性问题的代码示例:

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

public class VisibilitySolutionWithLock {
    private static boolean flag = false;
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                lock.lock();
                try {
                    if (flag) {
                        break;
                    }
                } finally {
                    lock.unlock();
                }
            }
            System.out.println("线程1结束循环");
        }).start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        lock.lock();
        try {
            flag = true;
        } finally {
            lock.unlock();
        }
        System.out.println("主线程修改flag为true");
    }
}

在这个示例中,新线程和主线程通过 ReentrantLock 来同步对 flag 的访问,从而确保了可见性。

Java内存模型中的指令重排序

指令重排序的概念

指令重排序是指JVM在不改变程序执行结果的前提下,为了优化程序性能,对指令的执行顺序进行重新排序。指令重排序可以发生在编译期(编译器优化)和运行期(处理器优化)。

在单线程环境下,指令重排序不会影响程序的最终执行结果,因为编译器和处理器会保证重排序后的执行结果与按照原始顺序执行的结果一致。然而,在多线程环境下,指令重排序可能会导致程序出现意想不到的行为,进而引发可见性问题。

指令重排序的示例

考虑以下代码:

public class InstructionReorderingExample {
    private static int a = 0;
    private static int b = 0;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            a = 1;
            b = 2;
        });

        Thread thread2 = new Thread(() -> {
            if (b == 2) {
                System.out.println("a的值为: " + a);
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在理想情况下,thread1 先执行 a = 1,再执行 b = 2,当 thread2 执行到 if (b == 2)true 时,a 的值应该为 1,输出 a的值为: 1。但由于指令重排序的存在,thread1 中的 a = 1b = 2 可能会被重排序,先执行 b = 2,再执行 a = 1。此时,thread2b == 2true 时,a 的值可能还未被赋值为 1,从而输出 a的值为: 0,这与预期结果不符。

内存屏障与指令重排序的关系

内存屏障(Memory Barrier)是一种CPU指令,它可以阻止指令重排序,并确保特定的内存操作顺序。在Java中,volatile 关键字、synchronized 关键字以及 Lock 接口的实现内部都使用了内存屏障来防止指令重排序。

例如,volatile 关键字通过内存屏障保证了对 volatile 变量的写操作之前的所有普通写操作都同步到主内存,读操作之后的所有普通读操作都从主内存中读取,从而避免了指令重排序对可见性的影响。

深入理解Java内存模型的happens - before原则

happens - before原则的定义

happens - before原则是JMM中定义的一种偏序关系,它用于判断在多线程环境下操作之间的可见性。如果一个操作A happens - before另一个操作B,那么操作A的执行结果对操作B是可见的。

happens - before原则的规则

  1. 程序顺序规则:在一个线程内,按照程序代码顺序,前面的操作happens - before后续的操作。例如,在一个线程中有代码 int a = 1; int b = a + 1;,那么 int a = 1 这个操作happens - before int b = a + 1 这个操作。
  2. 监视器锁规则:对一个锁的解锁操作happens - before后续对同一个锁的加锁操作。例如,线程A执行 synchronized (obj) { /* 代码块 */ } 释放锁,线程B随后执行 synchronized (obj) { /* 代码块 */ } 获取锁,那么线程A在同步块内的操作对线程B在同步块内的操作是可见的。
  3. volatile变量规则:对一个 volatile 变量的写操作happens - before后续对同一个 volatile 变量的读操作。例如,线程A执行 volatile int num = 10;,线程B随后执行 int value = num;,那么线程A对 num 的写操作对线程B对 num 的读操作是可见的。
  4. 线程启动规则:主线程A启动子线程B,那么主线程A中启动子线程B之前的操作happens - before子线程B中的所有操作。例如,在主线程中有 Thread thread = new Thread(() -> { /* 子线程代码 */ }); thread.start();,主线程中 thread.start() 之前的操作对 thread 子线程中的操作是可见的。
  5. 线程终止规则:线程中的所有操作happens - before对这个线程的终止检测。例如,线程A执行完毕,在其他线程中通过 threadA.isAlive() 检测线程A是否存活,那么线程A中的所有操作对检测操作是可见的。
  6. 传递性规则:如果操作A happens - before操作B,操作B happens - before操作C,那么操作A happens - before操作C。

结合happens - before原则分析代码

下面通过一段代码结合happens - before原则进行分析:

public class HappensBeforeExample {
    private static int num = 0;
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            num = 10;
            flag = true;
        });

        Thread thread2 = new Thread(() -> {
            if (flag) {
                System.out.println("num的值为: " + num);
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

根据happens - before原则:

  1. 根据程序顺序规则,在 thread1num = 10 happens - before flag = true
  2. 根据volatile变量规则,thread1flag = true(写操作)happens - before thread2if (flag)(读操作)。
  3. 再根据传递性规则,thread1 中的 num = 10 happens - before thread2 中的 System.out.println("num的值为: " + num);,所以 thread2 能够正确输出 num 的值为 10

总结常见的可见性问题场景及解决方案

  1. 场景一:多线程对共享变量的读写
    • 问题描述:如前面的示例,多个线程对同一个共享变量进行读写操作,由于工作内存和主内存的不一致,导致读线程无法及时获取到写线程对共享变量的修改。
    • 解决方案:使用 volatile 关键字声明共享变量,或者使用 synchronized 关键字或 Lock 接口来同步对共享变量的访问。
  2. 场景二:指令重排序导致的可见性问题
    • 问题描述:在多线程环境下,指令重排序可能使得某些操作的执行顺序不符合预期,从而影响其他线程对数据的可见性。
    • 解决方案:利用 volatile 关键字、synchronized 关键字或 Lock 接口内部的内存屏障机制来防止指令重排序。
  3. 场景三:线程启动和终止过程中的可见性问题
    • 问题描述:在主线程启动子线程或检测子线程终止时,可能存在主线程对子线程状态变化的可见性问题。
    • 解决方案:遵循happens - before原则中的线程启动规则和线程终止规则,确保主线程和子线程之间操作的可见性。

通过深入理解Java内存模型、可见性问题的产生原因以及各种解决方案,开发者能够编写出更加健壮、高效的多线程Java程序,避免因可见性问题导致的各种难以调试的错误。同时,在实际开发中,需要根据具体的场景和需求,选择最合适的方式来解决可见性问题,以平衡性能和正确性。