面试题答案
一键面试1. 指令重排在单线程和多线程环境下的表现
- 单线程环境:
- 指令重排是指编译器和处理器为了优化程序性能,在不改变程序单线程语义的前提下,对指令进行重新排序。
- 由于单线程程序的执行顺序不会对最终结果产生影响(只要符合单线程语义),所以在单线程环境下,指令重排能够提高程序的执行效率。例如,对于以下代码:
int a = 1;
int b = 2;
int c = a + b;
编译器可能会重排为:
int b = 2;
int a = 1;
int c = a + b;
这种重排不影响最终 c
的值,因为单线程中这些操作是顺序执行且无依赖关系。
- 多线程环境:
- 多线程环境下,指令重排可能会导致程序出现可见性问题。因为不同线程对共享变量的操作顺序可能因为指令重排而与程序编写的顺序不同。
- 例如,有两个线程
Thread1
和Thread2
,共享变量x
和y
:
class SharedData {
static int x = 0;
static int y = 0;
}
// Thread1
class Thread1 extends Thread {
@Override
public void run() {
SharedData.x = 1;
SharedData.y = 2;
}
}
// Thread2
class Thread2 extends Thread {
@Override
public void run() {
if (SharedData.y == 2) {
System.out.println(SharedData.x);
}
}
}
在理想情况下,当 Thread2
中 SharedData.y == 2
时,SharedData.x
应该为 1。但由于指令重排,Thread1
中 SharedData.x = 1
和 SharedData.y = 2
的顺序可能被重排,导致 Thread2
中 SharedData.y == 2
成立时,SharedData.x
仍为 0,这就出现了可见性问题。
2. Java 通过内存屏障禁止特定类型的指令重排保证可见性
- 内存屏障概念:内存屏障是一种CPU指令,它可以阻止屏障两侧的指令重排序,并且会刷新处理器缓存,使得对共享变量的修改能及时对其他处理器可见。
- Java中的内存屏障实现:
- LoadLoad屏障:
Load1; LoadLoad; Load2
,确保Load1
数据的装载先于Load2
及所有后续装载指令的装载。 - StoreStore屏障:
Store1; StoreStore; Store2
,确保Store1
数据对其他处理器可见(刷新到内存)先于Store2
及所有后续存储指令的存储。 - LoadStore屏障:
Load1; LoadStore; Store2
,确保Load1
数据装载先于Store2
及所有后续存储指令刷新到内存。 - StoreLoad屏障:
Store1; StoreLoad; Load2
,确保Store1
数据对其他处理器可见(刷新到内存)先于Load2
及所有后续装载指令的装载。这是开销最大的屏障,因为它同时包含了存储和装载操作。 - 在Java中,
volatile
关键字就利用了内存屏障。当一个变量被声明为volatile
时,对这个变量的读操作前会插入LoadLoad
和LoadStore
屏障,写操作后会插入StoreStore
和StoreLoad
屏障,从而禁止了特定类型的指令重排,保证了可见性。
- LoadLoad屏障:
3. 实际应用中不恰当处理指令重排导致的可见性问题及后果
- 问题举例:以双重检查锁定(Double - Checked Locking,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();
这行代码并非原子操作,它实际上分为三步:- 分配内存空间。
- 初始化对象。
- 将
instance
指向分配的内存地址。
- 由于指令重排,可能会出现步骤2和步骤3顺序颠倒的情况。当一个线程执行到
instance = new Singleton();
时,若发生指令重排,先执行了步骤3,此时instance
已经非空,但对象还未初始化完成。如果另一个线程此时判断instance!= null
并直接返回instance
,就会使用到未初始化完成的对象,导致程序出错。 - 后果:这种可见性问题可能导致程序出现难以调试的错误,如空指针异常、数据不一致等。这些错误往往在并发场景下随机出现,很难定位和修复,严重影响程序的稳定性和可靠性。