文章写在语雀,导出格式可能有混乱,具体可以在这里看文章https://www.yuque.com/pkqzyh/iyareo/70ada569fb9cb3736f3eae493ab097e2

一、多线程基础知识

在Java编程语言中,并发编程主要涉及**线程**,但是进程同样很重要

我们平常写的Java代码,Main方法运行,其实就是一个Java进程启动

package com.pkq.demo01;

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("Main Test");
        Thread.sleep(100000);
    }
}

:::success
打开命令行终端(Windows上的命令提示符或Linux/Mac上的终端),输入 jps命令并回车。这将列出所有由当前用户启动的Java进程,展示每个进程的进程ID(PID)

:::

:::warning
计算机系统通常运行很多的进行和线程

即使在仅仅配备单核处理器,同一时刻只能执行一个线程的系统中也是如此。

单核处理器的运算时间通过OS的时间片轮转机制,在多个进程和线程之间分配。

:::

1. 进程

  • 进程是程序的一次执行过程,是系统运行程序的基本单位。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
  • 在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
  • 每个进程拥有**独立的内存空间**,不同进程的内存区域是 “物理隔离” 的,操作系统通过内存管理机制(如分页、分段)保证一个进程无法直接访问另一个进程的内存(除非通过专门的 IPC 通信机制,如管道、Socket、共享内存)。
  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的

为什么进程要互相隔离

  1. 资源隔离:防止进程间相互干扰。比如进程 A 的内存错误(如数组越界)不会破坏进程 B 的内存数据,保证每个进程的运行稳定性。
  2. 安全防护:避免恶意进程窃取其他进程的敏感数据(如密码、隐私信息),因为它无法直接访问其他进程的内存。
  3. 独立调度:进程是操作系统资源分配的最小单位,独立内存空间让进程可以被单独创建、销毁、调度,不依赖其他进程。

举个例子:你同时打开两个 Java 程序(两个main方法启动的 JVM),这是两个独立的进程 —— 它们的static变量、堆对象完全独立,哪怕变量名相同,修改其中一个的变量,另一个也不会受影响。

和线程的内存模型对比(关键!理解多线程的核心)

之前我们说 “线程共享进程的内存空间”,这里的 “共享” 和进程的 “独立” 形成鲜明对比,用表格总结更清晰:

对比维度 进程 线程(同一进程内)
内存空间 完全独立(堆、方法区、代码段、数据段均独立) **共享**进程的堆、方法区、代码段、数据段;仅线程栈、程序计数器独立
资源分配 操作系统分配资源的最小单位(独立内存、CPU、IO 等) 操作系统调度执行的最小单位(不独立分配内存,共享进程资源)
访问权限 无法直接访问其他进程的内存(需 IPC 通信) 可直接访问进程的共享内存(如堆对象、静态变量)
影响范围 进程崩溃不影响其他进程 线程崩溃可能导致整个进程崩溃(如线程抛出未捕获的 OOM)

:::warning
“线程能直接访问共享内存”,正是多线程编程中 “线程安全问题” 的根源 —— 比如两个线程同时修改进程堆中的同一个对象属性,就可能出现数据不一致。

:::

对Java编程的实际影响

  1. 跨进程通信 vs 多线程通信
    • 跨进程(如两个 JVM 程序):不能直接共享数据,需用 Socket、Redis、消息队列等 IPC 方式;
    • 多线程(同一 JVM 进程):可通过共享堆对象、静态变量通信,但需用synchronizedLock等同步机制保证线程安全。
  2. 内存泄漏的影响范围
    • 进程内的内存泄漏:只会导致当前进程的内存占用升高,不会影响其他进程;
    • 线程的内存泄漏(如线程池未关闭、ThreadLocal未清理):会导致进程内存泄漏,因为线程共享进程内存。
  3. 程序启动开销
    • 启动进程:需要操作系统分配独立内存、初始化资源,开销大;
    • 启动线程:只需创建独立的栈和程序计数器,共享进程资源,开销小(这也是多线程比多进程更适合高并发的原因之一)。

总结

:::color2

  1. 进程的 “独立内存空间” 是操作系统分配的专属容器,核心是堆、方法区、代码段、数据段的独立;
  2. 这种独立性保证了进程的隔离性和安全性,但也导致进程间通信复杂、启动开销大;
  3. 线程共享进程的内存空间(堆、方法区等),是多线程高效通信的基础,但也带来了线程安全问题;
  4. 理解这一点,就能明白:为什么多线程编程要关注 “共享资源保护”,而多进程编程要关注 “IPC 通信机制”。

:::

这也是后续学习 JUC 的重要前提 ——JUC 的核心就是解决 “同一进程内多线程共享内存” 带来的线程安全、并发协作问题~

2.线程

:::info

  • 线程有时候被称为轻量级进程:进程和线程都提供了运行环境,但是创建线程所需的资源比创建进程所需要的资源少
  • 线程存在进程中——每个进程至少包含一个线程,线程共享进程的资源,包括内存和打开的文件。这种机制实现了高效的通信,但是也会导致线程安全问题。
  • 多线程执行是Java平台的核心特性之一。每个应用程序至少拥有一个线程——如果计入执行内存管理和信号处理等任务的系统线程,则会有多个线程,但是从应用程序开发者的角度来看,程序最开始只启动一个叫做主线程的线程,这个主线程具备创建其他线程的能力。

:::

  • 进程内部开辟线程:对OS来说,进程和线程都由CPU执行
  • 进程内部可以包含很多线程

3.并发

并发:指的是两个或多个事件在同一时间间隔内发生。这些事件宏观上是同时发生的,微观上是交替发生的。

单核CPU,可以通过时间片切换的方式,看起来像是同时执行多个任务。

这种单位时间内处理多个任务的能力叫做并发能力。

4.并行

并行:指的是两个或多个事件在同一时刻同时发生

一个单核处理机(CPU)同一时刻只能够执行一个程序,因此操作系统会负责协调多个程序交替执行(这些程序微观上是交替执行的,但是宏观上看起来好像在同时执行)。

多核CPU同一时刻可以同时执行多个程序,多个程序可以并行执行。

并行一定会产生并发,并发不一定产生并行。

5.同步和异步

同步:需要等待结果返回才能继续运行

异步:不需要等待结果返回,就能继续运行

6.用户线程和守护线程

在 Java 中,线程分为用户线程(User Thread)守护线程(Daemon Thread) ,核心区别在于「是否影响 JVM 的退出时机」——JVM 会等待所有用户线程执行完毕后自动退出,而守护线程作为 “服务型线程”,会随用户线程的全部终止而被强制终止(无论自身任务是否完成)。

:::info
用户线程的生命周期。 主线程(用户线程)启动后创建子用户线程,主线程结束后子用户线程继续执行,直到子用户线程结束 JVM 才退出。

:::

:::warning
守护线程的生命周期。 主线程(用户线程)启动后创建守护线程,主线程结束(最后一个用户线程),JVM 强制终止守护线程并退出。

:::

对比维度 用户线程(User Thread) 守护线程(Daemon Thread)
JVM 退出依赖 影响 JVM 退出:JVM 必须等待所有用户线程终止才退出 不影响 JVM 退出:最后一个用户线程终止后,JVM 强制终止守护线程并退出
生命周期 独立生命周期,由自身任务逻辑决定(执行完 / 异常) 依赖用户线程,随用户线程全部终止而终止
核心用途 承载核心业务(如业务处理、数据计算) 提供后台服务(如 GC、日志、监控)
创建方式 默认创建(Thread默认是用户线程) 需调用setDaemon(true)设置(必须在start()前)
资源处理 可执行关键操作(写文件、数据库事务、网络通信) 不可执行关键操作(JVM 可能随时终止,导致资源泄露)
优先级特性 优先级默认 5,OS 调度时与其他线程平等竞争 优先级默认较低(JVM 可能弱化其调度优先级),仅在用户线程空闲时执行
继承性 子线程默认继承父线程的 “用户线程” 属性 若父线程是守护线程,子线程默认也是守护线程(可手动修改)

6.1 用户线程

  • 定义:直接承载核心业务逻辑的线程,是程序运行的 “核心支柱”,JVM 的退出依赖于所有用户线程的终止。
  • 本质:独立于其他线程存在,生命周期由自身任务逻辑决定(执行完run()或异常终止)。
  • 典型例子
    • 主线程(main方法所在线程)默认是用户线程;
    • 手动创建的Thread(未设置为守护线程时);
    • 业务线程(如处理 HTTP 请求、执行数据库事务的线程)。

6.2 守护线程

  • 定义:为用户线程提供 “后台服务” 的线程,不承载核心业务,仅辅助用户线程运行(如资源回收、日志记录)。
  • 本质:生命周期依赖用户线程,当最后一个用户线程终止时,JVM 会强制中断所有守护线程并退出(无需等待守护线程完成任务)。
  • 典型例子
    • JVM 内置的垃圾回收线程(GC Thread) :持续回收用户线程产生的内存垃圾,用户线程全部结束后,GC 线程无存在意义,会被 JVM 终止;
    • 日志输出线程、监控线程(如监控系统资源使用率的线程)。

守护线程,是指在程序运行的时候在后台提供一种通用服务的线程

:::info
当Java进程中有多个线程在执行时,只有当所有非守护线程都执行完毕后,Java进程才会结束。但当非守护线程全部执行完毕后,守护线程无论是否执行完毕,也会一同结束。普通线程t1可以调用t1.setDeamon(true); 方法变成守护线程

:::

注意

  • 垃圾回收器线程就是一种守护线程
  • Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求

:::color2
创建守护线程必须满足两个条件,否则会抛出异常:

  1. 调用Thread.setDaemon(true)设置为守护线程;
  2. 设置操作必须在****Thread.start()**** 之前执行start()后线程状态已从NEW转为RUNNABLE,再设置守护属性会抛IllegalThreadStateException)。

:::

public class MyThread1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(getName() + "---" + i);
        }
    }
}
public class MyThread2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(getName() + "---" + i);
        }
    }
}
public class Demo {
    public static void main(String[] args) {
        MyThread1 t1 = new MyThread1();
        MyThread2 t2 = new MyThread2();

        t1.setName("女神");
        t2.setName("备胎");

        //把第二个线程设置为守护线程
        //当普通线程执行完之后,那么守护线程也没有继续运行下去的必要了.
        t2.setDaemon(true);

        t1.start();
        t2.start();
    }
}

6.3 守护线程的注意点

  1. setDaemon(true) 必须在start() 之前调用。创建守护线程必须满足两个条件,否则会抛出异常:
  2. 守护线程不可以执行关键业务操作:因为当所有的用户线程执行完毕,JVM会强制终止守护线程
  3. 守护线程的优先级较低,调度优先级弱于用户线程。
    1. OS 会优先调度用户线程,守护线程仅在用户线程空闲时(无用户线程需要 CPU)才会获得时间片,因此不宜用守护线程处理 “实时性要求高” 的任务。
  4. 主线程的默认类型是用户线程
  5. 守护线程中创建的子线程默认是守护线程

二、创建线程的方式

方式一:继承Thread

  • 方法介绍
方法名 说明
void run() 在线程开启后,此方法将被调用执行
void start() 使此线程开始执行,Java虚拟机会调用run方法()
  • 实现步骤
    • 定义一个子类继承Thread类,并且重写run()方法
    • 创建 Thread类的子类对象
    • 调用start方法启动线程(启动线程后,会自动执行run方法中的代码)
  • 代码演示继承Thread类
public class MyThread extends Thread{
    // 2、必须重写Thread类的run方法
    @Override
    public void run() {
        // 描述线程的执行任务。
        for (int i = 1; i <= 5; i++) {
            System.out.println("子线程MyThread输出:" + i);
        }
    }
}

测试

public class ThreadTest1 {
    // main方法是由一条默认的主线程负责执行。
    public static void main(String[] args) {
        System.out.println("Main线程开始");
        // 3、创建MyThread线程类的对象代表一个线程
        Thread t = new MyThread();
        // 4、启动线程(自动执行run方法的)
        t.start(); 

         for (int i = 0; i < 3; i++) {
            System.out.println("Main线程输出:"+i);
        }
        System.out.println("Main线程结束");
    }
}

打印结果如下图所示,我们会发现MyThread和main线程在相互抢夺CPU的执行权(注意:哪一个线程先执行,哪一个线程后执行,目前我们是无法控制的,每次输出结果都会不一样


最后我们还需要注意一点:不能直接去调用run方法,如果直接调用run方法就不认为是一条线程启动了,而是把Thread当做一个普通对象,此时run方法中的执行的代码会成为主线程的一部分。

两个小问题

  • 为什么要重写run()方法?因为run()是用来封装被线程执行的代码
  • run()方法和start()方法的区别?
    • run():封装线程执行的代码,直接调用,相当于普通方法的调用
    • start():启动线程;然后由JVM调用此线程的run()方法

方式二:实现Runnable接口

  • Thread构造方法
方法名 说明
Thread(Runnable target) 分配一个新的Thread对象
Thread(Runnable target, String name) 分配一个新的Thread对象
  • 实现步骤
    • 定义一个线程任务类MyRunnable实现Runnable接口,并重写run()方法
    • 创建MyRunnable任务对象
    • 创建Thread类的对象,把Runnable实现类的对象传递给Thread
    • 调用Thread对象的start()方法启动线程(启动后会自动执行Runnable里面的run方法)
  • 代码演示
public class MyRunnable implements Runnable {
    @Override
    public void run() {
         for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName()+"--->"+i);
        }
    }
}

