Java内存模型(JMM)工作原理
- 主内存与工作内存
- Java内存模型将内存分为主内存和工作内存。所有变量都存储在主内存中,而每个线程有自己独立的工作内存。线程对变量的操作(读取、赋值等)都在工作内存中进行,不能直接操作主内存。线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
- 例如,假设有一个共享变量
int num = 0
存储在主内存中。线程A要使用num
,它会先将num
从主内存复制到自己的工作内存,然后在工作内存中对其进行操作。操作完成后,再将num
刷新回主内存,这样线程B才能看到线程A对num
的修改。
- 原子性、可见性和有序性
- 原子性:原子操作是不可中断的,要么全部执行成功,要么全部执行失败。在Java中,对基本数据类型(除了
long
和double
)的变量的读取和赋值操作是原子性的,但像i++
这种复合操作不是原子性的,因为它包含读取、加1和赋值三个步骤。
- 可见性:一个线程对共享变量的修改,其他线程能够立刻看到。由于线程操作变量是在工作内存中,所以如果没有特殊处理,一个线程对变量的修改不会立即反映到主内存,其他线程也就看不到。
- 有序性:JMM允许编译器和处理器对指令进行重排序,但重排序不能影响单线程程序的执行结果。然而在多线程环境下,重排序可能会导致问题。
指令重排序在多线程环境下的问题
- 重排序规则
- 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以对指令进行重排序,以提高程序执行效率。
- 指令级并行重排序:现代处理器采用指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
- 引发的问题示例
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 = 1
和flag = true
可能会被重排序,先执行flag = true
,此时线程2看到flag
为true
,就会打印a
的值,而此时a
可能还未被赋值为1,导致打印出0,这就出现了不符合预期的结果。
应对问题的机制
- 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。
- 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对a
和flag
的最新修改,避免了指令重排序和可见性问题。
- 其他机制
- Lock接口:
java.util.concurrent.locks.Lock
接口提供了比synchronized
更灵活的锁机制。它同样可以保证原子性、可见性和有序性。例如ReentrantLock
,使用lock()
方法获取锁,unlock()
方法释放锁,在加锁和解锁之间的代码块具有与synchronized
同步块类似的内存语义。
- Final关键字:
final
关键字修饰的变量一旦初始化完成,就不能再被修改。对于被final
修饰的变量,在构造函数中对其初始化后,在其他线程中就能看到正确的值,保证了一定程度的可见性和有序性。例如,一个类的构造函数中对final
变量赋值,在构造函数结束后,其他线程可以安全地访问该final
变量。