并发5️⃣内存①JMM
1、Java 内存模型
Java Memory Model
作用:让 Java 程序在所有平台具有一致的内存访问效果,而不受硬件和操作系统影响。
- JMM 定义了程序中各种变量的访问规则,体现为主存、工作内存两个结构。
- 特性:原子性、可见性、有序性。
1.1、主存 & 工作内存
所有变量都存储在主存中。
每个线程有自己的工作内存,其中保存了该线程所使用变量的主存副本。
通常情况下
-
线程对变量的读写操作只在工作内存中进行,不直接读写主内存中的数据。
-
线程之间也无法直接访问对方工作内存的变量,需要通过主内存来传递。
1.2、三大特性(❗)
解决问题 | 实现方式 | |
---|---|---|
原子性 | 线程上下文切换 | Lock 或 synchronized |
可见性 | CPU 缓存 | volatile 或 synchronized |
有序性 | CPU 指令并行优化 | volatile |
原子性:多行字节码指令能同时执行,不受线程上下文影响。
解决:synchronized、Lock
2、可见性
可见性:主存中对变量的修改,对工作内存可见。
即线程读取变量的最新值,而非缓存的旧值。
2.1、问题描述
2.1.1、退不出的循环
① 示例
循环条件:
run
-
线程 t1 进入循环,循环条件为
run == true
。 -
一秒后,主线程设置
run = false
。 -
线程 t1 没有退出循环。
static boolean run = true; public static void main(String[] args) throws InterruptedException { new Thread(() -> { while (run) { // .... } }, "t1").start(); SleepUtils.sleepSeconds(1); run = false; }
② 分析
简单来说:工作内存没有感知到主存的变量已修改。
-
线程 t1 刚开始运行时,读取主存中的 run 值。
-
线程 t1 执行一段时间后,JIT 编译器将 while 循环(热点代码)优化,将主存中的 run 值缓存到工作内存。
-
主线程修改主存中的 run 值,但 t1 读工作内存中的 run 值。
2.1.2、解决方法(❗)
原理:两种解决方案
- volatile:要求线程从主存中读取变量,避免线程从工作缓存中读取。
- synchronized:
- 线程执行到
synchronized
同步代码块,加锁。 - 清空工作内存,将主存中的变量值拷贝到工作内存。
- 执行完同步代码块,将更新后的变量值刷新到主存中。
- 释放互斥锁。
- 线程执行到
对比
volatile | synchronized | |
---|---|---|
含义 | 易变 | 同步 |
修饰变量 | 成员变量 | 成员变量 |
解决问题 | 可见性 | 可见性、原子性 |
说明 | 无法解决原子性问题 (即不能防止线程之间的指令交错) |
性能低:涉及底层的 Monitor 结构 |
2.2、模式:两阶段终止
实现方式
- isInterrupted()
volatile
2.2.1、回顾 isInterrupted()
案例:后台监控
① 图示
循环
-
判断中断标志:即
isInterrupted()
,判断是否被中断。- true:处理后事(如保存结果、释放锁等),结束线程。
- false:执行任务。
-
执行任务:执行结束后睡眠 1 秒,捕获睡眠期间的异常。
-
正常:进入下一轮循环。
-
异常:睡眠线程被中断会清除中断标志,因此需要手动重新设置中断标志,进入下一轮循环。
-
② 代码实现
-
判断中断标志:若被中断则处理后事并结束进程。
-
执行任务:执行结束后睡眠 1 秒,捕获睡眠期间的异常。
-
睡眠期间被中断:中断标志被清除,需要重新设置中断标志。
private Thread thread; public void turnOn() { thread = new Thread(() -> { Thread current = Thread.currentThread(); while (true) { if (current.isInterrupted()) { // 模拟:处理后事 LogUtils.debug("terminating..."); // 处理完毕后退出 LogUtils.debug("Exit"); break; } // 执行任务 LogUtils.debug("working..."); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { // 设置中断标志 current.interrupt(); e.printStackTrace(); } } }, "t"); thread.start(); } public void turnOff() { thread.interrupt(); }
2.2.2、volatile(❗)
相比
isInterrupter()
方式,使用了自定义中断标志
- 启动
- 自定义
stopFlag
变量作为中断标志,volatile
保证可见性。 - 睡眠期间被中断,不需要重设 interrupted 中断标志。
- 自定义
- 终止
- 将 stopFlag 中断标志设为 true。
(相当于 isInterrupted() 方式的interrupt()
) - interrupt() 强制中断线程。
(若没有该方法,t1 在睡眠期间被中断时需等待睡眠时间结束)
- 将 stopFlag 中断标志设为 true。
代码
private volatile boolean stopFlag;
public void turnOn() {
thread = new Thread(() -> {
while (true) {
if (stopFlag) {
// 模拟:处理后事
LogUtils.debug("terminating...");
// 处理完毕后退出
LogUtils.debug("Exit");
break;
}
// 执行任务
LogUtils.debug("working...");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
// 不需要设置中断标志
e.printStackTrace();
}
}
}, "t1");
thread.start();
}
public void turnOff() {
LogUtils.debug("stop");
stopFlag = true;
thread.interrupt();
}
2.3、同步模式:Balking
2.3.1、定义
犹豫(Balking),即懒加载
- 定义:线程执行某个任务时发现任务已被执行(当前线程 / 其它线程),则当前线程无需再次执行,直接返回。
- 应用:懒汉式单例模式。
2.3.2、案例
模拟 web 环境启动监控功能
定义 startFlag
标识是否已启动,volatile
保证可见性。
-
启动
- 判断 startFlag,已启动则直接返回,未加锁则设置 true(必须保证原子性)
- 启动监控
-
说明:在 web 环境中,点击一次页面的【启动】按钮,相当于调用一次
turnOn()
方法。public class BalkingModel { private volatile boolean startFlag; public void turnOn() { synchronized (this) { if (startFlag) { LogUtils.debug("已开启监控"); return; } startFlag = true; } // 模拟启动监控 LogUtils.debug("监控中"); } }
3、有序性
有序性:字节码指令按一定顺序被 CPU 调度执行。
指令重排:JVM 调整字节码指令的执行顺序,以提高代码的整体执行效率。
3.1、指令重排
JVM 运行期优化技术
通常是将易于执行的指令(如资源耗费低)重排序到前面。
- 单线程:可以提高代码整体执行效率。将易于执行的指令先执行
- 多线程:会影响正确性。
3.2、示例:诡异的结果
两个线程分别执行以下两个方法,问 result 的最终结果?
int result = 0;
int num = 1;
boolean ready = false;
// 线程t1执行
public void m1() {
if(ready) {
// ①
result = num + num;
} else {
// ②
result = 1;
}
}
// 线程t2执行
public void m2() {
// ③
num = 2;
// ④
ready = true;
}
3.2.1、分析
① 正常情况
有 3 种情况:结果可能是 1 或 4。
-
先进入 m1():条件假,进入 else 执行 ②
-
先进入 m2():执行③,④还未执行;此时进入 m1():条件假,执行②
-
先进入 m2():执行③④;再进入 m1():条件真,执行①
② 指令重排
还有一种情况:result == 2
-
先进入 m2():执行④,③未执行
-
此时进入 m1():条件真,执行①
3.2.2、解决:volatile
使用 volatile
修饰成员变量,可保证 ready。