public class MyRunnableDemo {
    public static void main(String[] args) {
        System.out.println("Main线程开始");
        //创建MyRunnable类的对象
        MyRunnable my = new MyRunnable();

        //创建Thread类的对象,把MyRunnable对象作为构造方法的参数
        //Thread(Runnable target)
//        Thread t1 = new Thread(my);
//        Thread t2 = new Thread(my);
        //Thread(Runnable target, String name)
        Thread t1 = new Thread(my,"子线程1");
        Thread t2 = new Thread(my,"子线程2");
        //启动线程
        t1.start();
        t2.start();
        for (int i = 0; i < 5; i++) {
            System.out.println("main线程--->"+i);
        }

        System.out.println("Main线程结束");
    }
}


方式三: 实现Callable接口【应用】

假设线程执行完毕之后有一些数据需要返回,前面两种方式重写的run方法均没有返回结果。

public void run(){
    ...线程执行的代码...
}

JDK5提供了Callable接口和FutureTask类来创建线程,它最大的优点就是有返回值。在Callable接口中有一个call方法,重写call方法就是线程要执行的代码,它是有返回值的

public T call(){
    ...线程执行的代码...
    return 结果;
}
方法名 说明
V call() 计算结果,如果无法计算结果,则抛出一个异常
FutureTask(Callablecallable) 创建一个 FutureTask,一旦运行就执行给定的 Callable
V get() 如有必要,等待计算完成,然后获取其结果
  • 实现步骤、
  • 调用FutrueTask对的get()方法获取返回结果
  1. 先定义一个Callable接口的实现类,重写call方法
  2. 创建Callable实现类的对象
  3. 创建FutureTask类的对象,将Callable对象传递给FutureTask
  4. 创建Thread对象,将Future对象传递给Thread
  5. 调用Thread的start()方法启动线程(启动后会自动执行call方法)
  6. 等call()方法执行完之后,会自动将返回值结果封装到FutrueTask对象中

  • 代码演示
  • 先准备一个Callable接口的实现类
package com.pkq;

import java.util.concurrent.Callable;


public class MyCallable implements Callable<Integer> {
    private int n;
    public MyCallable(int n){
        this.n=n;
    }
     public Integer get(){
         return n;
    }

    /**
     * Computes a result, or throws an exception if unable to do so.
     *
     * @return computed result
     * @throws Exception if unable to compute a result
     */
    @Override
    public Integer call() throws Exception {
        /**
         * 描述线程的任务,返回线程执行返回后的结果
         * 求1-n的和
         */
        int sum=0;
        for(int i=1;i<=n;i++){
            sum+=i;
        }
        return sum;
    }
}

  • 再定义一个测试类,在测试类中创建线程并启动线程,还要获取返回结果
public class ThreadTest3 {
    public static void main(String[] args) throws Exception {
        // 3、创建一个Callable的对象
         MyCallable myCallable = new MyCallable(10);
        // 4、把Callable的对象封装成一个FutureTask对象(任务对象)
        // 未来任务对象的作用?
        // 1、是一个任务对象,实现了Runnable对象.
        // 2、可以在线程执行完毕之后,用未来任务对象调用get方法获取线程执行完毕后的结果。
         FutureTask<Integer> task = new FutureTask<>(myCallable);
        // 5、把任务对象交给一个Thread对象
        new Thread(task).start();


         MyCallable call2 = new MyCallable(20);
        FutureTask<Integer> task2  = new FutureTask<>(call2);
        new Thread(task2).start();


        // 6、获取线程执行完毕后返回的结果。
        // 注意:如果执行到这儿,假如上面的线程还没有执行完毕
        // 这里的代码会暂停,等待上面线程执行完毕后才会获取结果。
       Integer sum = task.get();
        System.out.println("1-"+myCallable.get()+"的和是:"+sum);

        Integer res = task2.get();
        System.out.println("1-"+call2.get()+"的和是:"+res);
    }
}

  • 继承Thread类
  • 好处: 编程比较简单,可以直接使用Thread类中的方法
  • 缺点: 可以扩展性较差,不能再继承其他的类

runnable 和 callable 有什么区别?

相同点

  • 都是接口
  • 都可以编写多线程程序
  • 都采用Thread.start()启动线程

主要区别

  • Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,支持泛型,和Future、FutureTask配合可以用来获取异步执行的结果
  • Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息

:::danger
注:Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

:::

线程的 run()和 start()有什么区别?

  • start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。 多次调用会抛出 java.lang.IllegalThreadStateException 异常
  • new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
  • 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

方式四:线程池(以后讲)

三、线程的常见方法

1. sleep 和 yield

对比维度 Thread.sleep(long millis) Thread.yield()
核心作用 让当前线程 “休眠” 指定时间,期间主动放弃 CPU 执行权 让当前线程 “主动礼让” CPU,给同优先级 / 更高优先级线程执行机会
线程状态变化 RUNNABLETIMED_WAITING(计时等待) 保持 RUNNABLE 状态(仅从 “运行态” 转为 “就绪态”)
锁资源释放 不释放!持有 <font style="color:#DF2A3F;background-color:#F3BB2F;">synchronized</font> / <font style="color:#DF2A3F;background-color:#F3BB2F;">Lock</font> 锁时,锁仍保留 不释放!持有锁时,锁仍保留
调度效果 强制休眠指定时间,期间不参与 CPU 竞争(时间到后才回归就绪) 仅为 OS 提供 “调度建议”,OS 可能忽略(不一定能让其他线程执行)
异常处理 必须捕获 / 抛出 InterruptedException(受检异常) 无异常(无需处理)
参数要求 需传入休眠时间(毫秒 / 纳秒),不能为负数(否则抛 IllegalArgumentException 无参数
使用场景 固定时间暂停(如倒计时、定时任务间隔、降低线程执行频率) 避免单个线程长期独占 CPU(如循环任务中礼让其他线程)

sleep:不会释放锁

  • 调用 sleep 会让当前线程从 Running进入 Timed Waiting 状态(阻塞)
    • 期间线程完全不参与 CPU 调度(即使 CPU 空闲,也不会给它分配时间片)
    • 休眠时间到期后,线程自动从 TIMED_WAITING 回归 RUNNABLE(就绪态),等待 OS 再次分配 CPU;
  • 若休眠期间线程被其他线程中断(调用 thread.interrupt()),会抛出 InterruptedException,并提前结束休眠。
  • 睡眠结束后的线程未必会立刻得到执行
  • 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
package com.pkq.sleepAndYield;
public class SleepYieldTest {
    public static void main(String[] args) {
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        };
        System.out.println(t1.getState());
        t1.start();
        System.out.println(t1.getState());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(t1.getState());
    }
}

:::color2
❗若线程持有 synchronized 锁或 Lock 锁,sleep 期间锁不会释放,其他线程仍无法获取该锁(会进入 BLOCKED 状态)。

:::

package com.pkq.demo02;

// 示例:sleep 不释放 synchronized 锁
public class SleepLockDemo {
    public static void main(String[] args) {
        Object lock = new Object();

        // 线程1持有锁后 sleep
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("线程1获取锁,开始sleep 2秒");
                try {
                    Thread.sleep(2000);
                } // 休眠期间锁仍被持有
                catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程1sleep结束,释放锁");
            }
        }).start();

        // 线程2等待锁(需等线程1 sleep结束释放锁)
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("线程2获取锁,执行任务");
            }
        }).start();
    }
}

执行结果:线程 2 会等待 2 秒(线程 1 sleep 结束释放锁)后,才会执行。

yield:主动礼让,仍可能抢 CPU

  • 调用 yield 会让当前线程 Running 进入 Runnable就绪状态,然后调度执行其它线程
  • 具体的实现依赖于操作系统的任务调度器
  • 调用后,当前线程从 RUNNABLE(运行态)转为 RUNNABLE(就绪态)—— 状态不变,只是从 “正在执行” 回到 “等待 CPU 调度”;
  • 线程仍在就绪队列中,OS 可能再次选中它执行(礼让效果不保证);
  • 仅给 OS 提供 “调度建议”:OS 会优先调度同优先级或更高优先级的线程,若没有其他可执行的线程,当前线程会立即重新获得 CPU;
  • 无异常抛出,调用后直接返回。

不释放锁!和 **<font style="color:#DF2A3F;">sleep</font>** 一样,持有 **<font style="color:#DF2A3F;">synchronized</font>** / **<font style="color:#DF2A3F;">Lock</font>** 锁时, **<font style="color:#DF2A3F;">yield</font>** 不会释放锁,其他线程仍无法获取。

// 示例:yield 不释放锁
public class YieldLockDemo {
    public static void main(String[] args) {
        Object lock = new Object();
        
        new Thread(() -> {
            synchronized (lock) {
                for (int i = 0; i < 5; i++) {
                    System.out.println("线程1执行:" + i);
                    Thread.yield(); // 礼让,但锁仍持有
                }
            }
        }).start();
        
        new Thread(() -> {
            synchronized (lock) {
                for (int i = 0; i < 5; i++) {
                    System.out.println("线程2执行:" + i);
                }
            }
        }).start();
    }
}

执行结果:线程 1 礼让后,因锁仍被持有,线程 2 无法执行,线程 1 会继续执行完循环,释放锁后线程 2 才开始。

总结

对比维度 sleep() 通俗理解 yield() 通俗理解
线程状态 “我要睡 X 毫秒,期间不干活”(TIMED_WAITING) “我现在不忙,谁要 CPU 谁先上,我排队等”(RUNNABLE)
调度效果 强制 “停工” X 毫秒,到期前绝不复工 自愿 “让贤”,但可能刚让完就被 OS 再次选中复工
锁释放 抱着锁睡觉,别人拿不到 抱着锁让贤,别人还是拿不到
异常处理 可能被 “叫醒”(中断),需要处理异常 不会被 “叫醒”,无异常

简单记:

  • sleep() 是 “强制休假”:时间到之前绝对不工作,不放手头的锁;
  • yield() 是 “主动换班”:告诉 OS “我可以等会儿”,但 OS 可能不同意,且不放手头的锁。
1. 用 Thread.sleep() 的场景
  • 需固定时间暂停的场景:如倒计时(“3 秒后执行下一步”)、定时任务间隔(“每 1 秒查询一次状态”);
  • 降低线程执行频率:如非实时性任务(日志打印),避免线程频繁占用 CPU;
  • 模拟延迟:测试多线程并发时,模拟网络延迟、IO 延迟(如 “请求后休眠 500ms 模拟响应时间”)。
2. 用 Thread.yield() 的场景
  • 避免单个线程长期独占 CPU:如循环执行的任务(如消息消费线程),定期调用 yield,给其他同优先级线程执行机会;
  • 优化 CPU 密集型任务的公平性:多个 CPU 密集型线程并发时,用 yield 减少单个线程垄断 CPU 的情况。

应用:防止 CPU 占用 100%

在没有利用 CPU 来计算的时候,不要让 while(true)空转浪费 CPU,这个时候可以使用 yield 或者 sleep 来让出 CPU 的使用权给其他程序

sleep 实现
while(true){
    try{
        Thread.sleep(1000);
    }catch(InterruptedException e){
        e.printStackTrace();
    }
}
  • 可以用 wait 或者调剂变量达到类似效果
  • 不同的是后两种方法都要加锁,并且需要相应的唤醒操作,一般使用于要进行同步的场景
  • sleep 适用于不需要锁同步的场景
wait 实现
synchronized(锁对象) {
    while(条件不满足) {
        try {
            锁对象.wait();
        } catch(InterruptedException e) {
            e.printStackTrace();
        }
    }
    // do sth...
}
条件变量实现
lock.lock();
try {
    while(条件不满足) {
        try {
            条件变量.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    // do sth...
} finally {
    lock.unlock();
}

2.线程休眠【应用】

  • 相关方法
方法名 说明
static void sleep(long millis) 使当前正在执行的线程停留(暂停执行)指定的毫秒数
  • 代码演示
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            System.out.println(Thread.currentThread().getName() + "---" + i);
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        /*System.out.println("睡觉前");
        Thread.sleep(3000);
        System.out.println("睡醒了");*/

        MyRunnable mr = new MyRunnable();

        Thread t1 = new Thread(mr);
        Thread t2 = new Thread(mr);

        t1.start();
        t2.start();
    }
}

