Java道经第1卷 - 第7阶 - 并发编程(一)
传送门:JB1-7-并发编程(一)
传送门:JB1-7-并发编程(二)
心法:本章使用 Maven 父子结构项目进行练习
练习项目结构如下:
|_ v1-6-basic-thread
|_ test
武技:搭建练习项目结构
- 创建父项目 v1-6-basic-thread,删除 src 目录,并添加依赖如下:
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>4.13.2</junit.version>
<lombok.version>1.18.24</lombok.version>
<jol-core.version>0.16</jol-core.version>
</properties>
<dependencies>
<!--junit-->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<!--jol-core-->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>${jol-core.version}</version>
</dependency>
</dependencies>
- 创建子项目 test,不添加任何依赖。
S01. 基础入门
E01. 线程基础概念
1. 进程VS线程
心法:启动一个程序至少启动了一个进程,启动一个进程至少启动了一个线程。
进程:OS 进行分配和管理资源的基本单位,切换开销大,进程之间独享数据空间。
线程:CPU 调度和分派的基本单位,是进程的一条执行路径,切换开销小,线程之间共享数据空间。
2. 线程调度器
心法: 线程调度器,全称 Thread Scheduler,简称 TS,是操作系统内核中,负责线程调度的一个重要组件。
线程调度:指 OS 在多线程环境下决定哪个线程应该在何时运行的过程,当多线程需要共享 CPU 资源时,需要一种机制来合理地分配 CPU 时间片,以最大程度地提高系统的吞吐量和响应性能。
调度方案 - 抢占式:TS 允许更高优先级的任务在任何时刻抢占当前任务,以确保系统能够及时响应高优先级任务的需求,该方案是 win11 默认的调度方案。
调度方案 - 时间片轮转:TS 先将 CPU 的执行时间划分成 N 个随机片段,然后:
- TS 为每个线程分配一个固定的时间片,每个时间片内,TS 都随机选择一个线程执行。
- 当时间片结束时将 CPU 资源分配给下一个线程。
- 该方案可以避免线程饥饿,即避免某个线程长时间占用 CPU 的情况,从而提高了系统的响应速度和效率。
调度方案 - 优先级:TS 根据线程的优先级来确定下一个执行的线程,容易产生线程饥饿问题。
调度方案 - 多级反馈队列:假设创建 3 个优先级队列 A, B 和 C,三个队列优先级递减,且时间片递减(分别为 10ms, 20ms 和 30ms):
- 当系统启动时,所有的线程都被 TS 放入队列 A,分配时间片 10ms。
- 当线程 T 在 10ms 内仍没执行完任务,则让出 CPU,降级到队列 B,以获得更大的时间片。
- 当线程 T 在 20ms 内仍没执行完任务,则让出 CPU,降级到队列 C,以获得更大的时间片。
- 当线程 T 在 30ms 内仍没执行完任务,则重新分配到队列 C,循环往复,直到完成。
3. 多线程优势
心法:CPU 的工作就是从内存中将指令一条一条取出并执行,当内存中没有任何指令时,CPU 就会处于空闲状态,多线程模型的最大好处就是可以提高 CPU 的资源利用率,即让尽量少的 CPU 处于空闲状态。
4. 并发VS并行
心法:并发就是多任务交替运行,并行就是多任务同时运行。
并发 Concurrency:在并发环境中,多个任务或操作可能交替执行,每个任务都在一段时间内得到处理,但不一定是同时执行,通常用于描述系统中同时存在多个活动任务或操作的情况,这些任务可能是独立的,也可能是相关联的。
并行 Parallelism:在并行环境中,多个任务或操作确实同时执行,每个任务都在不同的处理器核心上独立运行,通常用于描述系统中真正同时执行多个任务的情况,这些任务通常是相互独立的,彼此不受影响。
E02. 线程创建方式
心法: 用户线程也叫前台线程,在 Java 程序中,main 方法中的主线程,junit 方法中的主线程,以及在主线程中开启的子线程,都属于前台用户线程。
1. 主线程的退出
场景来源 | 退出时机 | 是否等待子线程结束 |
---|---|---|
main 方法中的主线程 | 全部子线程结束后退出 | 等 |
junit 方法中的主线程 | 主线程结束后立即退出 | 不等,但支持手动阻止主线程结束 |
2. 创建用户线程
心法: 创建用户线程的流程
- 创建一个线程类,该线程类需要继承 java.lang.Thread 类或实现 java.lang.Runnable 接口,推荐使用实现接口的方式创建线程类,拓展性更高,且 Thread 类本身也实现了 Runnable 接口。
- 重写 Runnable 接口中的
run()
方法,该方法也叫线程体,用于编写线程任务,无返回值。 - 实例化线程类,并对实例调用
start()
方法以启动该线程:- 调用
start()
方法不意味着线程立刻执行,而是进入 READY 状态,等待争抢 CPU 资源。 - 调用
run()
方法也可以执行线程体方法,但不具有任何线程特性,比如线程争抢等。
- 调用
武技: 测试通过继承 Thread 方式创建并启动线程
package start;
/** @author 周航宇 */
public class NewByThreadTest {
/** 通过继承Thread的方式开发前台线程类 */
private static class SubThread extends Thread {
/** 线程体: 该线程需要做的事情 */
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("子线程A-thread: " + i);
}
}
}
@Test
@SneakyThrows
public void test() {
// 创建一个线程实例并启动
new SubThread().start();
// 阻塞junit线程
TimeUnit.SECONDS.sleep(10L);
}
}
武技: 测试通过实现 Runnable 的方式创建并启动线程
package start;
/** @author 周航宇 */
public class NewByRunnableTest {
/** 通过实现Runnable的方式开发前台线程类 */
private static class SubThread implements Runnable {
/** 线程体: 该线程需要做的事情 */
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("子线程A-thread: " + i);
}
}
}
@Test
@SneakyThrows
public void test() {
// 创建一个线程实例并启动
new Thread(new SubThread()).start();
// 阻塞junit线程
TimeUnit.SECONDS.sleep(10L);
}
@Test
@SneakyThrows
public void testByInnerClass() {
// 创建一个线程实例并启动
new Thread(() -> {
for (int i = 0; i < 10; i++) {
System.out.println("子线程B-thread: " + i);
}
}).start();
// 阻塞junit线程
TimeUnit.SECONDS.sleep(10L);
}
}
3. 创建守护线程
心法: 守护线程也叫后台线程,仅当所有前台线程都死掉时才退出,否则就一直在后台活动,比如 GC 线程。
在守护线程中产生的新线程也是守护线程。
相关 API 方法如下:
t.setDaemon(true)
:设置 t 线程为守护线程,代码必须写在启动代码前,否则爆发非法线程状态异常。t.isDaemon()
:返回 t 线程是否是一个守护线程。
武技: 5 秒内守护线程每隔 0.5 秒输出一遍 “t-daemon”,5 秒后主线程结束,守护线程也结束
package start;
/** @author 周航宇 */
public class DaemonThreadTest {
@Test
@SneakyThrows
public void testDaemonThread() {
// 创建一个守护线程实例
Thread daemonThread = new Thread(() -> {
while (true) {
try {
// 线程会在这里睡眠500毫秒
TimeUnit.MILLISECONDS.sleep(500L);
System.out.println("t-daemon");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 设置守护线程的操作必须在启动之前完成
daemonThread.setDaemon(true);
// 测试该线程是否为守护线程
System.out.println(daemonThread.isDaemon() ? "是守护线程" : "不是守护线程");
// 启动守护线程
daemonThread.start();
// 让主线程睡眠5秒
TimeUnit.SECONDS.sleep(5L);
System.out.println("5秒后,主线程退出,守护线程也退出");
}
}
4. 相关API方法
Thread.currentThread()
:返回 代码当前所在的线程 的线程实例。t.isAlive()
:返回线程是否还活着。t.setName()
:设置该线程的名字,也可以在 Thread 构造时指定。t.getName()
:返回该线程的名称,默认从 thread-0 开始递推。t.setPriority(优先级)
:设置线程优先级,建议使用 Thread 类的优先级常量。t.getPriority()
:获取线程优先级,默认 5。
武技: 测试线程相关 API 方法
package start;
/** @author 周航宇 */
public class ThreadApiTest {
@Test
@SneakyThrows
public void testCurrentThread() {
new Thread(() -> {
// [thread-0, 5, main]
System.out.println(Thread.currentThread());
}).start();
// [main, 5, main]
System.out.println(Thread.currentThread());
// 阻塞junit线程
TimeUnit.SECONDS.sleep(5L);
}
@Test
@SneakyThrows
public void testThreadName() {
// 可以在实例化Thread时,在第二个参数中,直接指定子线程的名称
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName());
}, "子线程A");
thread.setName("子线程B");
thread.start();
// 阻塞junit线程
TimeUnit.SECONDS.sleep(5L);
}
@Test
@SneakyThrows
public void testIsAlive() {
Thread thread = new Thread(() -> {
while (true) {
}
});
System.out.println(thread.isAlive() ? "子线程存活" : "子线程未启动或挂起");
thread.start();
System.out.println(thread.isAlive() ? "子线程存活" : "子线程未启动或挂起");
// 阻塞junit线程
TimeUnit.SECONDS.sleep(5L);
}
@Test
@SneakyThrows
public void testPriority() {
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread());
});
thread.setPriority(Thread.MAX_PRIORITY);
thread.start();
System.out.println(thread.getPriority());
// 阻塞junit线程
TimeUnit.SECONDS.sleep(5L);
}
}
E03. 线程生命周期
1. 七种生命状态
心法: 一个线程总是处于初始,就绪,运行,阻塞,等待,计时等待,终止这七种生命周期状态之一,且这七种生命周期状态分别对应 java.lang.Thread.State 枚举类中封装的六种枚举常量,其中 READY 和 RUNNING 同时对应 RUNNABLE 状态,在代码中可以通过
t.getState()
获取到对应的枚举常量。
NEW 初始状态:此时线程刚被创建,未启动,未执行,也无资格被 TS 选中,若对其调用 start()
可立即切换到 READY 状态。
READY 就绪状态:此时线程等待被 TS 选中:
- 若线程被 TS 选中,可立即切换到 RUNNING 状态。
- 若对其调用
join()
可立即切换到 RUNNING 状态。
RUNNING 运行状态:此时线程正在执行:
- 若线程要访问已上锁的同步资源,则立即被动切换到 BLOCKED 状态。
- 若对其调用
wait()
,join()
或park()
可立即主动切换到 WAITING 状态。 - 若对其调用
sleep(t)
,wait(t)
或join(t)
可立即主动切换到 TIME_WAITING 状态。 - 若对其调用
parkNanos(t)
或parkUntil(t)
可立即主动切换到 TIME_WAITING 状态。
BLOCKED 阻塞状态:此时线程正在等待获取同步资源的锁,若成功访问已上锁的同步资源,则立即切换到 READY 状态。
WAITING 等待状态:此时线程正在等待被唤醒:
- 若对其调用
notify()
,notifyAll()
或unpark()
可立即切换到 READY 状态。 - 若对其调用
interrupt()
可立即切换到 READY 状态。
TIME_WAITING 计时等待状态:此时线程正在等待超时:
- 若超时,可立即切换到 READY 状态。
- 若对其调用
interrupt()
可立即切换到 READY 状态。
TERMINATED 终止状态:此时线程已经死亡,当线程体执行完毕,或线程体中代码爆发异常且未处理时,才会进入该状态。
武技: 测试线程生命周期状态
package start;
/** @author 周航宇 */
public class ThreadLifecycleStateTest {
@SneakyThrows
@Test
public void testLifecycleState() {
// 创建一个线程实例
Thread threadA = new Thread(() -> System.out.println("子线程A执行完毕.."));
// 创建一个线程实例
Thread threadB = new Thread(() -> {
try {
TimeUnit.HOURS.sleep(24L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("子线程B执行完毕..");
});
// 此时子线程刚被创建,处于NEW状态
System.out.println("当前子线程A的生命周期状态: " + threadA.getState());
System.out.println("当前子线程B的生命周期状态: " + threadB.getState());
// 启动子线程
threadA.start();
threadB.start();
// 此时子线程已被启动,处于RUNNABLE状态
System.out.println("当前子线程A的生命周期状态: " + threadA.getState());
System.out.println("当前子线程B的生命周期状态: " + threadB.getState());
// 让主线程睡眠5秒
TimeUnit.SECONDS.sleep(5L);
// 此时子线程A早已结束,处于TERMINATED状态,但子线程B还在睡眠,处于TIME_WAITING状态
System.out.println("当前子线程A的生命周期状态: " + threadA.getState());
System.out.println("当前子线程B的生命周期状态: " + threadB.getState());
}
}
2. 阻塞VS等待
心法:阻塞和等待有什么区别?
相同点:阻塞和等待都会让线程止步不前,阻塞会让线程进入锁池,不占用 CPU 资源,等待会让线程进入等待池,不占用 CPU 资源。
不同点:阻塞是一种被动状态,是为了解决多线程之间的资源竞争问题而设计的,而等待是一种主动手段,是为了解决多线程之间的数据通信方案而设计的。
3. 相关API方法
心法: 线程生命周期相关 API 方法
相关 API 方法:
Thread.sleep(1000L)
:线程立即睡眠并进入 TIME_WAITING 状态,睡眠结束后退回到 READY 状态,单位毫秒。TimeUnit.SECONDS.sleep(1L)
:TimeUnit工具类中提供的sleep()
的上层封装,t.join()
:将 t 线程临时加入到 代码当前所在的线程 中插队执行线程体任务,插队代码必须写在启动代码后,否则无插队效果。Thread.yield()
:让出一次被线程调度器选中的机会,但线程调度器下一次还是有可能选择该线程。
武技: 测试线程生命周期相关 API 方法
package start;
/** @author 周航宇 */
public class ThreadLifecycleApiTest {
@Test
@SneakyThrows
public void testSleep() {
// 让主线程睡眠2秒
System.out.println("主线程: 开始睡眠...");
Thread.sleep(2000L);
System.out.println("主线程: 睡眠结束...");
}
@Test
@SneakyThrows
public void testTimeUnit() {
// 让主线程睡眠2秒
System.out.println("主线程: 开始睡眠...");
TimeUnit.SECONDS.sleep(2L);
System.out.println("主线程: 睡眠结束...");
}
@Test
@SneakyThrows
public void testJoin() {
// 创建子线程
Thread subThread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName());
}
});
// 子线程启动
subThread.start();
// 子线程插主线程的队,则必须子线程执行完毕后,主线程才能继续执行
subThread.join();
// 主线程任务
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName());
}
}
@Test
@SneakyThrows
public void testYield() {
// 创建子线程
new Thread(() -> {
for (int i = 0; i < 5; i++) {
// 子线程让出一个时间片,但调度器下一次还是有可能选择我
Thread.yield();
System.out.println(Thread.currentThread().getName());
}
}).start();
// 主线程任务
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName());
}
// 阻塞junit线程
TimeUnit.SECONDS.sleep(3L);
}
}
E04. 停止线程方式
1. 停止运行的线程
心法:当一个 RUNNABLE 状态的线程完成了自己的线程体任务,就表示这个线程停止了,可以人为更改循环标志,迫使线程体运行完毕而结束线程。
相关 API 方法:
t.stop()
:方法已过时且容易产生线程状态不一致等问题,不建议使用。
武技: 测试停止一个运行的线程
package start;
/** @author 周航宇 */
public class StopRunnableThreadTest {
/** 本类用于测试使用标识变量的方式停止运行中的线程 */
private static class SubThread implements Runnable {
/** 标识当前线程体中的while循环是否可以结束 */
private static boolean isStop;
@SneakyThrows
@Override
public void run() {
while (!isStop) {
System.out.println("子线程运行..");
}
System.out.println("子线程结束..");
}
}
/** 停止一个正常运行的线程 */
@SneakyThrows
@Test
public void testStopRunnableThread() {
// 创建线程类
SubThread subThread = new SubThread();
// 创建线程并启动这个线程
new Thread(subThread).start();
// 主线程在5s后手动更改线程实例中的isStop标识变量
TimeUnit.SECONDS.sleep(5L);
// 在主线程中更改 `isStop` 标识变量
subThread.isStop = true;
// 阻塞junit线程
TimeUnit.SECONDS.sleep(10L);
}
}
2. 停止挂起的线程
心法:若当前线程处于挂起状态,如进行了
wait()
或sleep()
等操作,则建议先强行打断该线程,然后在异常处理中更改循环标志,迫使线程体运行完毕而结束线程。
相关 API 方法:
t.interrupt()
:强行打断 t 线程,该方法会抛出一个 InterruptedException 异常。
武技: 测试停止一个挂起的线程
package start;
/** @author 周航宇 */
public class StopSleepThreadTest {
/** 本类用于测试强行停止挂起中的线程 */
private static class SleepThread implements Runnable {
/** 标识当前线程体中的while循环是否可以结束 */
private static boolean isStop;
@SneakyThrows
@Override
public void run() {
while (!isStop) {
try {
// 让子线程挂起: 睡眠24小时
System.out.println("子线程挂起...");
TimeUnit.HOURS.sleep(24L);
} catch (InterruptedException e) {
System.out.println("5s后,子线程被interrupt打断...");
// 更改循环标志
isStop = true;
}
}
System.out.println("子线程结束...");
}
}
@SneakyThrows
@Test
public void testStopSleepThread() {
// 创建线程并启动这个线程
Thread thread = new Thread(new SleepThread());
thread.start();
// 主线程在5s后强行打断该线程,会爆发InterruptedException异常
TimeUnit.SECONDS.sleep(5L);
thread.interrupt();
// 阻塞junit线程
TimeUnit.SECONDS.sleep(10L);
}
}
S02. 内存模型
E01. 内存通信模型
心法: 计算机中大部分元件都是通过总线和 IO-Bridge(IO桥)和来进行通信的。
IO-Bridge 连接方式:
- CPU 通过系统总线(硬件)连接到 IO-Bridge。
- 内存通过内存总线(硬件)连接到 IO-Bridge。
- 其他元件如 USB,显卡,磁盘,网卡驱动等,通过 IO总线(硬件)连接到 IO-Bridge。
1. CPU硬件
心法:一般个人用笔记本都是单 CPU 主板,只能安装一颗 CPU,服务器才有可能使用多 CPU 主板。
可通过 任务管理器 -> 性能 -> CPU -> 插槽
查看 CPU 插槽个数。
2. CPU核心
心法:一颗 CPU 中存在多个核心,如双,四,八核等。
CPU 核心组成:一个 CPU 核心由 CPU 运算器(计算),CPU 控制器(收发指令)和 CPU 寄存器(保存计算中间值)组成。
CPU 高速缓存:每个核心都独有一级高速缓存 L1 和二级高速缓存 L2,一个 CPU 中的全部 CPU 核心共享三级高速缓存 L3:
- 可通过
任务管理器 -> 性能 -> CPU -> 内核
查看 CPU 核数。 - 可通过
任务管理器 -> 性能 -> CPU -> L1/L2/L3
查看三个级别缓存的空间大小。
3. CPU线程
心法:每个 CPU 核心都能独立运行至少一个线程。
超线程技术:Intel 发明了超线程技术,让一个核心可以模拟两个线程,但四核八线程仍远不及真八核效率高,可通过 任务管理器 -> 性能 -> CPU -> 逻辑处理器
查看 CPU 线程数。
4. 主内存
心法:全部 CPU 核心共享同一个主内存,多线程从主存中获取数据,但并不在主存上直接操作数据。
CPU 读取数据的顺序为 L1 -> L2 -> L3 -> 主存,读取成功时会依次向前备份。
5. JMM通信原理
心法: Java 内存模型简称 JMM,拥有 8 种原子性操作,共同完成线程和主存的通信过程。
命令 | 中文 | 描述 |
---|---|---|
lock | 锁定 | 对主内存变量加锁,标识一个线程独占状态 |
read | 读取 | 从主内存读取数据 |
load | 载入 | 将主内存的数据写入 CPU 缓存 |
use | 使用 | 从 CPU 缓存读取数据计算 |
assign | 赋值 | 将计算好的值重新赋值到 CPU 缓存中 |
store | 存储 | 将 CPU 缓存的值写入主内存 |
write | 写入 | 将 store 过去的变量值赋值给主内存中的变量 |
unlock | 解锁 | 将主内存变量解锁,解锁后才允许其他线程操作该变量 |
JMM 流程:假设某个线程的任务是将 a = 18 重新赋值为 a = 60,则整体流程如下:
- lock 加锁:对主存中的变量 a 进行加锁,标识该变量被本线程独占,此过程在同步时才会生效。
- read 读取:该线程将主内存中的 a 进行拷贝,此时原变量 a 和临时变量 a 仍在主存中。
- load 载入:将临时变量 a 通过总线加载到线程的 CPU 缓存中。
- use 使用:对 CPU 缓存中的临时变量 a 进行计算:
- CPU 控制器负责接收计算指令。
- CPU 运算器负责计算,并得到 a = 60 结果。
- CPU 寄存器负责存储计算结果。
- assign 赋值:将计算结果 60 重新赋值给 CPU 缓存中的临时变量 a。
- store 存储:将 CPU 缓存中的临时变量 a 通过总线存储到主存。
- write 写入:在主存中,将变量 a 重新赋值为 60。
- unlock 解锁:对主存中的变量 a 进行解锁,解锁后才允许其他线程操作该变量,此过程在同步时才会生效。
E02. Volatile关键字
1. MESI协议
心法:MESI 缓存一致性协议是现代多核 CPU 中用于维护多个缓存之间数据一致性的协议,它通过跟踪每个缓存行(Cache Line)的状态,确保不同 CPU 核心的缓存数据与主内存保持一致。
MESI 四大状态:该协议将缓存行的状态分为四种:
- Modified 修改:数据被修改且未同步到主内存,当前 CPU 独占该缓存行,其他 CPU 缓存行失效。
- Exclusive 独占:数据与主内存一致,当前 CPU 独占该缓存行,其他 CPU 未缓存该数据。
- Shared 共享:数据与主内存一致,多个 CPU 共享该缓存行,任何修改需通知其他 CPU。
- Invalid 无效:缓存行数据无效,需从主内存重新加载。
MESI 工作流程:假设有两个 CPU 核心 A 和 B,同时使用自己的 CPU 缓存修改同一个主内存缓存行(以下全部流程的状态更改操作的都在自己缓存中进行,而不会直接在主存中的缓存行上进行变更):
- A 读取数据:将主内存缓存行数据 load 到自己的缓存,并设置缓存行状态为 E(独占)。
- B 读取数据:将主内存缓存行数据 load 到自己的缓存,并设置缓存行状态为 S(共享)。
- A 修改数据:将缓存行状态修改为 M(修改):
- 系统会通过总线将 Invalidate 消息通知给其他正在使用相同缓存行的 B。
- B 将缓存行的状态被修改为 I(无效)。
- A 提交修改:将修改后的缓存行同步回主内存,然后将缓存行状态恢复为 E(独占)或 S(共享)。
- B 再次读取:发现缓存行的状态为 I(无效),于是从主内存重新加载最新版本(S 状态)。
2. Volatile保证可见性
心法: 使用 volatile 修饰的变量可以保证该变量的可见性,在底层硬件层面,往往是借助 MESI 等缓存一致性协议来间接实现这种可见性的。
MESI 主要处理的是不同核心之间的缓存不一致问题,例如,如果线程 T1 在核心 A 修改了一个变量,核心 B 的缓存需要被无效化,这时候 MESI 起作用,但如果两个线程都在核心 A 运行,它们访问的是同一个 CPU 的 L1 缓存,不需要跨核心同步,因此 MESI 不会介入。
volatile 使用注意事项:尽量仅修饰基本类型,修饰引用类型时只对其本身的改动保证可见性:
volatile User user
->user = new User()
: 本身地址发生改动,保证可见性。volatile User user
->user.setName("赵四")
: 内部属性发生改动,不保证可见性。
武技: 测试 volatile 关键字是否可以保证可见性
package memory;
/** @author 周航宇 */
public class VolatileVisibleTest {
/** 用于测试volatile可见性效果 */
private static class SubThread implements Runnable {
/** volatile保证变量可见性 */
private volatile boolean flag;
@Override
public void run() {
// 使用空的循环体来阻塞当前线程,死等flag值的变化
while (!flag) {}
System.out.println("子线程执行完毕..");
}
}
@SneakyThrows
@Test
public void test() {
// 启动一个子线程
SubThread subThread = new SubThread();
new Thread(subThread).start();
// 主线程2秒后,改变子线程的flag标识变量为true
TimeUnit.SECONDS.sleep(2L);
subThread.flag = true;
System.out.println("主线程改变了子线程中flag变量的值为true..");
// 阻止主线程结束
TimeUnit.SECONDS.sleep(5L);
System.out.println("主线程执行完毕..");
}
}
3. Volatile保证有序性
心法: 在不影响最终结果的前提下,JVM 可能会将代码的指令重排序后执行,以提高运行效率,但也可能会对我们的代码逻辑产生影响,特别是在多线程环境下。
当一个变量被声明为 volatile 时,JMM 会对该变量的读写操作施加一些特殊的限制(内存屏障),以禁止 JVM 对其进行指令重排,从而保证有序性。
四种内存屏障:
- StoreStore 写写屏障:保证屏障之前的普通写先完成,屏障之后的写后完成,防止写操作重排序。
- LoadLoad 读读屏障:保证屏障之前的读先完成,屏障之后的读后完成,,防止读操作重排序。
- LoadStore 读写屏障:保证屏障之前的读先完成,屏障之后的写后完成,防止读写操作重排序。
- StoreLoad 写读屏障:保证屏障之前的写先完成,屏障之后的读后完成,防止读写操作重排序。
写 volatile 变量时添加的屏障:
- 当对 volatile 变量进行写操作时,会添加 StoreStore 屏障和 StoreLoad 屏障。
- 当对 volatile 变量进行读操作时,会添加 LoadLoad 屏障和 LoadStore 屏障。
private int a = 0;
private volatile boolean flag = true;
private int b = 0;
public void write() {
// 执行普通写操作,将变量 a 的值设置为 1
// 普通写操作的数据会先存储在线程的本地缓存中,不会立即刷新到主内存
// 同时,JVM 为了提升性能,可能会对普通写操作进行重排序,导致其执行顺序与代码顺序不一致
a = 1;
// 在进行 volatile 写操作之前,JVM 会自动插入 StoreStore 内存屏障
// 此屏障的作用是确保在对 volatile 变量 flag 进行写操作之前
// 所有之前的普通写操作(a = 1)都已经完成,并且这些写操作的结果已经刷新到主内存
// 避免这些普通写操作被重排序到对 flag 的写操作之后,保证写操作的有序性
// 即将进行 volatile 写操作,下面的操作会受到内存屏障的约束
// 进行 volatile 写操作,将 volatile 变量 flag 的值设置为 false
// volatile 变量具有特殊的内存语义,其写操作会立即将结果刷新到主内存
// 并且会保证这个写操作对其他线程是立即可见的
// 基于 Java 内存模型的规则,后续的写操作不会被重排序到这个 volatile 写操作之前
flag = false;
// 在完成 volatile 写操作之后,JVM 会自动插入 StoreLoad 内存屏障
// 该屏障的作用是禁止后续的读操作被重排序到对 flag 的写操作之前
// 保证后续的读操作能够看到对 flag 以及之前普通写操作(如对变量 a 的赋值)所更新的最新内存状态
// 执行普通写操作,将变量 b 的值设置为 2
// 由于前面的 StoreLoad 屏障,此时该线程能够看到包括变量 a 和 flag 在内的最新内存状态
// 不过,此操作仍然是普通写操作,数据会先存于线程本地缓存,不会马上刷新到主内存,也可能被 JVM 重排序
b = 2;
}
public void read() {
// 执行普通读操作,从线程的本地缓存中读取变量 a 的值
// 由于普通读操作的数据是从本地缓存获取,可能会读取到过期的值
// 同时,JVM 为了优化性能,可能会对普通读操作进行重排序
int localA = a;
// 在进行 volatile 读操作之前,JVM 会自动插入 LoadLoad 内存屏障
// 此屏障的作用是确保在读取 volatile 变量 flag 之前,所有之前的普通读操作(如读取变量 a)都已完成
// 避免这些普通读操作被重排序到对 flag 的读操作之后,保证读操作的有序性
// 即将读取 volatile 变量 flag,下面的操作会受到内存屏障的约束
// 进行 volatile 读操作,读取 volatile 变量 flag 的值
// volatile 变量具有特殊的内存语义,其读操作能保证在读取之前
// 所有线程对普通变量和 volatile 变量的写操作都已经完成,并且这些写操作的结果对当前线程可见
// 也就是说,此时读取 flag 能看到之前所有写操作的最新结果
boolean localFlag = flag;
// 在完成 volatile 读操作之后,JVM 会自动插入 LoadStore 内存屏障
// 该屏障的作用是禁止后续的普通写操作被重排序到对 flag 的读操作之前
// 保证在读取 flag 之后,后续的写操作不会提前执行,维护内存操作的有序性
// 执行普通读操作,从线程的本地缓存中读取变量 b 的值
// 由于前面的 LoadStore 屏障,这里的操作确保是在读取 volatile 变量 flag 之后进行的
// 后续的普通写操作不会被提前到读取 flag 之前执行
int localB = b;
}
武技: 测试 volatile 关键字是否可以保证有序性
package memory;
/** @author 周航宇 */
public class VolatileOrderTest {
/** volatile指令重排序测试属性 */
private volatile int x = 0, y = 0, a = 0, b = 0;
@SneakyThrows
@Test
public void testOrderReorder() {
// 无限循环,仅当出现了指令重排的情况下,跳出循环
for (int i = 0; true; i++) {
// 创建子线程A,将a修改为1,将x修改为b的值
Thread threadA = new Thread(() -> {
a = 1;
x = b;
});
// 创建子线程B,将b修改为1,将y修改为a的值
Thread threadB = new Thread(() -> {
b = 1;
y = a;
});
// 启动两个子线程
threadA.start();
threadB.start();
// 让两个子线程插主线程的队,否则可能出现子线程还没赋值完毕,主线程就开始打印的问题
threadA.join();
threadB.join();
// 打印每一次的赋值的结果
System.out.printf("第%d次: x=%d,y=%d\n", i, x, y);
// 若出现 x=0, y=0 的情况,一定发生了执行重排序,结束无限循环
// 第1步 执行: x = b(0) => x = 0
// 第2步 执行: y = a(0) => y = 0
// 第3步 执行: a = 1
// 第4步 执行: b = 1
if (x == 0 && y == 0) {
System.out.println("发生指令重排序...");
break;
}
// 重置4个测试属性,然后再重新开启下一轮的测试
x = y = a = b = 0;
}
}
}
4. Volatile线程不安全
心法: volatile 能保证可见性,和有序性,但无法保证原子性
若线程 T1 和线程 T2 在进行 read/load 操作中,发现主内存中的 age 值都是 5,则都会加载这个 age 值,然后:
- T1 修改 age 为 6,并 write 到主存,此时主存中的 age 值为 6。
- T2 修改 age 为 7,并 write 到主存,此时主存中的 age 值为 7。
- 出现并发问题。
武技: 测试 volatile 关键字是否可以保证原子性
package memory;
/**@author 周航宇*/
public class VolatileAtomTest {
/** 共享资源 */
private volatile int num = 0;
@SneakyThrows
@Test
public void testAtom() {
// 创建两个线程,同时对num进行自增操作,各自10W次
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
num++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
num++;
}
});
// 启动两个线程并插队执行
t1.start();
t2.start();
t1.join();
t2.join();
// 结果为20W,证明线程安全,小于20W,证明线程不安全
System.out.println(num);
}
}
E03. 高速缓存行
心法: CPU 在读取主存数据时会将目标值及其相邻的 64 个字节数据(称为一个高速缓存行)一起读取和回写。
灵活使用缓存行可以提高程序效率,比如主存中数据 a 和数据 volatile b 在一个缓存行中:
- 每次 线程 T1 对 volatile b 的操作都难免携带着 a 一起在缓存和主存中传输以保持缓存一致性。
- 若此时恰好线程 T2 在不断读取 a,则每次都被通知需要去主存中读取,效率变低。
此时建议将 a 和 volatile b 人为分开在不同的缓存行以提升两个线程的操作效率。
武技: 测试 CPU 高速缓存行效果
package memory;
/** @author 周航宇 */
public class CacheLineTest {
/** 本类用于测试高速缓存行CacheLine */
private static class CacheLineDemo {
/**在x属性的左侧设置 7 * 8 = 56 个字节的变量*/
private long p01, p02, p03, p04, p05, p06, p07;
private volatile long x = 0L;
/**在x属性的右侧设置 7 * 8 = 56 个字节的变量*/
private long p09, p10, p11, p12, p13, p14, p15;
}
/** static变量保证共享唯一且先行 */
private static CacheLineDemo[] cacheLineDemos;
static {
// 将两个测试元素放入数组是为了保证在内存中相邻
cacheLineDemos = new CacheLineDemo[2];
cacheLineDemos[0] = new CacheLineDemo();
cacheLineDemos[1] = new CacheLineDemo();
}
@SneakyThrows
@Test
public void testCacheLine() {
// 创建子线程A,修改3亿次demos[0]的x属性,计算耗时
Thread t01 = new Thread(() -> {
long start = System.currentTimeMillis();
for (long i = 0; i < 3_0000_0000L; i++) {
cacheLineDemos[0].x = i;
}
long end = System.currentTimeMillis();
System.out.println("线程A耗时: " + (end - start));
}, "A");
// 创建子线程B,修改3亿次demos[1]的x属性,计算耗时
Thread t02 = new Thread(() -> {
long start = System.currentTimeMillis();
for (long i = 0; i < 3_0000_0000L; i++) {
cacheLineDemos[1].x = i;
}
long end = System.currentTimeMillis();
System.out.println("线程B耗时: " + (end - start));
}, "B");
// 启动两个线程并插队执行
t01.start();
t02.start();
t01.join();
t02.join();
System.out.println("主线程结束");
}
}