Java内存模型与可见性问题
Java内存模型概述
在Java开发中,深入理解Java内存模型(Java Memory Model,JMM)是处理多线程编程时避免各种问题的关键。JMM定义了Java虚拟机(JVM)在计算机内存中的工作方式,它规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
从宏观角度看,JMM是一种抽象的规范,它描述了一组规则来控制Java程序中变量(实例字段、静态字段和构成数组对象的元素)如何存储和访问。JVM将计算机的物理内存抽象成不同的区域,其中主要涉及主内存(Main Memory)和工作内存(Working Memory)。
主内存是所有线程共享的内存区域,存储了Java对象的实例变量、静态变量等。而每个线程都有自己独立的工作内存,工作内存中保存了该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
例如,假设有两个线程 ThreadA
和 ThreadB
,它们共享一个变量 sharedVariable
。ThreadA
首先从主内存中读取 sharedVariable
的值到自己的工作内存中进行操作,操作完成后再将修改后的值写回到主内存。之后,ThreadB
要看到 ThreadA
对 sharedVariable
的修改,就需要从主内存中重新读取该变量的值。
可见性问题的产生
可见性的定义
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在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
时,它会具有以下特性:
- 保证可见性:对一个
volatile
变量的写操作会立即同步到主内存中,而对一个volatile
变量的读操作会直接从主内存中读取,从而确保其他线程能够及时看到最新的值。 - 禁止指令重排序:
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 = 1
和 b = 2
可能会被重排序,先执行 b = 2
,再执行 a = 1
。此时,thread2
在 b == 2
为 true
时,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原则的规则
- 程序顺序规则:在一个线程内,按照程序代码顺序,前面的操作happens - before后续的操作。例如,在一个线程中有代码
int a = 1; int b = a + 1;
,那么int a = 1
这个操作happens - beforeint b = a + 1
这个操作。 - 监视器锁规则:对一个锁的解锁操作happens - before后续对同一个锁的加锁操作。例如,线程A执行
synchronized (obj) { /* 代码块 */ }
释放锁,线程B随后执行synchronized (obj) { /* 代码块 */ }
获取锁,那么线程A在同步块内的操作对线程B在同步块内的操作是可见的。 - volatile变量规则:对一个
volatile
变量的写操作happens - before后续对同一个volatile
变量的读操作。例如,线程A执行volatile int num = 10;
,线程B随后执行int value = num;
,那么线程A对num
的写操作对线程B对num
的读操作是可见的。 - 线程启动规则:主线程A启动子线程B,那么主线程A中启动子线程B之前的操作happens - before子线程B中的所有操作。例如,在主线程中有
Thread thread = new Thread(() -> { /* 子线程代码 */ }); thread.start();
,主线程中thread.start()
之前的操作对thread
子线程中的操作是可见的。 - 线程终止规则:线程中的所有操作happens - before对这个线程的终止检测。例如,线程A执行完毕,在其他线程中通过
threadA.isAlive()
检测线程A是否存活,那么线程A中的所有操作对检测操作是可见的。 - 传递性规则:如果操作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原则:
- 根据程序顺序规则,在
thread1
中num = 10
happens - beforeflag = true
。 - 根据volatile变量规则,
thread1
中flag = true
(写操作)happens - beforethread2
中if (flag)
(读操作)。 - 再根据传递性规则,
thread1
中的num = 10
happens - beforethread2
中的System.out.println("num的值为: " + num);
,所以thread2
能够正确输出num
的值为10
。
总结常见的可见性问题场景及解决方案
- 场景一:多线程对共享变量的读写
- 问题描述:如前面的示例,多个线程对同一个共享变量进行读写操作,由于工作内存和主内存的不一致,导致读线程无法及时获取到写线程对共享变量的修改。
- 解决方案:使用
volatile
关键字声明共享变量,或者使用synchronized
关键字或Lock
接口来同步对共享变量的访问。
- 场景二:指令重排序导致的可见性问题
- 问题描述:在多线程环境下,指令重排序可能使得某些操作的执行顺序不符合预期,从而影响其他线程对数据的可见性。
- 解决方案:利用
volatile
关键字、synchronized
关键字或Lock
接口内部的内存屏障机制来防止指令重排序。
- 场景三:线程启动和终止过程中的可见性问题
- 问题描述:在主线程启动子线程或检测子线程终止时,可能存在主线程对子线程状态变化的可见性问题。
- 解决方案:遵循happens - before原则中的线程启动规则和线程终止规则,确保主线程和子线程之间操作的可见性。
通过深入理解Java内存模型、可见性问题的产生原因以及各种解决方案,开发者能够编写出更加健壮、高效的多线程Java程序,避免因可见性问题导致的各种难以调试的错误。同时,在实际开发中,需要根据具体的场景和需求,选择最合适的方式来解决可见性问题,以平衡性能和正确性。