3. 线程优先级【应用】

其实线程的优先级设置可以理解为线程抢占CPU时间片的概率,虽然概率比较大,但是它不一定就是按照优先级的顺序去抢占CPU时间片的,具体的执行顺序还是要根据谁先抢到了CPU的时间片,谁就先来执行。 因此千万不要把设置线程的优先顺序当做是线程实际启动的优先顺序哦!

  • 线程调度
  • 两种调度方式
    • 分时调度模型:所有线程轮流使用 CPU 的使用权,平均分配每个线程占用 CPU 的时间片
    • 抢占式调度模型:抢占式调度模型是一种操作系统中的任务调度策略,它允许操作系统在某个任务执行过程中,根据优先级或其他条件,强制中断当前任务并切换到另一个任务。这种调度方式可以确保高优先级任务能够及时得到处理,从而提高系统的响应性和实时性。操作系统通常会使用一个调度器(Scheduler)来管理任务的执行顺序。调度器会根据任务的优先级、时间片轮转等策略来决定哪个任务应该被执行。
  • Java使用的是抢占式调度模型
  • 随机性假如计算机只有一个 CPU,那么 CPU 在某一个时刻只能执行一条指令,线程只有得到CPU时间片,也就是使用权,才可以执行指令。所以说多线程程序的执行是有随机性,因为谁抢到CPU的使用权是不一定的

Java线程调度的特点

  • 时间片轮转:在抢占式调度中,操作系统会为每个线程分配一个时间片(Time Slice),当时间片用完后,操作系统会强制切换线程。
  • 优先级抢占:高优先级线程可以抢占 低优先级线程的执行权。
  • 协作式调度的模拟:Java中可以通过Thread.yield()方法让当前线程主动让出CPU,但这并不是真正的协作式调度,而是对抢占式调度的一种补充。

优先级相关方法

方法名 说明
final int getPriority() 返回此线程的优先级
final void setPriority(int newPriority) 更改此线程的优先级线程默认优先级是5;线程优先级的范围是:1-10
  • setPriority(int newPriority)设置线程优先级(必须在 <font style="color:#DF2A3F;">start()</font> 前调用,否则可能无效或抛出异常)****;
  • getPriority():获取当前线程的优先级。

Java 规定线程优先级为 1~10 的整数,通过 Thread 类的 3 个静态常量定义,默认优先级为 NORM_PRIORITY(5):

  • Thread.MIN_PRIORITY = 1(最低优先级)
  • Thread.NORM_PRIORITY = 5(默认优先级)
  • Thread.MAX_PRIORITY = 10(最高优先级)
  • 代码演示
public class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + "---" + i);
        }
        return "线程执行完毕了";
    }
}
public class Demo {
    public static void main(String[] args) {
        //优先级: 1 - 10 默认值:5
        MyCallable mc = new MyCallable();

        FutureTask<String> ft = new FutureTask<>(mc);

        Thread t1 = new Thread(ft);
        t1.setName("飞机");
        t1.setPriority(10);
        //System.out.println(t1.getPriority());//5
        t1.start();

        MyCallable mc2 = new MyCallable();

        FutureTask<String> ft2 = new FutureTask<>(mc2);

        Thread t2 = new Thread(ft2);
        t2.setName("坦克");
        t2.setPriority(1);
        //System.out.println(t2.getPriority());//5
        t2.start();
    }
}
避免 “线程饥饿”

高优先级线程若长期占用 CPU(如无限循环),可能导致低优先级线程 “永远无法获得 CPU 时间片”(线程饥饿)。但大多数 OS 会有 “优先级衰减” 机制:

  • 高优先级线程持续执行一段时间后,OS 会自动降低其优先级,让低优先级线程有机会执行;
  • Java 本身不处理线程饥饿,依赖 OS 的调度策略。

4. 守护线程【应用】

面试突击:什么是守护线程?它和用户线程有什么区别?_threadpoolexecutor 是守护线程-CSDN博客

在 Java 语言中,线程分为两类:用户线程和守护线程,默认情况下我们创建的线程或线程池都是用户线程,所以用户线程也被称之为普通线程。

守护线程(也叫后台线程或者服务线程)是一种特殊的线程类型,用于为用户线程提供服务,当所有用户线程结束时,它们也会随之结束

在Java中,守护线程被设计为在后台运行,提供支持性服务,例如垃圾收集器就是典型的守护线程。这种线程的存在极大地依赖于用户线程的状态。如果一个程序中所有的用户线程都终止了,那么JVM会退出,即使守护线程仍在执行。

守护线程的创建方法是通过调用线程对象的 setDaemon(true) 方法来实现的,但这种方法必须在线程启动(即调用 start() 方法)之前使用,否则会抛出 IllegalThreadStateException 异常。一旦设置为守护线程,它将在用户线程结束时自动被终止,无论其任务是否完成

  • 相关方法
方法名 说明
void setDaemon(boolean on) 将此线程标记为守护线程,当运行的线程都是守护线程时,Java虚拟机将退出
  • 代码演示
public class MyThread1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(getName() + "---" + i);
        }
    }
}
public class MyThread2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(getName() + "---" + i);
        }
    }
}
public class Demo {
    public static void main(String[] args) {
        MyThread1 t1 = new MyThread1();
        MyThread2 t2 = new MyThread2();

        t1.setName("女神");
        t2.setName("备胎");

        //把第二个线程设置为守护线程
        //当普通线程执行完之后,那么守护线程也没有继续运行下去的必要了.
        t2.setDaemon(true);

        t1.start();
        t2.start();
    }
}

5.礼让线程

在Java中,线程礼让是通过Thread.yield()方法实现的。这个方法使当前线程从运行状态转入就绪状态,但并不保证一定会有其他线程立即代替它运行。这只是向操作系统的调度器发出的一个提示,表示当前线程愿意让出CPU控制权,但最终的决定权仍在操作系统的调度策略上

public class MyThread extends Thread{
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName()+" : "+i);
            if (i%2==0){
                System.out.println(Thread.currentThread().getName()+"让出控制权");
                Thread.yield();
            }
        }
    }
}
package com.pkq.yieldDemo;
public class YieldDemo {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        t1.setName("皮卡丘");
        t2.setName("柯南");
        t1.start();
        t2.start();
    }
}
//输出结果
皮卡丘 : 0
柯南 : 0
皮卡丘让出控制权
皮卡丘 : 1
皮卡丘 : 2
皮卡丘让出控制权
柯南让出控制权
皮卡丘 : 3
皮卡丘 : 4
皮卡丘让出控制权
皮卡丘 : 5
柯南 : 1
皮卡丘 : 6
柯南 : 2
皮卡丘让出控制权
柯南让出控制权
皮卡丘 : 7
皮卡丘 : 8
皮卡丘让出控制权
柯南 : 3
柯南 : 4
皮卡丘 : 9
柯南让出控制权
柯南 : 5
柯南 : 6
柯南让出控制权
柯南 : 7
柯南 : 8
柯南让出控制权
柯南 : 9

Process finished with exit code 0

从这个结果可以看得出来,即便有时候让出了控制权,其他线程也不一定会执行。

6. 插队线程

在 Thread 类中提供了一个 join ()方法来实现“插队功能”。当在某个线程中调用其他线程的 join ()方法时,调用的线程将被阻塞,直到被 join ()方法加入的线程执行完成后它才会继续运行。

public class MyRunnable implements Runnable{
    
    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            System.out.println(Thread.currentThread().getName()+"--->"+i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
public class ThreadTestJoin {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable myRunnable = new MyRunnable();
        Thread t1 = new Thread(myRunnable);
        Thread t2 = new Thread(myRunnable);
        Thread t3 = new Thread(myRunnable);
        t1.start();
        t1.join();
        t2.start();
        t2.join();
        t3.start();
        t3.join();
    }
}

输出结果

Thread-0--->0
Thread-0--->1
Thread-0--->2
Thread-1--->0
Thread-1--->1
Thread-1--->2
Thread-2--->0
Thread-2--->1
Thread-2--->2

执行效果是1号线程先执行完,再执行2号线程;2号线程执行完,再执行3号线程;3号线程执行完就结束了。如果我们把join()方法去掉,我们可以发现,当1号前程还没有执行完毕之前,2号线程已经开始执行了。

Thread-1--->0
Thread-0--->0
Thread-2--->0
Thread-0--->1
Thread-2--->1
Thread-1--->1
Thread-0--->2
Thread-2--->2
Thread-1--->2

7. interrupt 方法

线程中断的理解

首先,一个线程不应该由其他线程来强制中断或停止,而是应该由线程自己自行停止。所以Thread.stop, Thread.suspend, Thread.resume 都已经被废弃了。而 Thread.interrupt 的作用其实也不是中断线程,而是「通知线程应该中断了」。
具体到底中断还是继续运行,应该由被通知的线程自己处理。

具体来说,当对一个线程,调用 interrupt() 时,
① 如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常。仅此而已。
② 如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true,仅此而已。被设置中断标志的线程将继续正常运行,不受影响。

interrupt() 并不能真正的中断线程,需要被调用的线程自己进行配合才行。
也就是说,一个线程如果有被中断的需求,那么就可以这样做。
① 在正常运行任务时,经常检查本线程的中断标志位,如果被设置了中断标志就自行停止线程。
② 在调用阻塞方法时正确处理InterruptedException异常。(例如,catch异常后就结束线程。)

线程中断原理

线程的中断(interrupt)是Java多线程编程中一个重要的概念,它用于请求一个线程停止其当前正在执行的任务中断并不是强制终止线程,而是通过设置线程的中断标志位 ,让线程有机会在合适的时机检查中断状态并做出响应。 下面将详细解释线程中断的机制、使用方法以及相关注意事项:

  1. 中断机制
  • 中断状态位:当调用一个线程的interrupt()方法时,该线程的中断状态位将被设置为true。这意味着该线程被标记为“中断”。
  • 非强制停止:与stop()方法不同,interrupt()不会立即停止线程。它只是设置了一个标志,线程需要自己决定是否响应这个中断请求。
  • 阻塞状态响应:如果线程在调用interrupt()时处于阻塞状态(如调用了sleep()wait()join()等方法),则会抛出InterruptedException异常,并清除中断状态位
  1. 使用方法
  • 检查中断状态:在线程的运行过程中,可以通过调用Thread.currentThread().isInterrupted()来检查当前线程是否被中断。如果返回true,则表示线程已被中断。
  • 处理中断如果线程正在阻塞(如调用sleep() wait() ****join()****等方法),则会立即抛出InterruptedException ,并清除中断标志。 在捕获到InterruptedException异常后,可以选择退出循环或执行其他清理工作。为了确保中断状态被正确传播,可以在捕获异常后再次调用Thread.currentThread().interrupt()来重新设置中断状态。
  1. 注意事项
  • 不可中断的操作:并非所有操作都可以被中断。例如,某些I/O操作和系统调用可能不支持中断。
  • 避免使用废弃方法:不要使用已废弃的stop()suspend()resume()方法来控制线程,因为它们是不安全的。
  • 合理使用中断:中断是一种协作式的线程终止方式,需要程序员在编写代码时考虑到中断的可能性,并在适当的位置检查和处理中断状态。

总的来说,线程的中断是一种优雅地请求线程停止执行的方式,它通过设置中断状态位来实现。线程需要自己检查中断状态并决定是否响应。 在使用中断时,需要注意合理使用,避免使用已废弃的方法,并考虑到不可中断的操作。

打断 sleep,wait,join 的线程

Java提供了以下与线程中断相关的方法:

  • void interrupt():中断目标线程。如果线程正在阻塞(如调用wait()sleep()join()等方法),则会抛出InterruptedException并清除中断状态。
  • boolean isInterrupted():检查线程的中断状态,不会清除中断标志。
  • static boolean interrupted():检查当前线程的中断状态,并清除中断标志。(返回当前线程的中断状态 然后 将当前线程的中断状态设为false****)

sleep,wait,join这几个方法都会让线程进入阻塞状态

打断 sleep 的线程,会清空打断状态,打断标记为 false (认为对 sleep,wait,join的线程进行打断不算打断)

private static void test1() throws InterruptedException {
    Thread t1 = new Thread(()->{
        sleep(1);
    }, "t1");
    t1.start();
    sleep(0.5);
    t1.interrupt();
    log.debug(" 打断状态: {}", t1.isInterrupted());
}
java.lang.InterruptedException: sleep interrupted
     at java.lang.Thread.sleep(Native Method)
     at java.lang.Thread.sleep(Thread.java:340)
     at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
     at cn.itcast.n2.util.Sleeper.sleep(Sleeper.java:8)
     at cn.itcast.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
     at java.lang.Thread.run(Thread.java:745)
21:18:10.374 [main] c.TestInterrupt - 打断状态: false

打断正常运行的线程

:::danger
interrupt打断正常运行的线程, 不会清空打断状态

:::

打断后的打断标记为 true。但是**被打断的线程并不会停止运行**

这样可以通过一个标记让该线程获取自己被其他线程打断了,以便进行后续处理并决定是否停止当前线程

private static void test2() throws InterruptedException {
    Thread t2 = new Thread(()->{
        while(true) {
            Thread current = Thread.currentThread();
            boolean interrupted = current.isInterrupted();
            if(interrupted) {
                log.debug(" 打断状态: {}", interrupted);
                break;
            }
        }
    }, "t2");
    t2.start();
    sleep(0.5);
    t2.interrupt();
}

输出

20:57:37.964 [t2] c.TestInterrupt - 打断状态: true

8. 两阶段终止模式

两阶段终止模式介绍

两阶段终止模式(Two-Phase Termination Pattern)是一种并发编程中用于安全终止线程的设计模式。其核心思想是通过两个阶段实现线程的优雅停止:

