目录
一、Java 内存模型
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、
CPU 指令优化等。
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
二、原子性
三、可见性
1.volatile
易变关键字:它可以用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
注意1:volatile能保证的可见性,但并不保证原子性
注意2: synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低
例如:
volatile int i=0;
线程一:i++;
线程二:i--;
volatile只能保证他们的得到的i是最新值,但不能避免字节码指令的交错。
2.CPU缓存结构
四、有序性
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序。
这种特性称之为『指令重排』
,多线程下『指令重排』会影响正确性
。
1.指令级并行
为什么要有重排指令这项优化呢?从 CPU执行指令的原理来理解。
现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令
还可以再划分成一个个更小的阶段,例如,每条指令都可以分为:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
这 5 个阶段。
instruction fetch (IF)
instruction decode (ID)
execute (EX)
memory access (MEM)
register write back (WB)
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行
来提高效率
现代 CPU 支持多级指令流水线
,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
可以看出,不同指令的小操作之间的执行顺序发生了变化,但提高了效率。
2.内存屏障
Memory Barrier(Memory Fence)
可见性:
- 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
- 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
3.volatile原理
volatile 修饰的变量,可以禁用指令重排
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
不能解决指令交错
:
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读(写屏障之后的代码)重排序到它前面去
- 有序性的保证只是保证了本线程内相关代码不被重排序,不能保证多线程间的指令交错
4.double-checked locking
final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) {
// 也许有其它线程已经创建实例,所以再判断一次
//避免多个线程都进入if后,重复创建,所以再加一个if判断
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
特点:
- 懒惰实例化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁,降低了多次加锁的性能消耗
为什么使用volatile修饰INSTANCE
?
注意:第一个 if 使用了 INSTANCE 变量,是在同步块之外。虽然synchronized保证了原子性、可见性、有序性,但对于INSTANCE,由于它出现在了同步块外,并不能保证它的有序性。
同步块内:
//其中四条字节码指令:
new 表示创建对象,将对象引用入栈 // new Singleton
dup 表示复制一份对象引用 // 引用地址
invokespecial 表示利用一个对象引用,调用构造方法
putstatic 表示利用一个对象引用,赋值给 static INSTANCE
如果发生指令重排,先执行putstatic,再执行invokespecial。
在多线程下:
线程一执行putstatic后,还未执行invokespecial,线程二执行getstatic(第一个if),得到的是一个非空引用,但这个引用的对象还未完成构造方法,会发生错误。
所以:对 INSTANCE 使用 volatile 修饰,禁用指令重排。
5.happens-before原则
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛
开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
JVM定义的Happens-Before原则是一组偏序关系:对于两个操作A和B,这两个操作可以在不同的线程中执行。如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作是可见的。
-
程序顺序规则
在一个线程内部
,按照程序代码的书写顺序
,书写在前面的代码操作Happens-Before书写在后面的代码操作。这是因为Java语言规范要求JVM在单个线程内部要维护类似严格串行的语义
,如果多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。 -
锁定规则
对锁M解锁之前的所有操作Happens-Before对锁M加锁之后的所有操作。
static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
//t1执行完成后,对x的写操作,t2是可见的
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start();
-
volatile变量规则
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见 -
线程启动规则
线程 start 前对变量的写,对该线程开始后对该变量的读可见 -
线程结束规则
线程结束前对变量的写,对其它线程得知(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)它结束后的读可见 -
中断规则
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted) -
终结器规则
一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始。
对象调用finalize()方法时,对象初始化完成的任意操作,同步到全部主存同步到全部cache。 -
传递性规则
如果操作A Happens-Before B,B Happens-Before C,那么可以得出操作A Happens-Before C。
//例如:
volatile static int x;
static int y;
//先执行t1
new Thread(()->{
y = 10;
//对x赋值操作
x = 20;
//添加写屏障,之前的操作都会同步到内存
},"t1").start();
//再执行t1
new Thread(()->{
// x=20 对 t2 可见, 同时 y=10 也对 t2 可见
System.out.println(x);
},"t2").start();