MST

星途 面试题库

面试题:Java多线程编程中的内存模型与指令重排序对多线程的影响及应对策略

深入阐述Java内存模型(JMM)的工作原理,说明指令重排序在多线程环境下可能引发的问题,以及如何通过volatile关键字、synchronized关键字或其他机制来应对这些问题,结合具体的代码场景和JVM底层原理进行分析。
14.1万 热度难度
编程语言Java

知识考点

AI 面试

面试题答案

一键面试

Java内存模型(JMM)工作原理

  1. 主内存与工作内存
    • Java内存模型将内存分为主内存和工作内存。所有变量都存储在主内存中,而每个线程有自己独立的工作内存。线程对变量的操作(读取、赋值等)都在工作内存中进行,不能直接操作主内存。线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
    • 例如,假设有一个共享变量int num = 0存储在主内存中。线程A要使用num,它会先将num从主内存复制到自己的工作内存,然后在工作内存中对其进行操作。操作完成后,再将num刷新回主内存,这样线程B才能看到线程A对num的修改。
  2. 原子性、可见性和有序性
    • 原子性:原子操作是不可中断的,要么全部执行成功,要么全部执行失败。在Java中,对基本数据类型(除了longdouble)的变量的读取和赋值操作是原子性的,但像i++这种复合操作不是原子性的,因为它包含读取、加1和赋值三个步骤。
    • 可见性:一个线程对共享变量的修改,其他线程能够立刻看到。由于线程操作变量是在工作内存中,所以如果没有特殊处理,一个线程对变量的修改不会立即反映到主内存,其他线程也就看不到。
    • 有序性:JMM允许编译器和处理器对指令进行重排序,但重排序不能影响单线程程序的执行结果。然而在多线程环境下,重排序可能会导致问题。

指令重排序在多线程环境下的问题

  1. 重排序规则
    • 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以对指令进行重排序,以提高程序执行效率。
    • 指令级并行重排序:现代处理器采用指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
    • 内存系统重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
  2. 引发的问题示例
public class ReorderingExample {
    private static int a = 0;
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            a = 1; // 1
            flag = true; // 2
        });

        Thread thread2 = new Thread(() -> {
            while (!flag) {
                Thread.yield();
            }
            System.out.println(a); // 3
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}
  • 在这个例子中,理论上按照代码顺序,线程2执行到System.out.println(a)时,a应该已经被线程1赋值为1。但由于指令重排序,线程1中的a = 1flag = true可能会被重排序,先执行flag = true,此时线程2看到flagtrue,就会打印a的值,而此时a可能还未被赋值为1,导致打印出0,这就出现了不符合预期的结果。

应对问题的机制

  1. volatile关键字
    • 原理volatile关键字保证了变量的可见性,当一个变量被声明为volatile时,任何线程对它的修改都会立即刷新到主内存,并且其他线程在使用该变量时,会从主内存重新读取最新的值。同时,volatile关键字禁止指令重排序,对于volatile变量的写操作,在其前面的操作不能重排序到它后面,对于volatile变量的读操作,在其后面的操作不能重排序到它前面。
    • 示例
public class VolatileExample {
    private volatile static int a = 0;
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            a = 1; // 1
            flag = true; // 2
        });

        Thread thread2 = new Thread(() -> {
            while (!flag) {
                Thread.yield();
            }
            System.out.println(a); // 3
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}
  • 在这个例子中,将a声明为volatile后,指令重排序被禁止,线程2读取a时能保证看到线程1对a的最新赋值,从而打印出1。
  1. synchronized关键字
    • 原理synchronized关键字可以保证同一时刻只有一个线程进入同步块或同步方法,从而保证了原子性。同时,在进入同步块时,会从主内存中读取共享变量的值,在退出同步块时,会将共享变量的值刷新回主内存,保证了可见性。对于有序性,synchronized关键字相当于一个内存屏障,在进入同步块之前的所有写操作都要完成并刷新到主内存,在退出同步块之后的所有读操作都要从主内存重新读取,防止了指令重排序。
    • 示例
public class SynchronizedExample {
    private static int a = 0;
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            synchronized (SynchronizedExample.class) {
                a = 1;
                flag = true;
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (SynchronizedExample.class) {
                while (!flag) {
                    Thread.yield();
                }
                System.out.println(a);
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}
  • 在这个例子中,通过synchronized关键字同步,线程2在进入同步块时能获取到线程1对aflag的最新修改,避免了指令重排序和可见性问题。
  1. 其他机制
    • Lock接口java.util.concurrent.locks.Lock接口提供了比synchronized更灵活的锁机制。它同样可以保证原子性、可见性和有序性。例如ReentrantLock,使用lock()方法获取锁,unlock()方法释放锁,在加锁和解锁之间的代码块具有与synchronized同步块类似的内存语义。
    • Final关键字final关键字修饰的变量一旦初始化完成,就不能再被修改。对于被final修饰的变量,在构造函数中对其初始化后,在其他线程中就能看到正确的值,保证了一定程度的可见性和有序性。例如,一个类的构造函数中对final变量赋值,在构造函数结束后,其他线程可以安全地访问该final变量。