  1. 第一阶段:通知线程准备停止(设置停止标志或发送中断信号)。
  2. 第二阶段:线程感知到终止请求后,完成剩余任务并释放资源,最终安全退出。

与直接调用 Thread.stop()(已废弃)不同,该模式避免了强制终止导致的数据不一致或资源泄漏问题。


Java 线程进入终止状态的前提是线程进入 RUNNABLE 状态,而实际上线程也可能处在休眠状态,也就是说,我们要想终止一个线程,首先要把线程的状态从休眠状态转换到 RUNNABLE 状态。如何做到呢?这个要靠 Java Thread 类提供的<font style="color:rgb(214, 51, 132);">interrupt() 方法</font>,它可以将休眠状态的线程转换到 RUNNABLE 状态。

线程转换到 RUNNABLE 状态之后,我们如何再将其终止呢?RUNNABLE 状态转换到终止状态,优雅的方式是让 Java 线程自己执行完 run() 方法,所以一般我们采用的方法是<font style="color:rgb(214, 51, 132);">设置一个标志位</font>,然后线程会在合适的时机检查这个标志位,如果发现符合终止条件,则自动退出 run() 方法。这个过程其实就是我们前面提到的第二阶段:<font style="color:rgb(214, 51, 132);">响应终止指令</font>

综合上面这两点,我们能总结出终止指令,其实包括两方面内容:<font style="color:rgb(214, 51, 132);">interrupt() 方法</font><font style="color:rgb(214, 51, 132);">线程终止的标志位</font>

代码实现

以下示例实现一个后台监控线程,通过两阶段终止模式优雅停止,包含运行态和阻塞态的中断处理。

利用 isInterrupted()

注意事项:

  • interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait,还是正常运行
  • sleep过程中被打断会重置打断标记为false,所以需要重新设置打断标记为 true,以便在上面的if里判断标记,进行后续处理
  • 如果不重新设置打断标记为 true,则不会进行料理后事的处理
package com.pkq.demo2;


import java.util.concurrent.TimeUnit;

/**
 *
 * @author: pkq
 * @date: 2025-12-28
 * @description:
 */
public class TwoPhaseTerminationDemo {
    private Thread monitorThread;

    /**
     * 启动监控线程
     */
    public void startMonitor() {
        monitorThread = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    System.out.println("监控线程:正在采集CPU使用率...");
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    System.out.println("监控线程:接收到终止信号,准备收尾...");
                    
                    Thread.currentThread().interrupt();
                    
                    doCleanup();
                    
                    break;
                }
            }
        }, "MonitorThread");
        monitorThread.start();
    }

    /**
     * 优雅停止监控线程(两阶段终止的入口)
     */
    public void stopMonitor() {
        // 阶段1:发送终止通知(设置中断标志)
        monitorThread.interrupt();
    }

    /**
     * 收尾工作:资源清理、状态保存
     */
    private void doCleanup() {
        System.out.println("监控线程:执行收尾工作 - 关闭CPU采集连接、保存监控数据...");
    }

    // 测试方法
    public static void main(String[] args) throws InterruptedException {
        TwoPhaseTerminationDemo demo = new TwoPhaseTerminationDemo();
        // 启动监控线程
        demo.startMonitor();

        // 运行3秒后,停止监控
        TimeUnit.SECONDS.sleep(3);
        System.out.println("主线程:发出停止监控指令");
        demo.stopMonitor();
    }
}

  1. 核心现象:线程调用 <font style="color:rgb(0, 0, 0);">sleep()</font>/<font style="color:rgb(0, 0, 0);">wait()</font>/<font style="color:rgb(0, 0, 0);">join()</font> 时被中断,抛出 <font style="color:rgb(0, 0, 0);">InterruptedException</font> 的同时,JVM 会自动将中断标志位重置为 <font style="color:rgb(0, 0, 0);">false</font>(清除标志);
  2. 设计原因:JVM 认为异常已经告知线程中断事件,重置标志避免重复处理;
  3. 关键应对:在两阶段终止模式中,捕获该异常后必须手动调用 <font style="color:rgb(0, 0, 0);">Thread.currentThread().interrupt()</font> 重置标志,确保线程能检测到中断信号并完成收尾。
利用停止标记
package com.pkq.demo2;


import java.util.concurrent.TimeUnit;

/**
 *
 * @author: pkq
 * @date: 2025-12-28
 * @description:
 */
public class TwoPhaseTerminationDemo {
    private Thread monitorThread;
    private volatile boolean stop;

    /**
     * 启动监控线程
     */
    public void startMonitor() {
        monitorThread = new Thread(() -> {
            while (true) {
                if (stop) {
                    System.out.println("监控线程:接收到终止信号,准备收尾...");

                    Thread.currentThread().interrupt();

                    doCleanup();

                    break;
                }
                System.out.println("监控线程:正在采集CPU使用率...");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }, "MonitorThread");
        monitorThread.start();
    }

    /**
     * 优雅停止监控线程(两阶段终止的入口)
     */
    public void stopMonitor() {
        // 阶段1:发送终止通知(设置中断标志)
        stop = true;
        monitorThread.interrupt();
    }

    /**
     * 收尾工作:资源清理、状态保存
     */
    private void doCleanup() {
        System.out.println("监控线程:执行收尾工作 - 关闭CPU采集连接、保存监控数据...");
    }

    // 测试方法
    public static void main(String[] args) throws InterruptedException {
        TwoPhaseTerminationDemo demo = new TwoPhaseTerminationDemo();
        // 启动监控线程
        demo.startMonitor();

        // 运行3秒后,停止监控
        TimeUnit.SECONDS.sleep(3);
        System.out.println("主线程:发出停止监控指令");
        demo.stopMonitor();
    }
}

9.LocakSupport 的 park,unpark

interrupt 方法打断 park 线程, 不会清空打断状态

private static void test3() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        log.debug("park...");
        LockSupport.park();
        log.debug("unpark...");
        log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
    }, "t1");
    t1.start();
    sleep(0.5);
    t1.interrupt();
}
21:11:52.795 [t1] c.TestInterrupt - park... 
21:11:53.295 [t1] c.TestInterrupt - unpark... 
21:11:53.295 [t1] c.TestInterrupt - 打断状态:true

如果打断标记已经是 true, 则 park 会失效

private static void test4() {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5; i++) {
            log.debug("park...");
            LockSupport.park();
            log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
        }
    });
    t1.start();
    sleep(1);
    t1.interrupt();
}

输出

21:13:48.783 [Thread-0] c.TestInterrupt - park... 
21:13:49.809 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.812 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.813 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.813 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.813 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true

提示

可以使用 Thread.interrupted() 清除打断状态

10. 线程的生命周期

线程的生命周期包括新建、就绪、运行、阻塞和死亡五种基本状态

在Java中,线程从创建到终止,会经历多个不同的状态转换。具体如下:

  1. 新建状态(New) :当通过new关键字创建一个线程对象时,线程即进入新建状态。此时,线程对象已被初始化,但尚未开始执行。
  2. 就绪状态(Runnable) :当调用线程对象的start()方法后,线程进入就绪状态。处于这个状态的线程已经做好了准备,随时等待CPU调度执行。
  3. 运行状态(Running) :当CPU开始调度处于就绪状态的线程时,线程进入运行状态。在运行状态下,线程开始执行其run()方法中的代码。
  4. 阻塞状态(Blocked) :线程在运行过程中,因为某些原因暂时放弃CPU使用权,进入阻塞状态。阻塞状态可以细分为多种情况,例如等待阻塞、同步阻塞和其他阻塞。
    1. 等待阻塞:运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;
    2. 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),,则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
    3. 其他阻塞: 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。
  5. 死亡状态(Dead) :线程完成run()方法的执行,或者因异常而终止,就会进入死亡状态。一旦线程进入死亡状态,它不能再转入其他状态。

此外,Java线程的状态转换规则是由其生命周期管理的,例如一旦线程进入就绪状态,它就不能再回到新建状态;一旦线程终止,便无法再进入其他任何状态。这种严格的状态管理确保了线程的正确和有序运行。

11.线程的状态

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。线程对象在不同的时期有不同的状态。那么Java中的线程存在哪几种状态呢?Java中的线程

状态被定义在了java.lang.Thread.State枚举类中,State枚举类的源码如下:

public class Thread {
    
    public enum State {
    
        /* 新建 */
        NEW , 

        /* 可运行状态 */
        RUNNABLE , 

        /* 阻塞状态 */
        BLOCKED , 

        /* 无限等待状态 */
        WAITING , 

        /* 计时等待 */
        TIMED_WAITING , 

        /* 终止 */
        TERMINATED;
    
    }
    
    // 获取当前线程的状态
    public State getState() {
        return jdk.internal.misc.VM.toThreadState(threadStatus);
    }
    
}
线程状态 具体含义
NEW 一个尚未启动的线程的状态。也称之为初始状态、开始状态。线程刚被创建,但是并未启动。还没调用start方法。MyThread t = new MyThread()只有线程象,没有线程特征。
RUNNABLE 当我们调用线程对象的start方法,那么此时线程对象进入了RUNNABLE状态。那么此时才是真正的在JVM进程中创建了一个线程,线程一经启动并不是立即得到执行,线程的运行与否要听令与CPU的调度,那么我们把这个中间状态称之为可执行状态(RUNNABLE)也就是说它具备执行的资格,但是并没有真正的执行起来而是在等待CPU的度。
BLOCKED 当一个线程试图获取一个对象锁,而该对象锁被其他的线程持有,则该线程进入Blocked状态;当该线程持有锁时,该线程将变成Runnable状态。
WAITING 一个正在等待的线程的状态。也称之为等待状态。造成线程等待的原因有两种,分别是调用Object.wait()、join()方法。处于等待状态的线程,正在等待其他线程去执行一个特定的操作。例如:因为wait()而等待的线程正在等待另一个线程去调用notify()或notifyAll();一个因为join()而等待的线程正在等待另一个线程结束。
TIMED_WAITING 一个在限定时间内等待的线程的状态。也称之为限时等待状态。造成线程限时等待状态的原因有三种,分别是:Thread.sleep(long),Object.wait(long)、join(long)。
TERMINATED 一个完全运行完成的线程的状态。也称之为终止状态、结束状态

各个状态的转换,如下图所示:

  • New:表示刚刚创建的线程,这种线程还没有开始执行
  • RUNNABLE:运行状态,线程的start()方法调用后,线程会处于这种状态
  • BLOCKED:阻塞状态。当线程在执行的过程中遇到了synchronized同步块,但这个同步块被其他线程已获取还未释放时,当前线程将进入阻塞状态,会暂停执行,直到获取到锁。当线程获取到锁之后,又会进入到运行状态(RUNNABLE)
  • WAITING:等待状态。和TIME_WAITING都表示等待状态,区别是WAITING会进入一个无时间限制的等,而TIME_WAITING会进入一个有限的时间等待,那么等待的线程究竟在等什么呢?一般来说,WAITING的线程正式在等待一些特殊的事件,比如,通过wait()方法等待的线程在等待notify()方法,而通过join()方法等待的线程则会等待目标线程的终止。一旦等到期望的事件,线程就会再次进入RUNNABLE运行状态。
  • TERMINATED:表示结束状态,线程执行完毕之后进入结束状态。

注意:从NEW状态出发后,线程不能在回到NEW状态,同理,处理TERMINATED状态的线程也不能在回到RUNNABLE状态。

演示TIME_WAITING的状态转换

需求:编写一段代码,依次显示一个线程的这些状态:NEW -> RUNNABLE -> TIME_WAITING -> RUNNABLE -> TERMINATED

为了简化我们的开发,本次我们使用匿名内部类结合lambda表达式的方式使用多线程。

代码实现

public class ThreadStateDemo01 {

