4.1 Java 内存模型
JMM 即 Java Memory Model , 它定义了主存、工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。
JMM体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 -保证指令不会受到CPU缓存的影响
- 有序性 -保证指令不会受CPU指令并行优化的影响
4.2 可见性
退不出的循环
现象
main线程对于变量的修改对于t线程是不可见的,导致了 t 线程无法停止
@Slf4j
public class HasSeeTest {
static boolean hasExit = false;
public static void main(String[] args) {
new Thread(() -> {
while (!hasExit){
log.debug("循环中,等待hasExit为true");
}
}).start();
sleep(1);
log.debug("hasExit修改true");
// 线程t并没有结束
hasExit = true;
}
}
分析:
- 初始状态,t线程从主内存中读取了hasExit的值存到了工作内存
-
因为t线程要频繁从主内存中频繁读取hasExit的值,JIT编译器会将hasExit的值缓存至自己工作内存中的高速缓存中,减少对主内存中hasExit的访问,提高效率
-
1秒之后,main修改了hasExit的值,并同步到主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
解决方法
volatile(易变关键字)
- 它可以用来修饰静态成员变量和成员变量,它可以避免线程从自己工作缓存中查找变量的值,必须到主内存中获取它的值,线程操作volatile变量都是直接操作主内存
可见性 vs 原子性
开始的例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile 变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程; 字节码理解:
getstatic hasExit // 线程t获取 hasExit false
getstatic hasExit // 线程t获取 hasExit false
getstatic hasExit // 线程t获取 hasExit false
getstatic hasExit // 线程t获取 hasExit false
getstatic hasExit // 线程t获取 hasExit false
putstatic hasExit // 线程 main 修改hasExit为 true,仅此一次修改
getstatic hasExit // 线程t获取 hasExit true
比较之前线程安全:两个线程一个 i++ 一个 i–,只能保证看到最新值,不能解决指令交错
// i的初始值为0
getstatic i // 线程2 获取i的值 线程内i=0
getstatic i // 线程1 获取i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
结论:
- synchronized语句块
- 可以保证代码的原子性,也同时可以保证代码块内变量的可见性
- 但是synchronized是属于重量级操作,性能相对较低
- System.out.println() 中println() 就是用synchronized来同步,再上面代码的后面,打印hasExit的值,也能使t线程停下来
CPU缓存结构原理
1.CPU缓存结构
2.CPU缓存读
读取数据流程如下
- 根据低位,计算在缓存中的索引
- 判断是否有效
- 0 去内存读取新数据更新缓存行
- 1 再对比高位组标记是否一致
- 一致,根据偏移量返回缓存数据
- 不一致,去内存读取新数据更新缓存行
3.CPU缓存一致性
MESI 协议
-
E、S、M 状态的缓存行都可以满足CPU的读请求
-
E 状态的缓存行,有些请求,会将状态改为M,这时并不触发向主存的写
-
E 状态的缓存行,必须监听该缓存行的读操作,如果有,要变为S 状态
-
M 状态的缓存行,必须监听该缓存行的读操作,如果有,先将其他缓存(S 状态)中该缓存行变成 I 状态(即6的流程),写入主存,自己变为S状态
-
S 状态的缓存行,必须监听该缓存行的失效操作,如果有,自己变为I状态
-
S状态的缓存行,必须监听该缓存行的失效操作,如果有,自己变为I状态
-
I 状态的缓存行,有读请求,必须从主存读取
4.内存屏障
Memory Barrier(Memory Fence)
-
可见性
- 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
- 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
-
有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障前
模式之 Balking
1.定义
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做 了,直接结束返回
2.代码实现
@Slf4j
public class MonitorTest {
// 用来表示是否已经有线程已经在执行启动了
private static volatile boolean starting = false;
public static void main(String[] args) {
new Thread(() -> {
while(!starting){
log.debug("监控线程是否启动");
}
log.debug("监控线程启动");
}).start();
Sleeper.sleep(1);
log.info("尝试启动监控线程....");
synchronized (MonitorTest.class){
starting = true;
}
}
}
还可以用实现线程安全的单例
@Slf4j
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
4.3 有序性
- JVM 会在不影响正确性的前提下,可以调整语句的执行顺序
- 这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性
volatile 修饰的变量,可以禁用指令重排
volatile原理
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令后会加入读屏障
1.如何保证可见性
- 写屏障(sfence)保证在该屏障之前,对共享变量的修改,都同步到主存中
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
- 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
2.如何保证有序性
- 写指令会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true; // ready是 volatile 赋值带写屏障
// 写屏障
}
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
@Actor
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
不能解决指令交错
- 写屏障仅仅保证之后的读能读到最新的结果,但不能保证读跑到它前面
- 而有序性的保证也只是保证了本线程内相关代码不被重排序
3.双重检测锁(double-checked locking)
双重检测锁最熟知的就是 懒汉式单例
@Slf4j
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) {
// 首次访问会同步 而之后的使用没有synchronized
synchronized (Singleton.class){
if (INSTANCE == null){
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
return INSTANCE;
}
}
- 懒惰初始化
- 首次使用getInstance()才使用synchronized锁,后续使用时无需加锁
- 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外
但在多线程下,是有问题的,getInstance 方法对应的字节码为:
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
- 17 表示创建对象,将对象引用入栈 // new Singleton
- 20 表示复制一份对象引用 // 引用地址
- 21 表示利用一个对象引用,调用构造方法
- 24 表示利用一个对象引用,复制给static INSTANCE
jvm会有优化为:先执行24,在执行21.如果两个线程t1、t2,按如下时间序列执行:
关键在于
- 0: getstatic #2 这行代码在monitor控制之外,可以越过monitor读取INSTANCE变量的值
- 这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么t2拿到的将是一个未初始化完毕的单例
- 对INSTANCE使用 volatile 修饰即可,可以禁用指令重排,但注意在JDK 5 以上的版本的 volatile 才会真正有效
4.double-checked locking 解决
@Slf4j
public final class Singleton {
private Singleton() {
}
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) {
// 首次访问会同步 而之后的使用没有synchronized
synchronized (Singleton.class){
if (INSTANCE == null){
// 也许有其它线程已经创建实例,所以再判断一次
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
return INSTANCE;
}
}
// -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter -----------------------> 保证原子性、可见性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit ------------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
- 读写 volatile 变量会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点
- 可见性
- 写屏障保证在该屏障之前t1对共享变量的修改,都同步到主存
- 读屏障保证在该屏障之后t2对共享变量的读取,加载的是主存中最新数据
- 有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
- 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性
- 可见性
4.4原理之指令级并行
为什么要有重排指令这项优化呢?从 CPU 执行指令的原理出发
1.名词介绍
Clock Cycle Time
- 主频的概念大家接触的比较多,而CPU的Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是CPU能够识别的最小时间单位
- 比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的 Cycle Time 是 1s
- 例如,运行一条加法指令一般需要一个时钟周期时间
CPI
- 有的指令需要更多的时钟周期时间,所以引出了CPI(Cycles Per Instruction)指令平均时钟周期数
IPC
- IPC(Instruction Per Clock Cycle)即CPI的倒数,表示每个时钟周期能够运行的指令数
CPU执行时间
程序的 CPU 执行时间,即我们前面提到的 user + system 时间,可以用下面的公式来表示
程序CPU 执行时间 = 指令数 * CPI * Clock Cycle Time
2.鱼罐头的故事
加工一条鱼需要 50 分钟,只能一条鱼、一条鱼顺序加工…
可以将每个鱼罐头的加工流程细分为 5 个步骤:
- 去鳞清洗 10分钟
- 蒸煮沥水 10分钟
- 加注汤料 10分钟
- 杀菌出锅 10分钟
- 真空封罐 10分钟
即使只有一个工人,最理想的情况是:他能够在 10 分钟内同时做好这 5 件事,因为对第一条鱼的真空装罐,不会 影响对第二条鱼的杀菌出锅…
3.指令重排序优化
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令 还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据 写回 这 5 个阶段
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80世纪 中 叶到 90世纪 中叶占据了计算架构的重要地位。
注意
- 指令重排的前提是,重排指令不能影响结果
4.支持流水线的处理器
- 现代 CPU 支持多级指令流水线例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理 器,就可以称之为五级指令流水线。
- 这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一 条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了 指令的吞吐率。
奔腾四(Pentium 4)支持高达 35 级流水线,但由于功耗太高被废弃
5.SuperScalar 处理器
大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单元等,这样可以把多条指令也做到并行获取、译码等,CPU可以在一个时钟周期内,执行多于一条指令,IPC>1