简介
主要说一下Java 内存模型的基础、重排序、顺序一致性、Volatile 关键字、锁、final。
并发编程的模型分类
并发编程需要处理二个关键性的问题:线程之间如何通信 和 线程之间如何同步。
通信:
通信是指线程之间以何种机制来交换信息。线程之间的通信机制有两种:共享内存和 消息传递。
- 共享内存:线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。
- 消息传递:线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。
同步:
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
- 共享内存:同步是显式进行的。必须显式指定某个方法或某段代码需要在线程之间互斥执行。
- 消息传递:由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。
Java内存模型
Java 线程之间的通信由 Java 内存模型(JMM)控制。JMM 决定了一个线程对共享变量的写入何时对另一个线程
可见。
并发编程经常出现bug,往往是因为三个原因:
- 缓存导致的可见性问题
- 线程切换带来的原子性问题
- 编译优化带来的有序性问题
缓存导致的可见性问题:从上图来看,如果线程 A 和线程 B 要通信的话,要通过二个步骤
- 线程A需要将本地内存中变量A的副本刷新到主内存中,更新主内存变量A的值。
- 线程B去主内存中获取最新的变量A的值。
按照线程A写值->刷新主存->线程B读取值的顺序来,是不会产生可见性问题的。
public class Test {
private int count = 0;
public void add() {
int total = 0;
while(total < 10000) {
count++;
total++;
}
}
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
Thread thread1 = new Thread(
()-> {
test.add();
});
Thread thread2 = new Thread(
()-> {
test.add();
});
//启动线程
thread1.start();
thread2.start();
//让主线程等待thread1和thread2执行完
thread1.join();
thread2.join();
System.out.println("累加求和:"+test.count);
}
}
从结果可以看出来,并不是我们期待的结果20000,而是一个不确定的值,每次运行都会不同。
假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 本地缓存里,执行完 count+=1 之后,各自本地 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的本地缓存里都有了 count 的值,两个线程都是基于本地缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。
线程切换带来的原子性问题:count=count+1, 这个操作并不是是一个不可分割的整体,其实由三个操作组成。
- 指令 1:首先,需要把变量 count 从内存加载到寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入本地 缓存,最后刷入内存
重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三类:
-
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
-
指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
-
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排 序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序 列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是 所有的处理器重排序都要禁止)。
处理器重排序:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!
现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于
处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中
对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,
仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行
顺序,不一定与内存实际发生的读/写操作顺序一致!
假设处理器A和处理器B按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0。具体的原因如下图所示:
- 处理器A和处理器B同时将a=1,b=1写入写缓冲区。
- 处理器A和处理器B从内存中读取a和b的值赋值给x和y,此时:x=0,y=0;
- 处理器A和处理器B将a和b的值从写缓冲区刷到内存中。
从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1 -> A2,但内存操作实际发生的顺序却是:A2 -> A1。此时,处理器 A 的内存操作顺序被重排序了。由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作重排序。
内存屏障指令:
为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的
处理器重排序。JMM 把内存屏障指令分为下列四类:
happens-before:前面一个操作的结果对后续操作是可见的
JSR-133 内存模型使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器的解锁,happens-before 于随后对这个监视器的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
注意:两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作的执行的结果对后一个操作可见。