    public static void main(String[] args) throws InterruptedException {

        //定义一个内部线程
        Thread thread = new Thread(() -> {
            System.out.println("2.执行thread.start()之后,线程的状态:" + Thread.currentThread().getState());
            try {
                //休眠100毫秒
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("4.执行Thread.sleep(long)完成之后,线程的状态:" + Thread.currentThread().getState());
        });

        //获取start()之前的状态
        System.out.println("1.通过new初始化一个线程,但是还没有start()之前,线程的状态:" + thread.getState());

        //启动线程
        thread.start();

        //休眠50毫秒
        Thread.sleep(50);

        //因为thread1需要休眠100毫秒,所以在第50毫秒,thread处于sleep状态
        //用main线程来获取thread1线程的状态,因为thread1线程睡眠时间较长
        //所以当main线程执行的时候,thread1线程还没有睡醒,还处于计时等待状态
        System.out.println("3.执行Thread.sleep(long)时,线程的状态:" + thread.getState());

        //thread1和main线程主动休眠150毫秒,所以在第150毫秒,thread早已执行完毕
        Thread.sleep(100);

        System.out.println("5.线程执行完毕之后,线程的状态:" + thread.getState() + "\n");

    }

}

控制台输出

1.通过new初始化一个线程,但是还没有start()之前,线程的状态:NEW
2.执行thread.start()之后,线程的状态:RUNNABLE
3.执行Thread.sleep(long)时,线程的状态:TIMED_WAITING
4.执行Thread.sleep(long)完成之后,线程的状态:RUNNABLE
5.线程执行完毕之后,线程的状态:TERMINATED

四、线程安全问题

概念解释

线程主要通过共享对象字段以及对象引用来进行通信,这种通信方式效率极高,但是很可能导致两类错误:

  • **线程干扰内存一致性**错误。防止这些错误需要的工具是同步机制。
  • 同步可能导致线程争用。当两个或者多个线程试图访问**同一个资源**时,会导致Java运行时环境减慢一个或者多个线程的执行速度,甚至是暂停其执行。饥饿和活锁都属于线程争用的表现形式。
  • 当发生资源竞争的适合,**三性保证(可见性、有序性、原子性) **可以让程序获得正确结果。

线程同步和线程安全是多线程编程中至关重要的两个概念,它们确保在并发环境下程序的正确性和一致性

线程同步要解决多个线程之间**如何有序、正确地共享资源**的问题。

线程安全则是指多个线程在执行同一段代码时能够**保证程序运行的正确性避免数据不一致**的问题

  1. 线程同步
  • 定义:线程同步的目的是确保多个线程在访问共享资源时能够有序地进行,防止数据错乱和不一致的情况发生。
  • 需要同步的情况:当多线程并发执行时,如果存在对同一共享资源的读写操作,就需要进行同步。
  • 同步机制:Java提供了多种同步机制,包括synchronized关键字、ReentrantLock锁、volatile关键字和ThreadLocal类等。
  1. 线程安全
  • 定义:线程安全是指在多线程环境下,当多个线程同时访问某个方法或类时,能够保证程序运行的正确性和数据的一致性。
  • 安全问题来源:线程安全问题通常是由多个线程同时修改共享资源导致的。例如,两个线程同时修改同一个账户余额可能会导致数据不一致
  • 实现机制:线程安全可以通过互斥锁(如synchronizedReentrantLock)来实现,这些机制确保每次只有一个线程能够访问共享资源。
  1. 区别与联系
  • 线程同步是实现线程安全的一种方式,但线程安全的范围更广,除了同步之外,还包括其他一些机制如锁定、原子操作等。
  • 线程同步关注的是如何使多个线程按顺序执行,而线程安全关注的是最终结果的正确性

综上所述,线程同步和线程安全是多线程编程中密不可分的两个方面,正确应用这些概念和机制可以显著提高程序的可靠性和性能。

1. 卖票问题

多个线程访问同一个资源的适合

  • 如果只有读操作,则不会发生线程安全问题
  • 如果多个线程中对资源涉及到写操作,九容易引发线程安全问题。

😀案例演示:

我们通过电影院卖票的案例,来演示线程安全问题。

假设有三个窗口在卖票,总共100张票

资源定义-同一个资源问题

写法一:局部变量不能共享

public class SaleTicketWindow extends Thread {
    @Override
    public void run() {
        int tickets = 100;
        while (tickets > 0) {
            System.out.println(Thread.currentThread().getName() + "卖了一张票,还剩" + --tickets + "张票");
        }
    }
}


public class SaleTest {
    public static void main(String[] args) {
        //三个窗口同时卖票
        SaleTicketWindow window1 = new SaleTicketWindow();
        SaleTicketWindow window2 = new SaleTicketWindow();
        SaleTicketWindow window3 = new SaleTicketWindow();
        //开始卖票
        window1.start();
        window2.start();
        window3.start();
    }
}

写法二:不同对象的实例变量不共享

public class SaleTicketWindow extends Thread {
     int tickets = 100;
    @Override
    public void run() {
        while (tickets > 0) {
            System.out.println(Thread.currentThread().getName() + "卖了一张票,还剩" + --tickets + "张票");
        }
    }
}


public class SaleTest {
    public static void main(String[] args) {
        //三个窗口同时卖票
        SaleTicketWindow window1 = new SaleTicketWindow();
        SaleTicketWindow window2 = new SaleTicketWindow();
        SaleTicketWindow window3 = new SaleTicketWindow();
        //开始卖票
        window1.start();
        window2.start();
        window3.start();
    }
}

写法三:静态变量可共享

public class SaleTicketWindow extends Thread {
     static int tickets = 100;
    @Override
    public void run() {
        while (tickets > 0) {
            System.out.println(Thread.currentThread().getName() + "卖了一张票,还剩" + --tickets + "张票");
        }
    }
}


public class SaleTest {
    public static void main(String[] args) {
        //三个窗口同时卖票
        SaleTicketWindow window1 = new SaleTicketWindow();
        SaleTicketWindow window2 = new SaleTicketWindow();
        SaleTicketWindow window3 = new SaleTicketWindow();
        //开始卖票
        window1.start();
        window2.start();
        window3.start();
    }
}

写法四:同一个对象的实例变量共享

public class SellTicket implements Runnable {
    private int tickets = 100;

    //在SellTicket类中重写run()方法实现卖票,代码步骤如下
    @Override
    public void run() {
        while (tickets > 0) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "卖出一张票,还剩下" + --tickets + "张票");
        }
    }
}

public class SellTicketDemo {
    public static void main(String[] args) {
        //创建SellTicket类的对象
        SellTicket st = new SellTicket();

        //创建三个Thread类的对象,把SellTicket对象作为构造方法的参数,并给出对应的窗口名称
        Thread t1 = new Thread(st,"窗口1");
        Thread t2 = new Thread(st,"窗口2");
        Thread t3 = new Thread(st,"窗口3");

        //启动线程
        t1.start();
        t2.start();
        t3.start();
    }
}

也可以这样写

public class SaleTicket implements Runnable {
    int ticketNum = 100;

    @Override
    public void run() {
        sale();
    }

    /**
     * 抽取卖票方法
     */
    public void sale(){
        while (ticketNum > 0) {
            System.out.println(Thread.currentThread().getName() + "卖出一张,还剩:" + --ticketNum + "张票");
        }
    }
}

2.抽取资源类

资源类(体现封装性):对资源本身的封装(ticketNum)和对资源操作的封装(sale) 都在一个类中

鉴于前面的代码,根据设计模式思想

  1. 功能要内聚和隔离
  2. 卖票方法不管是多线程还是单线程,都要卖票。不应该写线程类的实现。应该独立编写业务方法。
  3. 任何业务方法,都可以被多线程方式运行,但是我们要保证安全。

public class SaleTicketV2 {
    private int ticketNum = 100;
    public void sale(){
        if (ticketNum > 0) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(Thread.currentThread().getName() + "卖出一张,还剩" + --ticketNum + "张票");
        }else {
            throw new RuntimeException("票已售完");
        }
    }
    public int getTicketNum() {
        return ticketNum;
    }
}


public class SaleTest {
    public static void main(String[] args) {
        //只创建一个对象,S阿勒TicketV2里面的ticketNum只有一个
        SaleTicketV2 saleTicketV2 = new SaleTicketV2();
        new Thread(() -> {
            while (true){
                saleTicketV2.sale();
            }
        }, "窗口1").start();
        new Thread(() -> {
            while (true){
                saleTicketV2.sale();
            }
        }, "窗口2").start();
        new Thread(() -> {
            while (true){
                saleTicketV2.sale();
            }
        }, "窗口3").start();
    }
}


:::danger
常见问题:

  • 卖票出现了问题
  • 相同的票出现了多次
  • 出现了负数的票
  • 问题产生原因线程执行的随机性导致的,可能在卖票过程中丢失cpu的执行权,导致出现问题

:::

为了解决前面的线程安全问题,我们可以使用线程同步思想。同步最常见的方案就是加锁,意思是每次只允许一个线程加锁,加锁后才能进入访问,访问完毕后自动释放锁,然后其他线程才能再加锁进来。

加锁的方法:

  1. 同步代码块
  2. 同步方法
  3. Lock锁

3.同步机制

如果要解决上述多线程并发访问同一个资源引发的线程安全问题(解决重复票和超卖问题),需要通过同步机制来解决。

安全问题出现的条件

  • 是多线程环境
  • 有共享数据
  • 有多条语句操作共享数据

如何解决多线程安全问题呢?

  • 基本思想:让程序没有安全问题的环境
  • 怎么实现呢?
  • 把多条语句操作共享数据的代码给锁起来,让任意时刻只能有一个线程执行即可
  • Java提供了同步代码块的方式来解决
  • 同步代码块格式:同步代码块的作用就是把访问共享数据的代码锁起来,以此保证线程安全。

同步方法

:::info
同步方法非static

:::

public synchronized void sellTicket() {
    // 操作共享资源的代码
}

//等价写法
public void sellTicket() {
    synchronized (this) {
        // 操作共享资源的代码
    }
}

:::info

  • 锁的对象:当前实例 this
  • 也叫“对象锁”/“实例锁”
  • 谁拿到 this 的锁,谁才能执行此同步方法;其它线程要等锁被释放

:::

:::danger
同步静态方法

:::

public static synchronized void sellTicketStatic() {
    // 操作共享资源的代码
}
//等价写法
public static void sellTicketStatic() {
    synchronized (TicketRunnable.class) {
        // 操作共享资源的代码
    }
}

:::danger

  • 锁的对象:当前类的 Class 对象,即 类名.class
  • 也叫“类锁”
  • 所有这个类的实例,共用这一把锁

:::

其实同步方法,就是把整个方法给锁住,一个线程调用这个方法,另一个线程调用的时候就执行不了,只有等上一个线程调用结束,下一个线程调用才能继续执行。

public class SaleTicketV2 {
    private int ticketNum = 100;
    public synchronized void sale(){
        if (ticketNum > 0) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(Thread.currentThread().getName() + "卖出一张,还剩" + --ticketNum + "张票");
        }else {
             throw new RuntimeException(Thread.currentThread().getName() +"票已经卖完了!!!");
        }
    }
}


public class SaleTest {
    public static void main(String[] args) {
        //只创建一个对象,S阿勒TicketV2里面的ticketNum只有一个
        SaleTicketV2 saleTicketV2 = new SaleTicketV2();
        new Thread(() -> {
            while (true){
                saleTicketV2.sale();
            }
        }, "窗口1").start();
        new Thread(() -> {
            while (true){
                saleTicketV2.sale();
            }
        }, "窗口2").start();
        new Thread(() -> {
            while (true){
                saleTicketV2.sale();
            }
        }, "窗口3").start();
    }
}


同步代码块

//锁对象:必须是一个唯一的对象(同一个地址)
synchronized(锁对象){
    //...访问共享数据的代码...
}

synchronized(锁对象):就相当于给代码加锁了,锁对象就可以看成是一把锁

  • 同步的好处和弊端
  • 好处:解决了多线程的数据安全问题
  • 弊端:当线程很多时,因为每个线程都会去判断同步上的锁,这是很耗费资源的,无形中会降低程序的运行效率

锁的对象:你在 synchronized (这里) 填的那个对象

  • 常见选择:
    • this
    • 某个专门的锁对象:private final Object lock = new Object();
    • 类的 Class 对象:类名.class

public class SellTicket implements Runnable {
    private static int tickets = 100;
    private static Object obj = new Object();

