JB1-7-并发编程(一)

Java道经第1卷 - 第7阶 - 并发编程(一)


传送门:JB1-7-并发编程(一)
传送门:JB1-7-并发编程(二)

心法:本章使用 Maven 父子结构项目进行练习

练习项目结构如下

|_ v1-6-basic-thread
	|_ test

武技:搭建练习项目结构

  1. 创建父项目 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>
  1. 创建子项目 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. 创建用户线程

心法: 创建用户线程的流程

继承Thread方案
创建线程类
实现Runnable方案
重写run方法
实例化线程类
启动线程
  1. 创建一个线程类,该线程类需要继承 java.lang.Thread 类或实现 java.lang.Runnable 接口,推荐使用实现接口的方式创建线程类,拓展性更高,且 Thread 类本身也实现了 Runnable 接口。
  2. 重写 Runnable 接口中的 run() 方法,该方法也叫线程体,用于编写线程任务,无返回值。
  3. 实例化线程类,并对实例调用 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("主线程结束");
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值
OSZAR »