一、java中的 JMM内存模型 指的是什么?
JMM(Java Memory Model)本身是一种抽象的概念并不真实存在它仅仅描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程)对于各个变量(包括实例字段、静态字段和数组元素)在内存中的读写访问方式并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见,关键技术点都是国绕多线程的 原子性、可见性 和 有序性展开的。
二、JMM的三大特性
1、 原子性:
原子性指的是一个操作或一组操作要么全部执行并且中间不被打断,要么全部不执行。在多线程环境中,原子操作是不可分割的,即在一个操作完成之前,其他线程不能访问和修改相同的资源。
对于32位的基本类型(如int),其读取和写入操作是原子的。这意味着当一个线程执行读取或写入操作时,这个操作不会被其他线程打断。但是需要注意的是,对于64位的long和double类型,在默认情况下,它们的操作不是原子性的,除非使用了volatile关键字或其他同步机制。
虽然基本类型的读取和写入操作是原子,但是对于不是单个的读取或者写入操作的话,那也不符合原子性,比如复合操作,举个例子:
public class IncrementExample {
private int num = 0;
public static void main(String[] args) throws InterruptedException {
IncrementExample example = new IncrementExample();
Thread threadA = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread threadB = new Thread(() -> {
for (int i = 0; i < 5; i++) {
example.increment();
}
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("Final count: " + example.getNum());
}
public void increment() {
num++; // 复合操作
}
public int getNum() {
return num; // 读取操作是原子的
}
}
在这个例子中,increment方法中的num++是一个复合操作,它包括读取、修改和写回三个步骤。由于这些步骤作为一个整体不是原子的,因此在多线程环境下可能会导致数据不一致。
那如何保证复合操作的原子性呢?
可以通过同步机制,也就是加锁确保复合操作作为一个整体不会被其他线程打断,从而避免数据竞争和不一致性问题。例如在复合操作的方法上面加一个 synchronized 关键字:
public synchronized void increment() {
num++; // 复合操作
}
2、可见性:
可见性指的是一个线程对共享变量的修改能够及时对其他线程可见。
3、有序性:
有序性指的是程序执行的顺序按照代码的先后顺序执行。然而,在现代处理器中,为了提高执行效率,指令可能会被重新排序。JMM通过引入内存屏障(Memory Barrier)来保证某些操作的执行顺序。内存屏障是一种特殊的指令,用于阻止编译器和处理器的优化,从而确保某些操作的执行顺序不会被改变。
(3.1)指令重排
指令重排指的是编译器和处理器为了优化性能而调整指令执行顺序的行为。在单线程环境中,这种重排序通常是安全的,但在多线程环境中,不当的重排序可能导致数据不一致和其他并发问题。
我们将用一个简单的例子来说明指令重排可能导致的问题:假设我们有两个线程,Thread A 和 Thread B,它们共享两个变量 x 和 y。
public class InstructionReorderingExample {
private volatile int x = 0;
private volatile int y = 0;
public static void main(String[] args) {
final InstructionReorderingExample example = new InstructionReorderingExample();
new Thread(() -> {
example.x = 1; // 操作 1
example.y = 2; // 操作 2
}).start();
new Thread(() -> {
if (example.x == 1) {
System.out.println("x is 1");
}
if (example.y == 2) {
System.out.println("y is 2");
}
}).start();
}
}
按照代码的顺序,期望的执行应该是这样的:
1.Thread A: x = 1
3.Thread A: y = 2
4.Thread B: 检查 x 是否为 1
5.Thread B: 检查 y 是否为 2
然而,在某些情况下,由于编译器或处理器的优化,指令可能会被重排。假设发生以下重排序:
1.Thread A: y = 2
2.Thread A: x = 1
3.Thread B: 检查 x 是否为 1
4.Thread B: 检查 y 是否为 2
在这种情况下,Thread B 可能会先检查 x 是否为 1,此时 x 还未被设置为 1(因为 x = 1 被推迟执行),所以Thread B 可能会认为 x 仍然是 0。接着 Thread A 执行 x = 1,但此时 Thread B 已经完成了检查,所以它不会看到 x 的更新。接下来 Thread B 检查 y 是否为 2,但由于 y 已经被设置为 2,所以它会输出 "y is 2",而不会输出 "x is 1"。
结果分析
在上述重排序的情况下,即使 x 最终被设置为 1,由于 Thread B 已经提前检查了 x,它没有看到 x 的更新,这会导致不正确的输出结果。这就是指令重排可能导致的问题之一。
三、主内存与工作内存
-
主内存:(Main Memory)
主内存是Java内存模型中所有线程都可以访问的一个共享区域。所有的变量(类实例字段、数组元素等)都存储在主内存中。主内存中的变量对所有线程都是可见的,这意味着任何线程都可以读取或修改这些变量的值。然而,直接从主内存读取或写入变量可能是低效的,因此引入了工作内存的概念。 -
工作内存(Working Memory)
每个线程的工作内存是一个私有的存储空间,它保存了该线程使用的变量的副本。线程对变量的所有操作都必须在自己的工作内存中进行,而不能直接操作主内存中的变量。这意味着每个线程都只能看到自己工作内存中的变量副本,而不是主内存中的真实值。这种设计提高了并发操作的性能,但也带来了复杂性,因为不同线程之间的工作内存中的变量副本可能不同步。
四、先行发生原则(Happens-Before Principle)
先行发生原则是一种偏序关系,它定义了多线程程序中不同操作之间的部分顺序。如果一个操作A先行发生于操作B,意味着操作A的结果对于操作B来说是可见的,并且操作B在逻辑上发生在操作A之后。这种关系有助于确保多线程程序中操作的有序性和数据的一致性。
先行发生原则本身是由Java内存模型定义的一组规则,它不需要你在代码中显式地去实现。相反,你需要根据这些规则来编写你的代码,确保符合先行发生原则的要求。
-
先行发生原则的作用:
1. 确保可见性:确保一个线程对共享变量的修改对于其他线程来说是可见的。
2. 保持有序性:确保某些操作的执行顺序,防止由于编译器或处理器的优化导致的操作顺序混乱。
3. 简化并发编程:通过提供一组清晰的规则,帮助开发者更容易地编写线程安全的代码。
4. 解决内存一致性问题:多线程环境下,不同线程可能会并发访问共享数据,如果没有适当的机制来保证操作的顺序,就可能导致数据不一致。
5. 避免竞态条件:先行发生原则帮助避免由于操作顺序不确定导致的竞态条件,确保程序的正确执行。
什么是竞态条件?
竞态条件(Race Condition)是并发编程中常见的一种问题,它指的是在多线程或多进程环境中,当两个或多个线程同时访问共享资源,并且至少一个线程修改该资源时,如果这些线程在没有适当同步的情况下执行,就可能导致程序行为的不确定性或错误结果。
-
先行发生原则定义了以下几种情况下的操作顺序:
1. 程序顺序规则:一个线程中的操作按照代码的顺序执行。
2. 锁定规则:一个unlock操作先行发生于后续对同一个锁的lock操作。
3. volatile 变量规则:对一个volatile变量的写入操作先行发生于后续对这个volatile变量的读取操作。
4. 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
5. 线程启动规则:线程的启动操作先行发生于该线程的任何操作。
6. 线程终止规则:线程中的任何操作先行发生于该线程的终止操作。
7. 异常规则:如果一个操作抛出了一个异常,并且这个异常被另一个操作捕获,那么抛出异常的操作先行发生于捕获异常的操作。
不要觉得这几个原则很高级,其实就是你写并发编程的一些基础常识而已。例如锁定规则,讲白了就是要先对线程解锁(unlock)你才能加锁(lock)。