    @Override
    public void run() {
        while (true) {
            synchronized (obj) { // 对可能有安全问题的代码加锁,多个线程必须使用同一把锁
                //t1进来后,就会把这段代码给锁起来
                if (tickets > 0) {
                    try {
                        Thread.sleep(100);
                        //t1休息100毫秒
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //窗口1正在出售第100张票
                    System.out.println(Thread.currentThread().getName() + " 卖出一张,还剩 " + --ticketNum + " 张票");
                  
                }
            }
            //t1出来了,这段代码的锁就被释放了
        }
    }
}

public class SellTicketDemo {
    public static void main(String[] args) {
        SellTicket st = new SellTicket();

        Thread t1 = new Thread(st, "窗口1");
        Thread t2 = new Thread(st, "窗口2");
        Thread t3 = new Thread(st, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

4.死锁问题

可重入锁

定义

:::info
可重入锁(Reentrant Lock)是指同一个线程在持有锁的情况下,再次请求获取该锁时不会被阻塞,而是可以成功获取(锁计数器累加);只有当线程完全释放锁(计数器归 0)后,其他线程才能竞争该锁。

synchronized 是 Java 内置的隐式可重入锁,这是它的核心特性之一,目的是避免同一线程重复获取同一锁时发生死锁。

:::

:::success
关键要点:
synchronized 锁是可重入的,同一个线程可以多次获得同一把锁
可重入性避免了同一线程在已经持有锁的情况下再次申请锁而导致的死锁问题
每次获取锁时,JVM会记录锁的持有者和重入次数
当退出同步代码块或方法时,重入计数减1,直到为0时才真正释放锁

:::

我们将通过一个简单的Java例子来展示synchronized关键字的可重入锁特性。
可重入锁意味着同一个线程可以多次获得同一个锁,而不会造成死锁。
在Java中,synchronized内置锁是可重入的。

例子:我们创建一个类,其中有两个同步方法,其中一个同步方法会调用另一个同步方法。
**如果锁不是可重入的,那么线程在调用第二个同步方法时将会被阻塞,因为它已经持有该锁。
**但是,由于synchronized是可重入的,线程可以进入第二个同步方法。

代码实现
public class ReentrantSyncExample {

    public synchronized void outerMethod() {
        System.out.println(Thread.currentThread().getName() + " 进入外层方法");
        try {
            Thread.sleep(100);
            // 调用另一个同步方法(需要同一个锁)
            innerMethod();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " 退出外层方法");
    }

    public synchronized void innerMethod() {
        System.out.println(Thread.currentThread().getName() + " 进入内层方法");
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " 退出内层方法");
    }

    public static void main(String[] args) {
        ReentrantSyncExample example = new ReentrantSyncExample();

        // 创建线程测试
        Thread thread1 = new Thread(() -> {
            example.outerMethod();
        }, "线程1");

        Thread thread2 = new Thread(() -> {
            example.outerMethod();
        }, "线程2");

        thread1.start();
        thread2.start();
    }
}

可重入锁实现原理

JVM 为每个锁对象(Object/Class)维护两个核心属性:

  1. 锁计数器:初始值为 0;
  2. 持有锁的线程 ID:初始为 null。

核心流程:

操作场景 锁计数器变化 持有线程 ID 变化
线程首次获取锁 0 → 1 null → 当前线程 ID
线程再次获取同一锁 n → n+1 保持当前线程 ID 不变
线程释放一次锁 n → n-1 保持当前线程 ID 不变
线程完全释放锁(n=0) 1 → 0 当前线程 ID → null

只有当计数器归 0 时,锁才真正释放,其他线程可竞争。

public class SynchronizedReentrantDemo {
    // 锁对象:当前实例(this)
    public synchronized void recursiveMethod() {
        System.out.println("线程 " + Thread.currentThread().getName() 
                           + " 获取锁,计数器:" + getLockCount());
        // 递归调用(再次获取同一把锁)
        if (getLockCount() < 3) { // 限制递归次数,避免栈溢出
            recursiveMethod();
        }
        System.out.println("线程 " + Thread.currentThread().getName() 
                           + " 释放锁,计数器:" + getLockCount());
    }

    // 模拟获取锁计数器(仅演示,实际需通过 JVM 工具查看)
    private int lockCount = 0;
    private int getLockCount() {
        return ++lockCount; // 仅模拟计数器累加逻辑
    }

    public static void main(String[] args) {
        SynchronizedReentrantDemo demo = new SynchronizedReentrantDemo();
        // 单线程调用递归方法
        new Thread(demo::recursiveMethod, "Thread-0").start();
    }
}
注意事项
  1. 可重入的前提是 “同一锁对象”
    • 如果线程持有对象 obj1 的锁,再请求对象 obj2 的锁,不属于 “重入”,可能引发死锁(比如线程 B 持有 obj2 锁,请求 obj1 锁)。
  2. 锁的粒度
    • synchronized 方法的锁是 this(实例方法)或 Class 对象(静态方法);
    • synchronized 代码块的锁是括号内的任意对象,需确保重入时锁对象一致。
  3. 避免滥用可重入
    • 虽然可重入避免了死锁,但过度嵌套锁会增加锁计数器,若释放次数与获取次数不一致,会导致锁无法释放(其他线程永久阻塞)。

死锁

死锁是指两个或多个线程互相持有对方需要的资源,并且都在等待对方释放自己所需的资源,从而导致所有线程都无法继续执行的情况。

public class DeadlockExample {
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    public void method1() {
        synchronized (lockA) {
            System.out.println(Thread.currentThread().getName() + " 获得了 lockA");

            try {
                // 模拟一些工作,让死锁更容易发生
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            synchronized (lockB) {
                System.out.println(Thread.currentThread().getName() + " 获得了 lockB");
                System.out.println(Thread.currentThread().getName() + " 执行 method1");
            }
        }
    }

    public void method2() {
        synchronized (lockB) {
            System.out.println(Thread.currentThread().getName() + " 获得了 lockB");

            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            synchronized (lockA) {
                System.out.println(Thread.currentThread().getName() + " 获得了 lockA");
                System.out.println(Thread.currentThread().getName() + " 执行 method2");
            }
        }
    }

    public static void main(String[] args) {
        DeadlockExample example = new DeadlockExample();

        Thread thread1 = new Thread(() -> {
            example.method1();
        }, "线程1");

        Thread thread2 = new Thread(() -> {
            example.method2();
        }, "线程2");

        thread1.start();
        thread2.start();

        try {
            // 等待线程执行
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("程序结束"); // 如果发生死锁,这行永远不会执行
    }
}

发生死锁以后,我们可以排查一下

jps:查询正在运行的Java进行,获得进程id

jstack pid:查看某个进程的线程快照(线程的执行情况)

Java程序员必备:jstack命令解析

Java死锁 如何定位?如何避免Java死锁?(图解+秒懂+史上最全)-阿里云开发者社区

5.锁原理

原理

同步机制的原理,其实就相当于给某段代码加锁”,任何线程想要执行这段代码,都要先获得"锁", 我们称为它同步锁。因为Java对象在堆中的数据分为对象头、实例变量、空白填充。(称为 Object Layout: 对象布局)

而对象头中包含:

  • Mark Word:记录了和当前对象有关的GC、锁标记等信息。
  • 指向类的指针:每一个对象需要记录它是出哪个类创建出来的。
  • 数组长度(只有数组对象才有)

哪个线程获得了"同步锁"对象之后,“同步锁”对象就会记录这个线程的ID, 这样其他线程就只能等待了,除非这个线程“释放”了锁对象,其他线程才能重新获得/占用“同步锁”对象。

对象布局

每个对象占用 8kb 的整数倍。

对象实例:(保存到堆空间) :

  1. 对象头
    1. Mark Word:标记字
    2. Klass Point:这个对象是哪个类的实例(指向类,类保存在方法区)
    3. 长度:数组特殊标记
  2. 实例数据(保持每个变量的值)
  3. 数据填充,凑够 8kb 整数倍

JOL:Java 对象布局分析工具(Java Object Layout)

JOL(Java Object Layout)是 OpenJDK 官方提供的工具类库

核心作用是(解析 Java 对象在 JVM 中的内存布局结构)—— 包括对象占用的总内存大小、对象头(Object Header)的组成、实例字段的内存偏移量、对齐填充(Padding)细节等。

简单说:JOL 是 “观察 JVM 内存中对象形态” 的显微镜,能把抽象的对象内存结构(比如对象头里的锁状态、字段对齐规则)转化为直观的文本输出,帮开发者 理解 JVM 底层的内存模型、优化内存占用、排查内存相关问题(如内存泄漏、对象过度创建)。

为什么使用 JOL

Java 对象的内存布局是 JVM 底层实现,开发者无法通过普通代码直接获取(比如<font style="color:rgba(0, 0, 0, 0.85) !important;">new Person()</font>后,你不知道它在内存中占多少字节、锁状态存在哪里)。JOL 的核心价值的是:

  1. 可视化对象内存结构,验证 JVM 的内存布局规则(如 8 字节对齐、字段重排序优化);
  2. 分析对象头(Mark Word、Klass Pointer)的组成,理解<font style="color:rgb(0, 0, 0);">synchronized</font>锁、<font style="color:rgb(0, 0, 0);">volatile</font>、偏向锁 / 轻量级锁的底层存储;
  3. 优化内存占用(比如通过字段顺序调整减少对齐填充,降低对象内存开销);
  4. 学习 JVM 底层原理(如压缩指针、数组对象的内存结构、空对象的内存占用)。
核心功能
  1. 查看对象总内存大小:比如空对象(无任何字段)在 64 位 JVM(开启压缩指针)下占 16 字节;
  2. 解析对象头结构:拆分 Mark Word(存储锁状态、哈希码、GC 分代年龄等)和 Klass Pointer(指向类元数据的指针);
  3. 展示实例字段的内存分布:包括字段的偏移量(offset)、占用字节数、数据类型对齐;
  4. 显示对齐填充(Padding):JVM 为保证对象内存地址是 8 的倍数(64 位 JVM),会在字段后或对象末尾补 0,JOL 能明确标出填充的字节数;
  5. 分析数组对象布局:数组对象的内存结构(对象头 + 数组长度 + 数组元素)与普通对象不同,JOL 可直观展示。
快速上手

JOL 是 OpenJDK 的工具类,需通过 Maven/Gradle 引入依赖,核心 API 是 <font style="color:rgba(0, 0, 0, 0.85);">ClassLayout.parseInstance(对象).toPrintable()</font>(打印对象布局)。

<!-- JOL核心依赖(OpenJDK官方提供) -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version> <!-- 最新版本可在Maven中央仓库查询 -->
</dependency>

我们也可以到 maven 中央仓库下载 jar 包,然后使用

定义一个简单的 Person 类,用 JOL 打印其内存结构:

public class Person {
    private String name;
    private int age;
    public Person(){}
    public Person(String name,int age){
        this.name=name;
        this.age=age;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public int getAge() {
        return age;
    }
    public void setAge(int age) {
        this.age = age;
    }
}

写代码测试

package com.pkq.volatileDemo.demo2;


import org.openjdk.jol.info.ClassLayout;


public class PersonTest {
    public static void main(String[] args) {
        Person person = new Person("皮卡丘",20);
        //引入jol 工具
        String printable = ClassLayout.parseInstance(person).toPrintable();
        System.out.println(printable);

    }
}

执行结果

  • OFFSET(偏移量):字段在对象内存中的起始地址(从 0 开始);
  • SIZE(大小):字段 / 对象头 / 填充的字节数;
  • (object header)(对象头):共 12 字节(开启压缩指针):
    • Mark Word:前 8 字节(存储锁状态、哈希码等,这里是无锁状态<font style="color:rgb(0, 0, 0);">01</font>);
    • Klass Pointer:后 4 字节(指向 Person 类的元数据,压缩指针下从 8 字节→4 字节);

:::info
开启压缩类指针(默认情况堆<=32GB):Klass Pointer 占用 4 字节

未开启压缩类指针(堆>32GB 或显式关闭):Klass Pointer 占用 8 字节

:::

偏向锁延迟:

  • JVM 启动时默认有 4 秒延迟才启动偏向锁
  • 可以通过-XX:BiasedLockingStartupDelay=0 立即启用

:::color3
🚨****偏向锁的历史变迁

  • Java15 默认禁用偏向锁(-XX:UseBiasedLocking)
  • Java17:弃用偏向锁
  • Java21:正式移除

:::

我们修改一下之前的 Person 类,添加一个同步方法,然后重新测试,看看又有何结果

 public synchronized void say(){
        System.out.println("我的名字叫"+name+",今年"+age+"岁");
        System.out.println("锁里面的对象信息");
        String printable = ClassLayout.parseInstance(this).toPrintable();
        System.out.println(printable);
    }

然后测试

public class PersonTest {
    public static void main(String[] args) {
        Person person = new Person("皮卡丘",20);
        //引入jol 工具
        String printable = ClassLayout.parseInstance(person).toPrintable();
        System.out.println(printable);
        Thread thread = new Thread(person::say);
        thread.start();
        try {
            thread.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        String str = ClassLayout.parseInstance(person).toPrintable();
        System.out.println(str);

    }
}

五、线程通信

线程通信是指多个线程之间以某种机制来交换信息、传递数据或协调工作。如果说线程同步解决的是“安全访问”问题(解决竞态条件),那么线程通信解决的就是“协作干活”问题(如等待、通知)。

1.为什么需要通信?

当一个线程需要等待另一个线程完成任务或准备好数据后才能继续执行时,就需要线程通信。 最经典的模型就是生产者-消费者问题。

2. Java中的线程通信方案

Java提供了两大类主流机制:

2.1 传统方式:基于 Object.wait()Object.notify()/notifyAll()

这是Java内置的、基于对象监视器(锁)的通信机制。

  • wait() : 释放当前持有的锁,使当前线程进入等待状态,直到被其他线程唤醒。
  • notify() : 唤醒一个正在此对象监视器上等待的线程(唤醒哪个取决于JVM)。
  • notifyAll() : 唤醒所有在此对象监视器上等待的线程。

⚠️ 关键限制:这三个方法必须在 synchronized 同步块或同步方法内调用,因为它们都需要先获得对象的监视器锁。

2.2 现代方式:使用 java.util.concurrent 包中的高级工具

这是更推荐、更安全、功能更强大的通信方式。

核心工具 主要作用 通信特点与优势
BlockingQueue (阻塞队列) 一个线程安全的队列,当队列满/空时,会自动阻塞插入/取出线程。 生产者和消费者完全解耦,无需手动 wait/notify,代码最简洁,是解决生产者-消费者问题的首选。
CountDownLatch (倒计时闩) 允许一个或多个线程等待其他线程完成操作。 一次性使用。初始化一个计数,线程调用countDown()减1,调用await()的线程会等待直到计数为0。
CyclicBarrier (循环栅栏) 让一组线程互相等待,全部到达屏障点后再一起继续执行。 可重复使用。适用于多线程计算,最后合并结果的场景。
Semaphore (信号量) 控制同时访问特定资源的线程数量,用于流量控制。 可以看作是共享锁的扩展,允许多个许可证。
Exchanger (交换器) 提供一个同步点,两个线程可以交换彼此的数据。 适用于两个线程间配对交换数据的场景。

3.多线程增减

使用wait,notify实现加减交替

等待唤醒机制:

public class ShareDataOne {
    private int cnt = 0;

    public void increment() {
        System.out.println(Thread.currentThread().getName() + "加1 :"+ ++cnt );

    }

    public void decrement() {
        System.out.println(Thread.currentThread().getName() + "减1 :"+ --cnt);
        cnt--;
    }

    public int getCnt() {
        return cnt;
    }
}

public class DataTest {
    public static void main(String[] args) {
        ShareDataOne shareDataOne = new ShareDataOne();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                shareDataOne.increment();
            }
        },"线程A").start();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                shareDataOne.decrement();
            }
        },"线程B").start();

//        System.out.println("cnt:"+shareDataOne.getCnt());
    }

}




外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

线程A[加法],抢到了锁
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程A[加法],抢到了锁
线程A[加法]:不该执行,等待
线程B[减法],抢到了锁
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人
线程B[减法],抢到了锁
线程B[减法]:不该执行,等待
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程A[加法],抢到了锁
线程A[加法]:不该执行,等待
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人
线程B[减法],抢到了锁
线程B[减法]:不该执行,等待
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程A[加法],抢到了锁
线程A[加法]:不该执行,等待
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人
线程B[减法],抢到了锁
线程B[减法]:不该执行,等待
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程A[加法],抢到了锁
线程A[加法]:不该执行,等待
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人
线程B[减法],抢到了锁
线程B[减法]:不该执行,等待
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程A[加法],抢到了锁
线程A[加法]:不该执行,等待
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人
线程B[减法],抢到了锁
线程B[减法]:不该执行,等待
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程A[加法],抢到了锁
线程A[加法]:不该执行,等待
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人
线程B[减法],抢到了锁
线程B[减法]:不该执行,等待
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程A[加法],抢到了锁
线程A[加法]:不该执行,等待
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人
线程B[减法],抢到了锁
线程B[减法]:不该执行,等待
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程A[加法],抢到了锁
线程A[加法]:不该执行,等待
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人
线程B[减法],抢到了锁
线程B[减法]:不该执行,等待
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程A[加法],抢到了锁
线程A[加法]:不该执行,等待
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人
线程B[减法],抢到了锁
线程B[减法]:不该执行,等待
线程A[加法]1: 1
线程A[加法]执行完成,释放锁,唤醒别人
线程B1: 0
线程B[减法]执行完成,释放锁,唤醒别人

4.线程虚假唤醒问题

**虚假唤醒**是指在多线程环境下,线程在调用_wait()_ 方法后,即使没有满足唤醒条件,也可能被唤醒。这种情况可能会导致程序出现错误或异常。虚假唤醒通常是由于线程竞争条件的存在而引起的。

产生虚假唤醒的原因

假设有多个线程执行了_wait()_ 方法,需要其他线程执行_notify()_ 或者_notifyAll()_ 方法去唤醒它们。如果多个线程都被唤醒了,但只有其中一部分是有用的唤醒操作,其余的唤醒都是无用功。例如,仓库有货了才能出库,突然仓库入库了一个货品,这时所有的线程(货车)都被唤醒来执行出库操作,但实际上只有一个线程能执行出库操作,其他线程都是虚假唤醒。

避免虚假唤醒的方法

为了避免虚假唤醒,需要在_while循环中使用_wait() 方法,而不是_if_语句。这样,当线程被唤醒时,它会再次检查条件是否满足,如果不满足,它会继续等待。

我们来看一下if导致的线程虚假唤醒问题

public class ShareDataOne {
    private int cnt = 0;

    public synchronized void increment() throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "[加法],抢到了锁" );
        //不该我干活,等待
        if (cnt==1){
            System.out.println(Thread.currentThread().getName() + "[加法]:不该执行,等待");
            this.wait();
            //一旦别人把wait唤醒,代码从wait的下一行继续执行
        }
        System.out.println(Thread.currentThread().getName() + "[加法]加1: "+ ++cnt);
        //唤醒别人执行
        this.notify();
        System.out.println(Thread.currentThread().getName() + "[加法]执行完成,释放锁,唤醒别人");
    }

    public synchronized void decrement() throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "[减法],抢到了锁");
        if (cnt==0){
            System.out.println(Thread.currentThread().getName() + "[减法]:不该执行,等待");
            this.wait();
        }
        System.out.println(Thread.currentThread().getName() + "减1: "+ --cnt);
        this.notify();
        System.out.println(Thread.currentThread().getName() + "[减法]执行完成,释放锁,唤醒别人");
    }

    public int getCnt() {
        return cnt;
    }
}

public class DataTest {
    public static void main(String[] args) {
        ShareDataOne shareDataOne = new ShareDataOne();
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    shareDataOne.increment();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            },"A-"+i).start();
        }
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    shareDataOne.decrement();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            },"A-"+i).start();
        }
    }

}

根据下面的运行结果,我们发现没有实现加减交替,这是为什么呢?

A-2[加法],抢到了锁
A-2[加法]1: 1
A-2[加法]执行完成,释放锁,唤醒别人
A-3[减法],抢到了锁
A-31: 0
A-3[减法]执行完成,释放锁,唤醒别人
A-0[减法],抢到了锁
A-0[减法]:不该执行,等待
A-4[减法],抢到了锁
A-4[减法]:不该执行,等待
A-0[加法],抢到了锁
A-0[加法]1: 1
A-0[加法]执行完成,释放锁,唤醒别人
A-2[减法],抢到了锁
A-21: 0
A-2[减法]执行完成,释放锁,唤醒别人
A-1[减法],抢到了锁
A-1[减法]:不该执行,等待
A-4[加法],抢到了锁
A-4[加法]1: 1
A-4[加法]执行完成,释放锁,唤醒别人
A-3[加法],抢到了锁
A-3[加法]:不该执行,等待
A-1[加法],抢到了锁
A-1[加法]:不该执行,等待
A-11: 0
A-1[减法]执行完成,释放锁,唤醒别人
A-41: -1
A-4[减法]执行完成,释放锁,唤醒别人
A-01: -2
A-0[减法]执行完成,释放锁,唤醒别人
A-1[加法]1: -1
A-1[加法]执行完成,释放锁,唤醒别人
A-3[加法]1: 0
A-3[加法]执行完成,释放锁,唤醒别人

注意:消费者被唤醒以后,是从wait()方法(被阻塞的地方)之后开始执行,而不是重新从同步块执行

:::success
案例解释:

wait是随机唤醒一个处于阻塞状态的线程,比如A1,A2,A3处于阻塞状态,B2.B1,B0处于阻塞状态。

当B3执行完以后,调用notify方法,随机唤醒一个处于阻塞的线程,然后被唤醒的线程执行之前调用wait方法以后的代码。

如果B2被唤醒,执行-1操作,导致结果为-1,不是我们期待的结果,这种就是虚假唤醒,我们期待唤醒的是A线程,最后唤醒的是B线程。

:::

如何解决虚假唤醒问题?

1、if判断为while判断 2、notify 为notifyAll

:::color3
while是为了再一次循环判断刚刚争抢到锁的线程是否满足继续执行下去的条件,条件通过才可以继续执行下去,不通过的线程只能再次进入wait状态,由其他活着的、就绪状态的线程进行争抢锁。

notifyAll主要是解决线程死锁的情况,每次执行完++或者–操作,都会唤醒其他所有线程为活着的、就绪的、随时可争抢的状态。

:::

A-0[加法],抢到了锁
A-0[加法]1: 1
A-0[加法]执行完成,释放锁,唤醒别人
A-3[减法],抢到了锁
A-31: 0
A-3[减法]执行完成,释放锁,唤醒别人
A-4[减法],抢到了锁
A-4[减法]:不该执行,等待
A-2[减法],抢到了锁
A-2[减法]:不该执行,等待
A-1[减法],抢到了锁
A-1[减法]:不该执行,等待
A-0[减法],抢到了锁
A-0[减法]:不该执行,等待
A-4[加法],抢到了锁
A-4[加法]1: 1
A-4[加法]执行完成,释放锁,唤醒别人
A-3[加法],抢到了锁
A-3[加法]:不该执行,等待
A-2[加法],抢到了锁
A-2[加法]:不该执行,等待
A-1[加法],抢到了锁
A-1[加法]:不该执行,等待
A-01: 0
A-0[减法]执行完成,释放锁,唤醒别人
A-1[减法]:不该执行,等待
A-2[减法]:不该执行,等待
A-4[减法]:不该执行,等待
A-1[加法]1: 1
A-1[加法]执行完成,释放锁,唤醒别人
A-2[加法]:不该执行,等待
A-3[加法]:不该执行,等待
A-41: 0
A-4[减法]执行完成,释放锁,唤醒别人
A-2[减法]:不该执行,等待
A-1[减法]:不该执行,等待
A-3[加法]1: 1
A-3[加法]执行完成,释放锁,唤醒别人
A-2[加法]:不该执行,等待
A-11: 0
A-1[减法]执行完成,释放锁,唤醒别人
A-2[减法]:不该执行,等待
A-2[加法]1: 1
A-2[加法]执行完成,释放锁,唤醒别人
A-21: 0
A-2[减法]执行完成,释放锁,唤醒别人

再思考一下,如果使用的是notify会产生什么问题

5. 面试题:两个线程交替打印

两个线程:一个线程打印1-26,另一个线程打印a-z。打印方式:1a2b3c…26z

public class PrintEach {
    private int num = 1;
    private char c='a';
    //flag=1表示打印数字,flag=2表示打印字母
    private int flag=2;
    public void printNum() throws InterruptedException {
        synchronized (this){
            /**
             * 当flag=1时,说明正在打印数字,此时需要等待,等待时将flag设置为2,表示可以打印字母
             * 当flag=2时,说明正在打印字母,此时需要等待,等待时将flag设置为1,表示可以打印数字
             */
            while (flag==1){
                this.wait();
            }
            System.out.print(num++);
            flag=1;
            this.notifyAll();

        }
    }
    public void printChar() throws InterruptedException {
        synchronized (this){
            while (flag==2){
                this.wait();
            }
            System.out.print(c++);
            flag=2;
            this.notifyAll();
        }
    }
}

测试类

public class TestPrintNumber {
    public static void main(String[] args) {
        PrintEach printNumber = new PrintEach();
        new Thread(()->{
            for (int i = 0; i < 26; i++) {
                try {
                    printNumber.printNum();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"窗口一").start();

        new Thread(()->{
            for (int i = 0; i < 26; i++) {
                try {
                    printNumber.printChar();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }

        },"窗口二").start();
    }
}

六、JMM(Java Memory Model)

Java的JIT编译器,为了优化性能,它可能会将频繁访问的某个堆变量(比如循环条件标志)优化存储到寄存器中,而不是每次都从堆(或缓存)中读取。这样可以显著提高访问速度。

从上面的图中我们可以看出

  1. 所有的共享变量是存储在主存中的
  2. 每个线程都保存一份该线程用到的共享变量的副本
  3. 线程 A 与线程 B 进行通信必须经过两个步骤:
    1. 线程 A 把在本地内存中更新过的共享变量更新到主内存中
    2. 线程 B 到主内存中读取线程 A 之前更新过的共享变量

所以线程 A 无法直接访问线程 B 的工作内存,线程间的通信必须通过主存。

验证可见性

多线程环境下,一个线程对共享变量的修改,另一个线程能够立刻可见,我们称该共享变量具备内存可见性。
谈到内存可见性,要先引出JMM(Java Memory Model,Java内存模型)的概念。JMM规定,将所有的变量都存放在公共主存中,当线程使用变量时会把主存中的变量复制到自己的工作空间(或者叫私有内存)中,线程对变量的读写操作,是自己工作内存中的变量副本。

:::info

  1. 主存变量sum,初始值为0。
  2. 线程A计划将sum加1,先将sum=0复制到自己的私有内存中,然后更新sum的值。线程A操作完成之后其私有内存中sum的值为1,然而线程A将更新后的sum值回刷到主存的时间是不固定的。
  3. 在线程A没有回刷sum到主存前,刚好线程B同样从主存中读取sum,此时值为0,和线程A进行同样的操作,最后期盼的sum=2目标没有达成,最终sum=1。
    线程B没有将sum变成2的原因是:线程A的修改还在其工作内存中,对线程B不可见,因为线程A的修改还没有刷入主存。这就发生了典型的内存可见性问题。

:::

问题本质是 “线程修改共享变量后,未及时同步到主存,导致其他线程读取到旧值”

共享变量的存储模型:Java 中共享变量(如<font style="color:rgba(0, 0, 0, 0.85) !important;">flag</font>)存储在主存,每个线程有自己的 工作内存(CPU 缓存 / 寄存器)。线程操作共享变量时,必须经历 3 步:

  • 从主存读取变量到工作内存(读操作);
  • 在工作内存中修改变量(改操作);
  • 将修改后的值刷回主存(写操作)。

如何解决可见性问题?

只需保证共享变量的修改对其他线程 “可见”,常用方案:

  1. 给共享变量加 <font style="color:rgb(0, 0, 0);">volatile</font> 关键字(强制线程修改后立即刷回主存,且读取时直接从主存读取):<font style="color:rgb(0, 0, 0);">private static volatile Integer flag = 1;</font>
  2. 使用同步锁(如<font style="color:rgb(0, 0, 0);">synchronized</font><font style="color:rgb(0, 0, 0);">Lock</font>):同步块内的变量修改会被强制刷回主存。

修改后重新运行,最终 flag 会稳定输出 2,并且线程 A 不会陷入死循环,可见性问题被解决。

验证有序性

案例演示

所谓程序的**有序性,是指程序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果****,即发生了有序性问题。**

为了提升性能,编译器和 CPU 可能会对指令进行重排序(Reordering)。
在单线程环境下,重排序不会改变执行结果,但在多线程环境下可能导致问题。

指令重排序:一般来说,CPU为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行顺序同代码中的先后顺序一致,但是它会保证程序最终的执行结果和代码顺序执行的结果是一致的。指令重排序不会影响单个线程的执行,但是会影响多个线程并发执行的正确性。

package com.pkq.volatileDemo;

/**
 * ClassName: VolatileReorderingExample
 * Package: com.pkq.volatileDemo
 * Description:
 *
 * @Author pkq
 * @Create 2024-10-31 20:08
 * @Version 1.0
 */
public class VolatileReorderingExample {
    public static  int i=0,j=0;
    public static   int a=0,b=0;

    public static void main(String[] args) throws Exception {
        int cnt=0;
        while (true){
            cnt++;
            a=0;
            b=0;
            i=0;
            j=0;
            //定义两个线程
            //线程A
            Thread t1= new Thread(() -> {
                a = 1;
                i = b;
            });
            //线程B
            Thread t2 = new Thread(() -> {
                b = 1;
                j = a;
            });

            //得到线程执行完毕后的结果
            t1.start();
            t2.start();
            t1.join();//让a线程优先执行完毕
            t2.join();//让b线程优先执行完毕
            //主线程要等待a,b线程执行完毕
            System.out.println("第"+cnt+"次输出结果:i="+i+",j="+j);
            if (i==0 && j==0) {
                break;
            }
        }

    }
}

按代码书写顺序,t1 和 t2 交叉执行时,可能的结果只有 3 种:

  • 情况 1:t1 先执行完(<font style="color:rgb(0, 0, 0);">a=1,i=0</font>)→ t2 再执行(<font style="color:rgb(0, 0, 0);">b=1,j=1</font>)→ 结果 <font style="color:rgb(0, 0, 0);">i=0,j=1</font>
  • 情况 2:t2 先执行完(<font style="color:rgb(0, 0, 0);">b=1,j=0</font>)→ t1 再执行(<font style="color:rgb(0, 0, 0);">a=1,i=1</font>)→ 结果 <font style="color:rgb(0, 0, 0);">i=1,j=0</font>
  • 情况 3:t1 和 t2 交替执行(如 t1 先<font style="color:rgb(0, 0, 0);">a=1</font>→t2<font style="color:rgb(0, 0, 0);">b=1</font>→t1<font style="color:rgb(0, 0, 0);">i=1</font>→t2<font style="color:rgb(0, 0, 0);">j=1</font>)→ 结果 <font style="color:rgb(0, 0, 0);">i=1,j=1</font>

但实际运行中,会出现第四种异常结果:**<font style="color:rgb(0, 0, 0);">i=0且j=0</font>**—— 这就是有序性问题导致的。

现象分析

按照以前的观点:代码执行顺序不会发生改变,也就是第一个线程是 a=1 在 i=b 之前执行的,第二个线程<font style="color:rgb(27, 28, 33);">b=1</font>,是在 <font style="color:rgb(27, 28, 33);">j=a</font>之前执行的。

发生了重排序:在线程 1 和线程 2 内部的两行代码的实际执行顺序和代码在 Java 文件中的顺序是不一样的,代码指令不是严格按照代码顺序执行的,他们的顺序改变了,这样就是发生了重排序,这里颠倒的是 a=1,i=b;以及 j=a,b=1 的顺序,从而发生了指令重排序,直接获取哦了 i=b(0),j=a(0)的值,显然这个值是不对的。

异常结果的根源:指令重排序

  1. 关键前提:单线程指令可重排

线程 t1 的两条指令(<font style="color:rgba(0, 0, 0, 0.85) !important;">a=1</font><font style="color:rgba(0, 0, 0, 0.85) !important;">i=b</font>)在单线程下,编译器 / CPU 可能重排序为:<font style="color:rgba(0, 0, 0, 0.85) !important;">i=b</font><font style="color:rgba(0, 0, 0, 0.85) !important;">a=1</font>;线程 t2 的两条指令(<font style="color:rgba(0, 0, 0, 0.85) !important;">b=1</font><font style="color:rgba(0, 0, 0, 0.85) !important;">j=a</font>)也可能重排序为:<font style="color:rgba(0, 0, 0, 0.85) !important;">j=a</font><font style="color:rgba(0, 0, 0, 0.85) !important;">b=1</font>

单线程下,这种重排完全不影响结果(比如 t1 单线程重排后,<font style="color:rgba(0, 0, 0, 0.85) !important;">i</font>还是 0,<font style="color:rgba(0, 0, 0, 0.85) !important;">a</font>还是 1),符合「as-if-serial 语义」;但多线程下,两条线程的重排指令交叉执行,就会破坏逻辑。

  1. 「i=0 且 j=0」的具体时序(核心!)

当 t1 和 t2 的指令都发生重排序时,会出现以下致命时序:

步骤 线程操作(重排后) 变量状态变化
1 t1 执行重排后的第一条指令:<font style="color:rgb(0, 0, 0);">i = b</font> 此时<font style="color:rgb(0, 0, 0);">b=0</font>
<font style="color:rgb(0, 0, 0);">i=0</font>
2 t2 执行重排后的第一条指令:<font style="color:rgb(0, 0, 0);">j = a</font> 此时<font style="color:rgb(0, 0, 0);">a=0</font>
<font style="color:rgb(0, 0, 0);">j=0</font>
3 t1 执行重排后的第二条指令:<font style="color:rgb(0, 0, 0);">a = 1</font> <font style="color:rgb(0, 0, 0);">a=1</font>
(但 j 已赋值完成,没用了)
4 t2 执行重排后的第二条指令:<font style="color:rgb(0, 0, 0);">b = 1</font> <font style="color:rgb(0, 0, 0);">b=1</font>
(但 i 已赋值完成,没用了)

最终结果:<font style="color:rgba(0, 0, 0, 0.85) !important;">i=0且j=0</font>,触发循环退出。

  1. 为什么<font style="color:rgb(0, 0, 0);">join()</font>解决不了?

代码中<font style="color:rgba(0, 0, 0, 0.85) !important;">t1.join()</font><font style="color:rgba(0, 0, 0, 0.85) !important;">t2.join()</font>仅保证「线程执行完毕」,但不禁止线程内部的指令重排序—— 线程 t1 就算执行完,其内部指令也可能是重排后的顺序,<font style="color:rgba(0, 0, 0, 0.85) !important;">join()</font>无法约束重排序行为。

volatile 禁止重排序

为了确保不会出现这种情况,可以使用 <font style="color:rgb(27, 28, 33);">volatile</font> 关键字来防止指令重排序。

:::info
**<font style="color:rgb(0, 0, 0);">volatile</font>**的关键作用:不能保证原子性(比如<font style="color:rgb(0, 0, 0);">i++</font>仍需锁),而是「禁止重排序」和「保证可见性」—— 本案例用它禁止重排序,解决了异常结果;

:::

验证原子性

所谓原子操作,就是“不可中断的一个或一系列操作”,是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何线程的切换。

我们来看下面的例子

public class Counter {
    private volatile int cnt=0;
    public void increment(){
        cnt++;
    }
    public int getCnt(){
        return cnt;
    }
}

public class CounterTest {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread a = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increment();
            }
        }, "a");

        Thread b = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increment();
            }
        }, "b");
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println(counter.getCnt());

    }
}

为什么会出现这种情况?

cnt++其实是三步操作

  1. 读取 count 的值
  2. 值加 1
  3. 写回内存

但是这几步操作不是原子操作,如果涉及线程切换,导致多个线程同时读到相同值,覆盖更新,最终结果小于预期。

我们可以看看编译后的 字节码 文件

// class version 55.0 (55)
// access flags 0x21
public class com/pkq/volatileDemo/Counter {

  // compiled from: Counter.java

  // access flags 0x42
  private volatile I cnt

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 10 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
   L1
    LINENUMBER 11 L1
    ALOAD 0
    ICONST_0
    PUTFIELD com/pkq/volatileDemo/Counter.cnt : I
    RETURN
   L2
    LOCALVARIABLE this Lcom/pkq/volatileDemo/Counter; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1

  // access flags 0x1
  public increment()V
   L0
    LINENUMBER 13 L0
    ALOAD 0
    DUP
    GETFIELD com/pkq/volatileDemo/Counter.cnt : I
    ICONST_1
    IADD
    PUTFIELD com/pkq/volatileDemo/Counter.cnt : I
   L1
    LINENUMBER 14 L1
    RETURN
   L2
    LOCALVARIABLE this Lcom/pkq/volatileDemo/Counter; L0 L2 0
    MAXSTACK = 3
    MAXLOCALS = 1

  // access flags 0x1
  public getCnt()I
   L0
    LINENUMBER 16 L0
    ALOAD 0
    GETFIELD com/pkq/volatileDemo/Counter.cnt : I
    IRETURN
   L1
    LOCALVARIABLE this Lcom/pkq/volatileDemo/Counter; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1
}

怎么保持原子性呢? 加锁

  • synchronized:保证同一时刻只有一个线程执行临界区代码。
  • Lock(如 ReentrantLock) :更灵活的锁机制。
  • 原子类(AtomicInteger 等) :基于 CAS(Compare-And-Swap)实现的无锁原子操作。

volatile 关键字

保证可见性和有序性

  1. 可见性
    1. 当一个变量被声明为 volatile,任何线程对该变量的修改都会马上写到主内存
    2. 当其他线程读取这个变量的时候,会直接从主内存读取,而不是读取各自线程的变量副本
    3. 这样可以确保一个线程对该变量的修改可以被其他线程看到
  2. 有序性:volatile 关键字可以禁止指令重排序(编译器或者处理器为了优化性能可能对指令进行重排序)
    1. 在写操作之前的所有操作,在写操作之前完成(写屏障)
    2. 在读操作之后的所有操作,在读操作之后完成(读屏障)
    3. 这样可以避免编译器或者处理器对指令进行重排序,从而保证程序按照代码顺序执行

参考资料

【并发】深入理解Java线程的底层原理 - 金鳞踏雨 - 博客园

面试突击:什么是守护线程?它和用户线程有什么区别?_threadpoolexecutor 是守护线程-CSDN博客

Java死锁 如何定位?如何避免Java死锁?(图解+秒懂+史上最全)-阿里云开发者社区

Java程序员必备:jstack命令解析

可见性和有序性的原理-03

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