【java进阶】------ 多线程【上】

1.1 多线程概念

  • 多线程 :指的是从软件或者硬件上实现多个线程并发执行 的技术。具有多线程能力的计算机,能够在同一时间段内调度多个线程任务,从而提升程序的响应能力和资源利用效率。

在 Java 中,多线程并不是简单地"多写几段代码同时运行",而是由 JVM、操作系统调度器以及 CPU 共同参与完成的一种执行机制。线程是程序执行流的基本单位,多线程程序的核心目标,是让多个执行路径围绕同一个程序目标协同工作。

多线程强调的是多个执行路径 。这些执行路径可能在多核 CPU 上真正同时执行,也可能在单核 CPU 上快速切换执行。因此,理解多线程时必须区分"并发 "和"并行"。


1.2 并发和并行


1.2.1 并发(Concurrent)

  • 并发在同一时间段内,有多个任务交替获得 CPU 执行权,看起来像是在同时执行。


并发强调的是任务调度能力。即使只有一个 CPU,操作系统也可以通过时间片轮转,让多个线程交替执行。由于 CPU 切换速度很快,用户通常会感觉多个任务在同时运行。


1.2.2 并行(Parallel)

  • 并行在同一时刻,有多个指令在多个 CPU 或多个 CPU 核心上同时执行。

并行依赖硬件能力。如果一台计算机拥有多个核心,那么多个线程可能真正做到"同时运行"。例如,一个线程负责下载文件,另一个线程负责解析数据,二者在不同 CPU 核心上同时推进。


对比项 并发 并行
核心含义 同一时间段内交替执行 同一时刻真正同时执行
硬件要求 单核也可以实现 通常依赖多核 CPU
关注重点 任务调度能力 任务同时执行能力
典型现象 多个线程抢占 CPU 时间片 多个线程分别运行在不同核心上

注意:Java 多线程程序的运行结果往往具有随机性。因为线程是否能获得 CPU 执行权,并不是由代码书写顺序绝对决定的,而是由线程调度器综合决定。


1.3 进程和线程

1.3.1 进程

  • 进程 :是正在运行的程序。

进程是系统分配资源和调度的独立单位。一个正在运行的 IDEA、浏览器、微信都可以看作进程。进程之间默认拥有相对独立的内存空间,一个进程不能随意访问另一个进程内部的数据。

进程具有以下特征:

  • 独立性:进程是系统进行资源分配和调度的独立单位。
  • 动态性:进程是程序的一次执行过程,会动态创建、运行和消亡。
  • 并发性 :多个进程可以在操作系统中并发执行

1.3.2 线程

  • 线程 :是进程中的单个顺序控制流,是一条具体的执行路径。

一个进程可以包含一个线程,也可以包含多个线程。若一个进程只有一条执行路径,则称为单线程程序 ;若一个进程有多条执行路径,则称为多线程程序

对比项 进程 线程
所属关系 操作系统中的运行程序 进程中的执行路径
资源拥有 拥有独立内存空间 共享所属进程资源
创建开销 较大 较小
通信方式 进程间通信较复杂 同进程线程共享数据更方便

总结:进程是资源分配的基本单位,线程是 CPU 调度的基本单位。Java 多线程开发主要讨论的是:如何创建线程、如何控制线程执行、如何保护共享数据以及如何实现线程协作。


1.4 实现多线程方式一:继承 Thread

1.4.1 应用场景

当一个类本身就可以被设计为"线程任务对象",并且不需要再继承其他父类时,可以通过继承 Thread 类来实现多线程。这种方式结构直观,适合初学阶段理解线程启动流程。

1.4.2 核心方法

方法名 说明
void run() 封装线程启动后要执行的任务代码
void start() 启动线程,JVM 会自动调用该线程对象的 run() 方法

1.4.3 实现步骤

  • 定义一个类继承 Thread 类。
  • 在子类中重写 run() 方法。
  • 创建该线程类的对象。
  • 调用 start() 方法启动线程。

1.4.4 代码示例


1.4.4.1 MyThread
java 复制代码
package com.example.a01threadcase1;

/**
 * 自定义线程类。
 * <p>
 * 通过继承 Thread 类,并重写 run 方法,定义线程启动后要执行的任务。
 */
public class MyThread extends Thread {

    @Override
    public void run() {
        // getName() 获取当前线程的名字,方便观察两个线程交替执行的效果。
        for (int i = 0; i < 100; i++) {
            System.out.println(getName() + ": HelloWorld");
        }
    }
}

1.4.4.2 ThreadDemo
java 复制代码
package com.example.a01threadcase1;
/**
 * 演示创建并启动多线程的第一种方式:继承 Thread 类。
 */
public class ThreadDemo {
    public static void main(String args[]) {
        // 1. 创建自定义线程类的对象。
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();

        // 2. 设置线程名称,便于在控制台区分不同线程的输出。
        t1.setName("线程1");
        t2.setName("线程2");

        // 3. 调用 start 方法启动线程。
        // start 会让 JVM 开启新的线程,并自动调用该线程对象的 run 方法。
        t1.start();
        t2.start();
    }
}

运行结果如下:


1.4.5 核心问题辨析

  • 为什么要重写 run() 方法?
    因为 run() 方法用于封装线程真正要执行的任务代码。
  • 为什么启动线程要调用 start(),而不是直接调用 run()
    直接调用 run() 只是普通方法调用,代码仍然运行在当前线程中;调用 start() 才会真正开启新的线程执行路径。

注意 :同一个 Thread 对象只能调用一次 start()。线程启动后再次调用 start() 会抛出 IllegalThreadStateException


1.5 实现多线程方式二:实现 Runnable 接口

1.5.1 应用场景

当一个类已经继承了其他父类,或者希望将"线程对象 "和"线程任务 "分离时,更推荐实现 Runnable 接口。该方式体现了面向接口编程思想,扩展性更好。


1.5.2 构造方法

构造方法 说明
Thread(Runnable target) 分配一个新的 Thread 对象,并绑定任务对象
Thread(Runnable target, String name) 分配一个新的 Thread 对象,并指定线程名称

1.5.3 实现步骤

  • 定义一个类实现 Runnable 接口。
  • 重写 run() 方法。
  • 创建 Runnable 实现类对象。
  • 创建 Thread 对象,并将任务对象传入构造方法。
  • 调用 start() 方法启动线程。

1.5.4 代码示例


1.5.4.1 MyRunnable
java 复制代码
package com.example.a01threadcase2;
/*
 * 自定义任务类:
 * 实现 Runnable 接口,把线程要执行的代码写在 run 方法中。
 */
public class MyRunnable implements Runnable{
    @Override
    public void run() {
        // run 方法中的代码会在线程启动后执行。
        for (int i = 0; i < 100; i++) {
            // Thread.currentThread() 可以获取当前正在执行这段代码的线程对象。
            System.out.println(Thread.currentThread().getName() +"HelloWorld");
        }
    }
}

1.5.4.2 MyThread
java 复制代码
package com.example.a01threadcase2;
/*
 * 测试类:
 * 演示通过 Runnable 创建线程,并让多个线程执行同一个任务对象。
 */
public class MyThread {
    public static void main(String[] args) {
        // 创建 Runnable 接口的实现类对象,表示线程要执行的任务。
        MyRunnable mr = new MyRunnable();

        // 创建两个线程对象,并把同一个任务对象传递给线程。
        Thread t1 = new Thread(mr);
        Thread t2 = new Thread(mr);

        // 设置线程名称,方便观察不同线程的执行结果。
        t1.setName("线程1");
        t2.setName("线程2");

        // 启动线程。调用 start 方法后,JVM 会自动调用对应线程的 run 方法。
        t2.start();
        t1.start();
    }
}

运行结果如下:

总结Runnable 方式将任务与线程对象解耦,多个线程可以共享同一个任务对象。因此,在实际开发中,它比直接继承 Thread 更常用。


1.6 实现多线程方式三:实现 Callable 接口

1.6.1 应用场景

如果线程任务执行完毕后需要返回结果,就可以使用 Callable 接口。与 Runnable 相比,Callable 的核心优势是:可以通过 call() 方法返回结果,并且可以抛出异常。


1.6.2 核心 API

方法名 说明
V call() 计算结果,如果无法计算结果,则抛出异常
FutureTask(Callable<V> callable) 创建一个 FutureTask 对象,用于包装 Callable 任务
V get() 等待计算完成,并获取线程执行结果

1.6.3 实现步骤

  • 定义一个类实现 Callable 接口。
  • 重写 call() 方法,并设置返回值。
  • 创建 Callable 实现类对象。
  • 创建 FutureTask 对象包装 Callable
  • 创建 Thread 对象,并将 FutureTask 传入构造方法。
  • 启动线程。
  • 调用 get() 方法获取执行结果。

1.6.4 代码示例


1.6.4.1 MyCallable
java 复制代码
package com.example.a01threadcase3;

import java.util.concurrent.Callable;

// Callable 表示一个可以在线程中执行,并且能返回结果的任务
public class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        int sum = 0;
        // 计算 1 到 100 的累加和
        for (int i = 1; i <= 100; i++) {
            sum += i;
        }
        // call 方法的返回值,会被 FutureTask 保存起来
        return sum;
    }
}

1.6.4.2 ThreadDemo
java 复制代码
package com.example.a01threadcase3;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo {
    /*
    * 多线程的第三种实现方式:
    * 特点:可以获取到多线程运行的结果
    * 1.创建一个类MyCallable实现callable接口
    * 2.重写call(是有返回值的,表示多线程运行的结果)
    *
    * 3.创建MyCallable的对象(表示多线程要执行的任务)
    * 4.创建FutureTask的对象(作用管理多线程运行的结果)
    * 5.创建Thread类的对象,并启动(表示线程)
    * */
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 3.创建 Callable 任务对象,任务中定义了线程要执行的代码和返回值
        MyCallable mc = new MyCallable();
        // 4.FutureTask 可以接收 Callable 任务,并在任务执行结束后获取返回结果
        FutureTask<Integer> ft = new FutureTask<>(mc);
        // 5.Thread 不能直接接收 Callable,所以需要用 FutureTask 包装后再交给 Thread
        Thread t1 = new Thread(ft);
        // 启动线程,线程会执行 MyCallable 中的 call 方法
        t1.start();
        // get 方法会等待线程执行完毕,并获取 call 方法的返回值
        Integer result = ft.get();
        System.out.println(result);
    }
}

运行结果如下:


1.6.5 三种实现方式对比

实现方式 优点 缺点 是否有返回值
继承 Thread 编写简单,可直接使用 Thread 方法 Java 单继承限制,扩展性较差
实现 Runnable 扩展性强,任务与线程分离 无法直接返回结果
实现 Callable 可返回结果,可抛出异常 需要配合 FutureTask 使用

最佳实践提示 :如果只是执行任务,优先考虑 Runnable;如果需要返回计算结果,使用 Callable;如果只是学习线程启动机制,继承 Thread 最直观。


1.7 设置和获取线程名称 与 线程休眠

1.7.1 应用场景

多线程程序的输出通常是交替出现的。如果不为线程设置名称,控制台只能看到默认的 Thread-0Thread-1 等名称,排查问题不够直观。因此,在学习和调试阶段,应当主动为线程命名。


1.7.2 常用方法

方法名 说明
void setName(String name) 将线程名称修改为指定名称
String getName() 返回当前线程名称
static Thread currentThread() 返回当前正在执行的线程对象
static void sleep(long time) 让当前线程休眠指定毫秒数

1.7.3 代码示例


1.7.3.1 MyThread
java 复制代码
package com.example.a02threadcase1;

public class MyThread extends Thread {

    // 空参构造方法:不指定线程名称时,会使用 Thread 类提供的默认名称。
    public MyThread() {
    }

    // 带参构造方法:接收线程名称,并交给父类 Thread 保存。
    public MyThread(String name) {
        /*
            super(name) 表示调用父类 Thread 的有参构造方法。

            MyThread 继承了 Thread,线程名称这个属性和相关功能本来就定义在 Thread 中。
            如果这里只接收 name,却不调用 super(name),父类 Thread 就拿不到这个名称,
            后面调用 getName() 时仍然会得到默认名称,例如 Thread-0、Thread-1。
         */
        super(name);
    }

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {

            try {
                /*
                    sleep 方法会让当前线程休眠指定时间。

                    Thread.sleep(1000) 声明了可能抛出 InterruptedException。
                    这是一个编译时异常,Java 要求必须处理:要么继续向外抛出,要么用 try-catch 捕获。

                    run 方法是重写 Thread 的方法,父类中的 run 没有声明抛出 InterruptedException,
                    所以这里不能直接在 run 方法后面写 throws InterruptedException,只能在方法内部用 try-catch 处理。
                 */
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 将中断异常包装成运行时异常抛出,避免静默吞掉线程执行中的异常信息。
                throw new RuntimeException(e);
            }

            System.out.println(getName()+":"+i);
        }
    }
}

1.7.3.2 MyThreadDemo
java 复制代码
package com.example.a02threadcase1;

public class MyThreadDemo {


    /*
        ** String getName() **
            返回当前线程的名称。

        ** void setName(String name) **
            设置线程名称;也可以通过构造方法设置。

        线程名称说明:
            1. 如果没有手动设置名称,线程会使用默认名称。
            2. 默认格式为 Thread-X,其中 X 是从 0 开始的序号。
            3. 可以通过 setName 方法或构造方法给线程设置名称。
            --------------------------------------------
        ** static Thread currentThread() **
            获取当前正在执行的线程对象。

        main 线程说明:
            1. JVM 启动后会自动启动多条线程。
            2. 其中负责调用 main 方法并执行其中代码的线程叫 main 线程。
            3. 以前编写的普通代码,默认都是运行在 main 线程中的。
            --------------------------------------------
        ** static void sleep(long time) **
            让当前线程休眠指定时间,参数单位为毫秒。

        sleep 方法说明:
            1. 哪条线程执行到 sleep 方法,哪条线程就会暂停对应的时间。
            2. 参数表示休眠时间,单位是毫秒;1 秒 = 1000 毫秒。
            3. 休眠时间结束后,线程会自动醒来并继续执行后续代码。
     */

    public static void main(String[] args) {
        MyThread my1 = new MyThread();
        MyThread my2 = new MyThread();

        // void setName(String name):将当前线程的名称修改为指定名称。
        my1.setName("高铁");
        my2.setName("飞机");

//        // Thread(String name):创建线程时直接设置线程名称。
//        // 注意: 在继承 Thread 类自定义线程时,
//        // 若想通过构造方法为新建线程命名,
//        // 必须在子类的构造方法中显式调用父类的有参构造方法(即 super(name))。
//        MyThread my1 = new MyThread("高铁");
//        MyThread my2 = new MyThread("飞机");

        // start():启动线程。
        my1.start();
        my2.start();

        // static Thread currentThread():返回当前正在执行的线程对象。
        System.out.println(Thread.currentThread().getName());
    }
}

运行结果如下:

注意Thread.sleep() 让当前线程进入休眠状态,但不会释放已经持有的同步锁。该方法声明了 InterruptedException,因此必须进行异常处理。


1.8 线程优先级

在多线程环境中,如果计算机只有一个 CPU,那么在某一个特定时刻 CPU 只能执行一条指令。线程只有抢夺到 CPU 的时间片(即使用权) 才可以执行自己的指令。这就引出了不同的线程调度方式以及程序执行的"随机性"特征。


1.8.1 线程调度模型

主流的操作系统或虚拟机通常采用以下两种调度模型之一:

  • 抢占式调度模型
    系统优先让优先级高的线程 使用 CPU。如果多个线程的优先级相同,那么系统会随机选择一个。在这种模型下,优先级高的线程获取 CPU 时间片的概率相对大一些(但这并不意味着绝对先执行完,仅仅是概率上的优势)。

如下列各图:







  • 分时调度模型(非抢占式)
    所有线程轮流获取 CPU 的使用权,系统会平均分配每个线程占用 CPU 的时间片。这种模式主打公平交替,也就是"你一次我一次",并且每个线程每次占用的执行时间也大致相同。

如下列各图:







1.8.2 常用方法

方法名 说明
final int getPriority() 返回线程优先级
final void setPriority(int newPriority) 修改线程优先级

优先级参数说明:

  • 范围1 ~ 10 (超出该范围会抛出 IllegalArgumentException 异常)
  • 默认值5 (主线程及新建线程默认优先级均为 5)

1.8.3 代码示例


1.8.3.1 MyRunnable
java 复制代码
package com.example.a02threadcase2;

public class MyRunnable implements Runnable{

    // 重写 run 方法,这里面包含了线程启动后要执行的具体逻辑
    @Override
    public void run() {
        // 循环 100 次
        for (int i = 1; i <= 100; i++) {
            // Thread.currentThread() 获取当前正在执行这段代码的线程对象
            // .getName() 获取该线程的名称(比如我们在 Demo 中设置的 "飞机" 或 "坦克")
            System.out.println(Thread.currentThread().getName() + "---" + i);
        }
    }
}

1.8.3.2 ThreadDemo
java 复制代码
package com.example.a02threadcase2;

public class ThreadDemo {
    public static void main(String[] args) {

        // 1. 创建线程要执行的任务对象 (由于 MyRunnable 实现了 Runnable 接口,它可以作为参数传递给 Thread)
        MyRunnable mr = new MyRunnable();

        // 2. 创建线程对象
        // 构造方法的第一个参数是任务对象 mr,第二个参数是给这个线程起的名字
        Thread t1 = new Thread(mr, "飞机");
        Thread t2 = new Thread(mr, "坦克");

        // 3. 设置线程的优先级
        // 优先级范围是 1 到 10,默认是 5。
        // 优先级越高,抢占到 CPU 执行权的概率就越大(但这只是概率变大,并不保证绝对优先执行完)。
        t1.setPriority(1);  // 将 t1("飞机") 的优先级设置为最低 1
        t2.setPriority(10); // 将 t2("坦克") 的优先级设置为最高 10

        // 4. 启动线程
        // 注意:必须调用 start() 方法线程才会真正启动,如果直接调用 run() 方法,那就变成普通的方法调用了。
        t1.start();
        t2.start();

        // 5. 获取当前线程的默认优先级
        System.out.println("main线程的默认优先级为: "+Thread.currentThread().getPriority());
    }
}

运行结果如下:

注意线程优先级只会影响调度概率,不保证执行顺序。 不能把业务正确性建立在线程优先级之上。


1.9 守护线程

1.9.1 核心概念

  • 守护线程 :用于服务其他线程。当 JVM 中所有非守护线程(如主线程、普通用户线程)都执行结束后,守护线程也会随之陆续结束,即使它的任务代码还没执行完。
方法名 说明
void setDaemon(boolean on) 将此线程标记为守护线程

经典应用场景:

如图所示的聊天软件

  • 非守护线程(主业务):聊天窗口(线程 1)。只要你还在聊天,程序就必须一直运行。
  • 守护线程(辅助业务):后台文件传输(线程 2)。当聊天窗口(即所有非守护线程)被关闭、程序退出了,此时还在后台传输文件的线程就没有继续运行的必要了,它作为守护线程会被系统随之关闭。
  • (常见的守护线程还有:Java 的垃圾回收器、内存监控线程等)

1.9.2 代码示例("女神"与"备胎")

为了更容易理解,我们用一个"女神"与"备胎"的通俗比喻来演示代码。核心思想是:当女神(非守护线程)离开(结束)了,备胎(守护线程)也就没有存在的意义了,会跟着陆续离开。


1.9.2.1 MyThread1(普通线程 / 女神)
java 复制代码
package com.example.a02threadcase3;

/**
 * 普通线程(用户线程)------ 也就是例子中的"女神"线程。
 * 它的存活周期只受自己任务代码的控制。
 */
public class MyThread1 extends Thread{

    @Override
    public void run() {
        // 女神线程的任务:只循环 10 次就结束了
        for (int i = 1; i <= 10; i++) {
            // getName() 会获取当前线程的名字(在这里就是"女神")
            System.out.println(getName() + "@" + i);
        }
        // 当这个循环执行完毕,"女神"线程就正式死亡(结束运行)了
    }
}

1.9.2.2 MyThread2(守护线程 / 备胎)
java 复制代码
package com.example.a02threadcase3;

/**
 * 守护线程(Daemon Thread)
 * 它的目的是为其他线程提供服务。
 * 当系统中所有的非守护线程都结束时,守护线程也就没有存在的意义了,会被 JVM 陆续停止。
 */
public class MyThread2 extends Thread {

    @Override
    public void run() {
        // 备胎线程的任务:目标是循环 100 次
        for (int i = 1; i <= 100; i++) {
            System.out.println(getName() + "---" + i);
        }
        // 核心原理解析:
        // 虽然这里写了循环 100 次,但因为它是守护线程,
        // 一旦非守护线程(如:女神线程和主线程)全部执行完毕,
        // 守护线程(备胎)就会随之陆续结束,通常跑不到第 100 次。
    }
}

1.9.2.3 ThreadDemo(测试类)
java 复制代码
package com.example.a02threadcase3;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
            final void setDaemon(boolean on)    设置为守护线程
            细节:
                当其他的非守护线程执行完毕之后, 守护线程会陆续结束
            通俗易懂:
                当女神线程结束了, 那么备胎也没有存在的必要了
         */

        MyThread1 t1 = new MyThread1();
        MyThread2 t2 = new MyThread2();

        // 1. 为线程设置名字
        t1.setName("女神");
        t2.setName("备胎");

        // 2. 将 t2 设置为守护线程(备胎线程)
        // 意味着:只要女神线程(以及 main 线程)结束了,这个备胎线程就会随之结束。
        t2.setDaemon(true);

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

运行结果分析

运行代码后会发现,当"女神@10"打印完毕后,"备胎"线程并不会完整执行到 100 次,而是会在打印几句之后(因为 JVM 停止线程需要一个短暂的过程,所以会"陆续"结束)被迫终止。
注意 :必须在调用 start() 启动线程之 前 调用 setDaemon(true)。如果线程已经启动,再试图将其设置为守护线程状态,会抛出 IllegalThreadStateException 异常。


1.10 礼让线程与插入线程

1.10.1 礼让线程:yield()

yield() 表示当前线程愿意让出 CPU 执行权,但下一次 CPU 是否继续分配给该线程并不确定。因此,它只能改善线程交替执行的概率,不能保证严格顺序。


1.10.1.1 MyThread
java 复制代码
package com.example.a02threadcase4;

/**
 * 演示 yield() 礼让线程
 */
public class MyThread extends Thread {
    @Override
    public void run() {
        // "飞机" 和 "坦克" 线程的任务都是循环打印 100 次
        for (int i = 1; i <= 100; i++) {
            System.out.println(getName() + "@" + i);
            
            /*
             * 核心原理解析:Thread.yield()
             * * 如果没有这行代码:
             * "飞机" 线程抢到 CPU 后,可能会一口气打印 10 甚至 20 次,然后才轮到 "坦克"。
             * 结果就是两者的打印是成大块大块交替的。
             * * 加了这行代码后:
             * "飞机" 打印完第 1 次,立马执行 yield() 放弃 CPU,变成大家重新抢。
             * 这样 "坦克" 抢到的概率就变大了。
             * * 【注意】:
             * yield() 只是"礼让"或者"客气一下"。它只是让出当前的执行权,
             * 下一秒 CPU 把执行权分给谁是不确定的。
             * 有可能 "飞机" 刚让出 CPU,下一秒立刻又抢到了。
             * 所以 yield() 只能让多个线程的执行显得"尽可能均匀"一点,但绝对保证不了"你一次我一次"的严格交替。
             */
            Thread.yield(); 
        }
    }
}
1.10.1.2 ThreadDemo
java 复制代码
package com.example.a02threadcase4;

public class ThreadDemo {
    public static void main(String[] args) {
         /*
            public static void yield()  出让线程 / 礼让线程
         */

        // 1. 创建两个线程对象
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();

        // 2. 为线程设置名字,方便在控制台观察交替现象
        t1.setName("飞机");
        t2.setName("坦克");

        // 3. 启动线程
        // 启动后,这两个线程在执行各自的 run() 方法时,
        // 每次循环都会遇到 yield(),从而触发"礼让"机制。
        t1.start();
        t2.start();
    }
}

运行结果如下:

注意
Thread.yield() 仅令当前线程主动出让 CPU 时间片 并退回就绪状态 。由于底层的抢占式调度机制,刚出让 CPU 的线程仍可能立刻再次抢到执行权。因此,yield() 只能提高交替执行的概率,不能保证绝对的先后顺序


1.10.2 插入线程:join()

join() 表示等待指定线程执行完毕。在哪个线程中调用 join(),哪个线程就进入等待状态,直到被插入的线程结束后再继续执行。


1.10.2.1 MyThread.java
java 复制代码
package com.example.a02threadcase5;

/**
 * 自定义线程类
 */
public class MyThread extends Thread {

    @Override
    public void run() {
        // "土豆" 线程的任务是循环打印 8 次
        for (int i = 1; i <= 8; i++) {
            System.out.println(getName() + "@" + i);
        }
    }
}

1.10.2.2 ThreadDemo
java 复制代码
package com.example.a02threadcase5;

public class ThreadDemo {
    // 注意:调用 join() 方法可能会抛出 InterruptedException 异常,需要 throws 声明或 try-catch
    public static void main(String[] args) throws InterruptedException {
        /*
            public final void join()  插入线程 / 插队线程
         */

        // 1. 创建线程对象并命名
        MyThread t = new MyThread();
        t.setName("土豆");
        
        // 2. 启动 "土豆" 线程
        t.start();

        /*
         * 3. 核心原理解析:t.join()
         * * 什么是插队?
         * 表示把 t 这个线程(即"土豆"),插入到【当前线程】之前。
         * * 谁是当前线程?
         * 这行代码是写在 main 方法里的,所以【当前线程】就是 main 线程。
         * * 执行效果:
         * 当代码执行到 t.join() 时,main 线程会被完全阻塞(停下来等待)。
         * 它必须眼睁睁地看着 "土豆" 线程把这 8 次循环全部执行完毕。
         * 只有当 "土豆" 线程彻底死掉(执行结束)后,main 线程才会苏醒,继续往下执行自己的代码。
         */
        t.join();

        // 4. 下面的代码运行在 main 线程当中
        // 由于上面调用了 t.join(),这部分代码绝对不会和 "土豆" 线程交替打印。
        // 它必须等到 "土豆@8" 打印完之后,才会开始打印 "main线程0" 到 "main线程9"。
        for (int i = 0; i < 10; i++) {
            System.out.println("main线程" + i);
        }
    }
}

运行结果如下:

总结yield() 是"让一下",结果不确定;join() 是"等你执行完",结果确定。二者都与线程调度有关,但语义完全不同。


1.11 线程的生命周期(当前周期缺少等待唤醒机制与锁机制)

注意sleep() 睡眠时间结束后,线程不会 立刻执行后续代码。

此时它仅解除"阻塞"并重新进入就绪状态 ,必须再次参与抢夺 CPU 执行权。只有成功进入运行状态后,才会继续向下执行。


1.12 线程安全问题

1.12.1 问题引入:卖票案例

多个售票窗口共同出售 100 张票 ,本质上是多个线程共同操作同一份共享数据。如果没有任何同步控制,就可能出现重票超卖等数据安全问题。



1.12.2 不加锁的卖票程序


1.12.2.1 MyThread
java 复制代码
package com.example.a03threadcase1;

public class MyThread extends Thread {

    // 【共享数据】:必须加 static,保证三个窗口共享这 100 张票
    static int ticket = 0;

    @Override
    public void run() {
        while (true) {
            if (ticket < 100) {
                
                /*
                 * ==============================================================================
                 * 【核心原理解析:为什么会出现线程安全问题?】
                 * 这里的 sleep(100) 模拟了网络延迟或系统调度耗时,
                 * 它的作用是把瞬间完成的操作"慢放",从而 100% 暴露出以下两个严重的并发 Bug:
                 * * Bug 1:超卖现象(卖出第 101 张票)
                 * ------------------------------------------------------------------------------
                 * [前提] 当前 ticket = 99。
                 * [1] 窗口1 进入 if,判断 99 < 100 成立,准备卖票,但在此处 sleep 睡着了。
                 * [2] 窗口2 趁机进入,此时 ticket 仍是 99,判断 99 < 100 成立,也睡着了。
                 * [3] 窗口1 醒来,执行 ticket++,ticket 变为 100,打印卖出【第 100 张】。
                 * [4] 窗口2 醒来,由于它已经过了 if 判断,直接执行 ticket++,
                 * 导致 ticket 变为 101,打印卖出【第 101 张】。超卖发生!
                 * * Bug 2:重票现象(两个窗口卖出同一张票,且某张票凭空消失)
                 * ------------------------------------------------------------------------------
                 * [前提] 当前 ticket = 0。
                 * [1] 窗口1 刚执行完 ticket++(此时 ticket 变 1),还没来得及打印,CPU 被剥夺。
                 * [2] 窗口2 抢到 CPU,也执行 ticket++(此时 ticket 变 2),并打印:卖出【第 2 张】。
                 * [3] 窗口1 重新抢回 CPU,继续执行未完成的打印代码。
                 * 但此时内存中的 ticket 已被窗口2 改为 2,于是窗口1 也打印:卖出【第 2 张】。
                 * 导致 1 号票未被打印,2 号票被卖了两次!重票发生!
                 * ==============================================================================
                 */
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 核心购票动作:票号自增 -> 打印出票信息
                ticket++;
                System.out.println(getName() + "正在卖第" + ticket + "张票");
                
            } else {
                // 票号达到 100,票已售空,退出死循环
                break;
            }
        }
    }
}

1.12.2.2 ThreadDemo
java 复制代码
package com.example.a03threadcase1;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
            需求:
                某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
         */

        // 1. 创建三个独立的线程对象,代表三个售票窗口
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        MyThread t3 = new MyThread();

        // 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

        // 3. 开启线程
        // 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果如下:

1.12.2.3 重票 与 超卖 现象解析
1.12.2.3.1 重票现象








1.12.2.3.2 超卖现象






1.12.3 安全问题出现的条件

线程安全问题通常需要同时满足三个条件:

  • 存在多线程环境。
  • 多个线程共享同一份数据。
  • 多条语句共同操作这份共享数据。

结论 :卖票案例中的 ticket++ 并不是一个不可拆分的操作,它至少包含"读取旧值、计算新值、写回新值"等步骤。一旦线程在中途失去 CPU 执行权,就可能破坏数据一致性。


1.13 同步代码块解决线程安全问题

1.13.1 核心概念

Java 提供了 synchronized 关键字,用于给一段操作共享数据的代码加锁。

java 复制代码
synchronized(锁对象) {
    // 多条语句操作共享数据的代码
}

synchronized(锁对象) 相当于给代码块上锁。任意时刻,只允许一个线程持有这把锁并进入同步代码块执行。


1.13.2 正确示范:使用唯一锁对象

1.13.2.1 MyThread
java 复制代码
package com.example.a03threadcase2;

public class MyThread extends Thread {

    // 【共享数据】:必须加 static,保证三个售票窗口共享这 100 张票
    static int ticket = 0;

    // 【锁对象】:必须加 static!保证所有的线程对象共享同一把"锁"。
    // 随便什么对象都可以作为锁(这里用 Object),但极其关键的是:锁必须是唯一的!
    // 如果不加 static,每个窗口拿着自己的锁进房间,那就形同虚设了。
    static Object obj = new Object();

    @Override
    public void run() {
        while (true) {
            
            /*
             * ==============================================================================
             * 【核心原理解析:同步代码块 (Synchronized)】
             * 语法:synchronized (锁对象) { 操作共享数据的代码 }
             * * 【运行机制推演】:
             * 1. 当窗口1(线程1)执行到这里时,它会检查 obj 这把锁是否被别人拿走。
             * 2. 如果锁还在,窗口1 就会【拿走这把锁】,然后进入花括号里的代码块执行。
             * 3. 此时,即使窗口1 在里面执行了 sleep(10) 呼呼大睡,CPU 执行权被抢走,
             * 当窗口2(线程2)来到这里时,发现门上的锁没了!
             * 4. 窗口2 就会被【阻塞】在 synchronized 门外(进入 BLOCKED 状态),
             * 只能眼巴巴地等着,绝对进不去!
             * 5. 只有等窗口1 睡醒了,把票卖了(ticket++ 并打印),然后【走出】了这个 synchronized 的大括号,
             * 窗口1 才会把锁还回去。
             * 6. 这个时候,门外的窗口2 才能抢到这把锁,进去继续卖下一张票。
             * * 【结论】:
             * synchronized 强制把"判断票数"、"休眠"、"卖票"这三个步骤绑定成了
             * 一个不可分割的【原子操作】。从而完美杜绝了超卖和重票。
             * ==============================================================================
             */
            synchronized (obj) { // 开始同步,获取锁
                
                if (ticket < 100) {
                    try {
                        // 此时即使休眠,也不会释放 obj 锁。别的线程只能在外面干等。
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    ticket++;
                    System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
                    
                } else {
                    // 票卖完了,退出循环
                    break;
                }
                
            } // 同步代码块结束,立刻释放 obj 锁,让其他线程进来抢
        }
    }
}

1.13.2.2 ThreadDemo
java 复制代码
package com.example.a03threadcase2;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
            需求:
                某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
         */

        // 1. 创建三个独立的线程对象,代表三个售票窗口
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        MyThread t3 = new MyThread();

        // 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

        // 3. 开启线程
        // 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果如下:


1.13.3 错误示范一:同步代码块包裹范围过大

同步代码块应该只包裹真正操作共享数据的关键代码。如果把整个循环都放入同步代码块,虽然安全,但会导致一个线程长期持有锁,其他线程几乎没有执行机会。

1.13.3.1 MyThread
java 复制代码
package com.example.a03threadcase3;

public class MyThread extends Thread {

    static int ticket = 0; // 0 ~ 99
    
    // 锁对象,一定要是唯一的
    static Object obj = new Object();

    @Override
    public void run() {
        /*
         * 【同步代码块细节1:不要包裹循环】
         * * 【运行机制推演】:
         * 1. 假设 "窗口1" 最先抢到了 CPU,它顺利拿到了 obj 这把锁,走进了 synchronized 的大门。
         * 2. 紧接着,窗口1 进入了 while(true) 死循环开始卖票。
         * 3. 重点来了:在循环内部,哪怕窗口1 执行了 sleep(10) 睡着了,
         * 由于它【还没有走到 synchronized 的右大括号 "}"】,它【绝对不会把锁交出来】!
         * 4. 此时外面的 "窗口2" 和 "窗口3" 只能眼巴巴地在门外死等。
         * 5. 窗口1 就这样在里面循环:醒了卖,卖了睡,不断循环... 
         * 6. 直到它一口气把 100 张票全部卖光(ticket >= 100),执行 else 里面的 break 跳出循环。
         * 7. 出了循环后,窗口1 才终于走到结尾,释放了锁。
         * 8. 等门外的 窗口2 和 窗口3 终于拿到锁进去时,发现票已经没了,只能直接下班。
         * * 【最终结果】:
         * 毫无并发可言,100张票全被 "窗口1" 一个人包圆了,其他线程饿死。
         */
        synchronized (obj) { 
            while (true) {
                // 原本同步代码块应该只包住这一部分,现在包在了外面
                if (ticket < 100) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    ticket++;
                    System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
                } else {
                    break;
                }
            }
        } // 必须要等到100张票卖完,跳出循环走到这里,才会释放锁!
    }
}
1.13.3.2 ThreadDemo
java 复制代码
package com.example.a03threadcase3;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
            需求:
                某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
         */

        // 1. 创建三个独立的线程对象,代表三个售票窗口
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        MyThread t3 = new MyThread();

        // 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

        // 3. 开启线程
        // 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果如下:


1.13.4 错误示范二:锁对象不唯一

如果每个线程拿到的锁不是同一个对象,那么同步代码块形同虚设。多个线程依然可以同时进入"看似加锁"的代码区域。

1.13.4.1 MyThread
java 复制代码
package com.example.a03threadcase4;

public class MyThread extends Thread {

    // 表示这个类所有的对象,都共享 ticket 数据
    static int ticket = 0; // 0 ~ 99
    
    // 原本正确的唯一锁被注释掉了
    // static Object obj = new Object();

    @Override
    public void run() {
        while (true) {
            
            /*
             * 【严重错误示范:使用了不唯一的锁对象 this】
             * * 【原理解析:为什么 this 在这里没用?】
             * 1. 结合测试类的代码,我们是通过 new MyThread() 创建了 t1、t2、t3 三个线程对象。
             * 2. 在 Java 中,关键字 `this` 代表的是【当前调用这个方法的对象】。
             * 3. 也就是说:
             * - 当 t1 执行到这里时,锁对象是 t1 自己。
             * - 当 t2 执行到这里时,锁对象是 t2 自己。
             * - 当 t3 执行到这里时,锁对象是 t3 自己。
             * 4. 【灾难后果】:
             * 这就相当于这 100 张票放在一个房间里,但这个房间开了 3 扇门。
             * t1 拿着第 1 扇门的钥匙,t2 拿着第 2 扇门的钥匙...
             * 大家各开各的门,各进各的房间,谁也拦不住谁!
             * 最终结果就是:依然会出现超卖和重票,线程安全问题完全没有解决。
             */
            synchronized (this) { 
                // 同步代码块
                if (ticket < 100) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    ticket++;
                    System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
                } else {
                    break;
                }
            }
        }
    }
}
1.13.4.2 ThreadDemo
java 复制代码
package com.example.a03threadcase4;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
            需求:
                某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
         */

        // 1. 创建三个独立的线程对象,代表三个售票窗口
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        MyThread t3 = new MyThread();

        // 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

        // 3. 开启线程
        // 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果如下:


1.13.5 使用 类名.class 作为唯一锁

当共享数据是静态变量时,可以使用当前类的字节码对象作为锁。由于一个类在 JVM 中只对应一个 Class 对象,因此 类名.class 是天然唯一的。


1.13.5.1 MyThread
java 复制代码
package com.example.a03threadcase5;

public class MyThread extends Thread {

    // 表示这个类所有的对象,都共享 ticket 数据
    static int ticket = 0; // 0 ~ 99

    @Override
    public void run() {
        while (true) {
            
            /*
             * ==============================================================================
             * 【底层原理解析:为什么 MyThread.class 是绝对安全的唯一锁?】
             * * 1. 【JVM 类加载机制】:
             * 当 Java 程序启动并首次使用到 MyThread 类时,JVM 的类加载器(ClassLoader)
             * 会将 MyThread.class 字节码文件加载到内存(方法区/元空间)中。
             * * 2. 【Class 对象的单例性】:
             * 在加载的同时,JVM 会在堆内存中为这个类自动创建一个与之对应的 `java.lang.Class` 对象。
             * 核心重点:无论你在主程序中 `new MyThread()` 创建了多少个线程实例(t1, t2, t3...),
             * 这个代表类的 `Class` 对象在整个 JVM 运行周期内,有且仅有一个!
             * * 3. 【替代静态对象】:
             * 之前我们使用 `static Object obj = new Object();` 来保证锁的唯一性,
             * 虽然可行,但在工程实践中容易因为开发者的疏忽(比如漏写 static)而导致锁失效。
             * 直接使用 `MyThread.class`,则是利用了 Java 语言本身的底层机制来获取唯一的互斥锁。
             * 这也是并发编程中处理类级别(静态级别)共享数据时,最正规的实践范式。
             * ==============================================================================
             */
            synchronized (MyThread.class) { 
                // 同步代码块,确保对共享变量 ticket 的"读-改-写"具备原子性
                if (ticket < 100) {
                    try {
                        // 模拟系统调度延迟或网络 I/O 耗时
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    ticket++;
                    System.out.println(getName() + "正在卖第" + ticket + "张票!!!");
                } else {
                    break;
                }
            }
        }
    }
}

1.13.5.2 ThreadDemo
java 复制代码
package com.example.a03threadcase5;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
            需求:
                某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票
         */

        // 1. 创建三个独立的线程对象,代表三个售票窗口
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        MyThread t3 = new MyThread();

        // 2. 为线程起名字,方便在控制台观察到底是哪个窗口卖出了哪张票
        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

        // 3. 开启线程
        // 三个窗口同时开始工作,去执行 MyThread 里面的 run() 方法争抢着卖票
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果如下:

同步原则锁对象必须唯一 ,且同步范围应尽量精确。范围过小无法保证安全,范围过大会降低并发效率。


1.14 同步方法

1.14.1 核心概念

同步方法就是把 synchronized 关键字加到方法声明上。

java 复制代码
修饰符 synchronized 返回值类型 方法名(参数列表) {
    // 方法体
}
  • 非静态同步方法的锁对象是 this

java 复制代码
修饰符 static synchronized 返回值类型 方法名(方法参数) { 
	// 方法体
}
  • 静态同步方法的锁对象是 当前类.class

1.14.2 代码示例


1.14.2.1 MyRunnable
java 复制代码
package com.example.a03threadcase6;

public class MyRunnable implements Runnable {

    // 【核心细节 1:为什么这里不需要加 static?】
    // 因为在测试类中,我们只 new 了一个 MyRunnable 对象(mr),
    // 然后把这同一个 mr 对象作为参数传给了三个 Thread。
    // 这意味着三个线程天然共享这一个 mr 对象里的 ticket 成员变量。
    int ticket = 0;

    @Override
    public void run() {
        // 1. 循环
        while (true) {
            // 2. 调用同步方法。
            // 如果方法返回 true,说明票卖完了,break 跳出循环结束线程。
            // 如果返回 false,说明刚卖了一张票,继续下一次循环。
            if (method()) break;
        }
    }

    /*
     * ==============================================================================
     * 【底层原理解析:同步方法 (Synchronized Method)】
     * * 1. 【语法与表现】:
     * 直接把 synchronized 关键字加在方法返回值的前面。
     * 它的作用和同步代码块一样,保证同一时刻只能有一个线程进入这个方法执行。
     * * 2. 【隐式锁对象到底是谁?】:
     * - 对于【非静态】的同步方法,它的锁对象是 `this`(即当前调用该方法的对象)。
     * - 对于【静态】的同步方法,它的锁对象是 `当前类的字节码文件对象`(即 MyRunnable.class)。
     * * 3. 【在这里为什么安全?】:
     * 因为我们用的是非静态方法,锁是 `this`。
     * 在主程序中,三个线程(t1, t2, t3)都是基于同一个 `mr` 对象启动的。
     * 当它们调用 method() 时,这里的 `this` 全都指向堆内存中那个唯一的 `mr` 对象!
     * 锁对象唯一,因此线程安全得以保证。
     * ==============================================================================
     */
    private synchronized boolean method() {
        // 3. 判断共享数据是否到了末尾,如果到了末尾
        if (ticket == 100) {
            return true;
        } else {
            // 4. 判断共享数据是否到了末尾,如果没有到末尾,执行核心卖票逻辑
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            
            ticket++;
            System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
            
            return false;
        }
    }
}

1.14.2.2 ThreadDemo
java 复制代码
package com.example.a03threadcase6;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
            需求:
                某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票。
                利用同步方法完成。
            技巧:
                同步代码块抽取成独立的方法。
         */

        // 1. 【核心机制】:只创建了一个任务对象 mr
        // 这个 mr 对象内部包含了那个唯一的 ticket 变量。
        MyRunnable mr = new MyRunnable();

        // 2. 创建三个线程对象,把同一个任务对象 mr 传给它们
        // 这样 t1, t2, t3 就绑定在了同一个对象上,执行同一套逻辑,竞争同一把 this 锁。
        Thread t1 = new Thread(mr);
        Thread t2 = new Thread(mr);
        Thread t3 = new Thread(mr);

        // 3. 设置线程名称
        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

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

运行结果如下:

注意 :同步方法并不是新的锁机制,本质仍然是 synchronized。只是锁对象由语法自动确定,因此必须清楚它背后使用的是 this 还是 类名.class


1.15 Lock 锁

1.15.1 应用场景

synchronized 的加锁和释放锁是隐式完成的。为了更清晰地表达"在哪里加锁、在哪里释放锁",JDK 5 以后提供了 Lock 接口,常用实现类是 ReentrantLock


1.15.2 核心 API

方法名 说明
void lock() 获取锁
void unlock() 释放锁
ReentrantLock() 创建一个ReentrantLock的实例

1.15.3 代码示例


1.15.3.1 MyThread
java 复制代码
package com.example.a04threadcase1;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyThread extends Thread {

    // 共享的票数数据
    static int ticket = 0;

    /*
     * ==============================================================================
     * 【核心机制:显式锁 Lock】
     * 1. Lock 是一个接口,通常我们使用它的实现类 ReentrantLock(可重入锁)。
     * 2. 【极其重要】:因为我们这里是继承 Thread 类的方式,会 new 出三个不同的线程对象,
     * 所以这把锁必须加上 `static`,确保这三个窗口争抢的是【同一把锁】!
     * ==============================================================================
     */
    static Lock lock = new ReentrantLock();

    @Override
    public void run() {
        // 1. 死循环,不断卖票
        while (true) {
            
            // 2. 手动加锁:相当于走到这里把门锁上,其他线程只能在外面等
            lock.lock(); 
            
            /*
             * ==============================================================================
             * 【黄金法则:为什么要把释放锁放在 finally 里面?】
             * try-finally 结构是使用 Lock 锁的【标准范式】。
             * * 情景 1:代码正常运行完。走到 finally 释放锁,没问题。
             * 情景 2:遇到 break 跳出循环。注意!在执行 break 真正跳出循环之前,
             * JVM 会强制先去执行 finally 里面的代码!所以锁依然会被安全释放。
             * 情景 3:【最危险的情况】如果在 try 里面代码抛出了异常(比如遇到除数为0等bug),
             * 程序会直接中断向下执行,跳到 catch 里。如果不加 finally,
             * 这个线程就会带着锁"死掉",导致其他等待的线程永远拿不到锁(死锁)。
             * 放在 finally 里,就能保证无论发生什么,锁最终一定会被释放!
             * ==============================================================================
             */
            try {
                // 3. 判断是否卖完
                if (ticket == 100) {
                    break; // 即使执行 break,也会先执行下面的 finally 代码块
                } else {
                    // 4. 模拟卖票耗时
                    Thread.sleep(10);
                    ticket++;
                    System.out.println(getName() + "在卖第" + ticket + "张票");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // 无论如何,最终一定要手动释放锁!相当于把门打开,让其他线程进来。
                lock.unlock(); 
            }
        }
    }
}
1.15.3.2 ThreadDemo
java 复制代码
package com.example.a04threadcase1;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
            需求:
                某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票。
                用 JDK5 的 Lock 接口实现线程安全。
         */

        // 1. 创建三个线程对象
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        MyThread t3 = new MyThread();

        // 2. 设置线程名字
        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

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

运行结果如下:

注意 :使用 Lock 时,释放锁必须写在 finally 中。否则一旦同步代码中发生异常,锁可能无法释放,其他线程将永久等待。


1.16 死锁

1.16.1 核心概念

  • 死锁:两个或多个线程互相持有对方需要的资源,导致这些线程都处于等待状态,无法继续向下执行。

死锁常见产生条件如下:

  • 资源有限:系统中的互斥资源(如唯一的锁对象)数量少于同时争抢的线程数,这是发生死锁的客观前提。
  • 存在同步嵌套:一个线程在没有释放已持有锁的情况下,又去尝试获取其他锁(即"锁中套锁"),导致资源被长期霸占不放。
  • 获取锁的顺序不一致:例如线程A先拿锁1再拿锁2,而线程B先拿锁2再拿锁1。双方互相握着对方需要的下一把锁,形成解不开的死循环等待。

1.16.2 代码示例


1.16.2.1 MyThread
java 复制代码
package com.example.a04threadcase2;

public class MyThread extends Thread {

    // 创建两个唯一的锁对象 A 和 B
    static Object objA = new Object();
    static Object objB = new Object();

    @Override
    public void run() {
        // 1. 循环
        while (true) {
            
            /*
             * ==============================================================================
             * 【死锁原理解析:互相等待,谁也不让谁】
             * * 【运行推演】:
             * 1. 假设 "线程A" 先抢到 CPU,进入 if 判断,拿到了 objA 锁。
             * 此时它准备去拿 objB 锁,但在拿之前,CPU 执行权被剥夺了!
             * 2. "线程B" 抢到 CPU,进入 else if 判断,它毫无阻碍地拿到了 objB 锁。
             * 紧接着,它准备去拿 objA 锁,发现 objA 已经被别人拿走了(在线程A手里),
             * 于是 "线程B" 陷入阻塞状态(死等)。
             * 3. 此时 CPU 执行权切回给 "线程A"。"线程A" 接着往下执行,准备拿 objB 锁,
             * 发现 objB 已经被别人拿走了(在线程B手里),于是 "线程A" 也陷入阻塞。
             * * 【最终结局】:
             * 线程A 握着 objA 想要 objB;
             * 线程B 握着 objB 想要 objA。
             * 双方互相僵持,程序永远卡死在这里,这就是死锁。
             * ==============================================================================
             */
            
            if ("线程A".equals(getName())) {
                synchronized (objA) {
                    System.out.println("线程A拿到了A锁,准备拿B锁");
                    
                    // 嵌套锁:在没有释放 A 锁的情况下,尝试去获取 B 锁
                    synchronized (objB) {
                        System.out.println("线程A拿到了B锁,顺利执行完一轮");
                    }
                } // 走到这里才会同时释放 A 和 B
                
            } else if ("线程B".equals(getName())) {
                if ("线程B".equals(getName())) { // 这里的 if 其实有点多余,因为上面已经 else if 判断过了
                    synchronized (objB) {
                        System.out.println("线程B拿到了B锁,准备拿A锁");
                        
                        // 嵌套锁:在没有释放 B 锁的情况下,尝试去获取 A 锁
                        synchronized (objA) {
                            System.out.println("线程B拿到了A锁,顺利执行完一轮");
                        }
                    }
                }
            }
        }
    }
}

1.16.2.2 ThreadDemo
java 复制代码
package com.example.a04threadcase2;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
            需求:
                演示死锁现象
         */

        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();

        t1.setName("线程A");
        t2.setName("线程B");

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

运行结果如下:

最佳实践提示 :避免死锁的核心,是保证多个线程获取锁的顺序一致减少同步嵌套 ,并尽量缩短持有锁的时间


1.17 生产者和消费者模式

1.17.1 模式概述

生产者消费者模式是一个经典的多线程协作模型。它包含两类线程:

  • 生产者线程:负责生产数据,并放入共享区域。
  • 消费者线程:负责从共享区域取出数据并消费。

为了降低生产者与消费者之间的耦合,通常会引入一个共享数据区域,例如桌子、仓库、队列等。生产者只负责放数据,消费者只负责取数据,二者通过共享区域间接协作。


1.17.1.1 案例描述

核心思想:利用第三方共享资源(如本例中的"桌子")的状态,来控制生产者和消费者线程的交替执行。


1.17.1.1.1 理想情况

厨师(生产者)和吃货(消费者)的执行节奏完美契合:

  • 厨师刚做好一碗面放到桌子上,吃货立刻拿走吃掉;
  • 桌子一空,厨师马上再做一碗。两者自然交替,无需触发任何等待。


1.17.1.1.2 消费者等待

当消费者(吃货)抢占 CPU 速度过快时:

  • 判断与等待 :吃货抢到执行权,发现桌子上没有食物 ,只能调用 wait() 强制进入等待状态。
  • 生产与唤醒 :厨师随后抢到执行权,制作食物并放到桌子上,接着调用 notify() 唤醒正在等待的吃货起来吃面。

1.17.1.1.3 生产者等待

当生产者(厨师)抢占 CPU 速度过快时:

  • 判断与等待 :厨师抢到执行权,发现桌子上已经有食物 (吃货还没吃),为了防止食物溢出,厨师调用 wait() 进入等待状态。
  • 消费与唤醒 :吃货抢到执行权,吃光桌子上的食物后,调用 notify() 唤醒正在等待的厨师继续做饭。

1.17.2 等待和唤醒方法

方法名 说明
void wait() 使当前线程等待,直到其他线程调用该对象的 notify()notifyAll()
void notify() 唤醒正在等待对象监视器的单个线程
void notifyAll() 唤醒正在等待对象监视器的所有线程

注意wait()notify()notifyAll() 必须由锁对象调用,并且必须写在同步代码块或同步方法中。否则会抛出 IllegalMonitorStateException


1.18 生产者消费者案例:等待唤醒机制

1.18.1 案例需求

该案例通过厨师吃货模拟生产者消费者模型:

  • 桌子上没有食物时,厨师生产食物,吃货等待。
  • 桌子上有食物时,吃货消费食物,厨师等待。
  • 食物总数达到指定次数后,程序结束。

1.18.2 代码示例


1.18.2.1 Desk
java 复制代码
package com.example.a05threadcase1;

public class Desk {

    /*
     * 作用:控制生产者(厨师)和消费者(吃货)的执行
     */

    // 共享变量 1:桌子的状态(是否有面条)
    // 0: 没有面条(需要厨师做,吃货等待)
    // 1: 有面条(需要吃货吃,厨师等待)
    public static int foodFlag = 0;

    // 共享变量 2:总共要吃/做的碗数
    // 控制整个程序的结束条件
    public static int count = 10;

    // 共享变量 3:唯一的锁对象
    // 厨师和吃货必须争夺同一把锁,保证桌子状态的修改是绝对安全的
    public static Object lock = new Object();
}

1.18.2.2 Cook
java 复制代码
package com.example.a05threadcase1;

public class Cook extends Thread {

    @Override
    public void run() {
        /*
         * 1. 循环
         * 2. 同步代码块(获取桌子的锁)
         * 3. 判断共享数据是否到了末尾(到了末尾,结束循环)
         * 4. 判断共享数据是否到了末尾(没有到末尾,执行核心逻辑)
         */
        while (true) {
            // 2. 厨师去抢桌子的锁
            synchronized (Desk.lock) {
                // 3. 检查总数,如果 10 碗都吃完了,厨师下班
                if (Desk.count == 0) {
                    break;
                } else {
                    // 4. 核心业务逻辑
                    // 先判断桌子上是否有食物(foodFlag == 1 表示有)
                    if (Desk.foodFlag == 1) {
                        // 【等待机制】:如果桌子上已经有面条了,厨师就不做了,进入等待状态
                        try {
                            // 调用 wait() 会立刻释放手里的 Desk.lock 锁!
                            // 并在原地睡着,直到被别人(吃货)唤醒。
                            Desk.lock.wait(); 
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    } else {
                        // 如果桌子上没有面条(foodFlag == 0),厨师就开始做
                        System.out.println("厨师做了一碗面条");
                        
                        // 面条做好了,修改桌子的状态为"有面条"
                        Desk.foodFlag = 1;
                        
                        // 【唤醒机制】:面条做好了,赶紧喊醒正在等待的吃货起来吃!
                        // notifyAll() 会唤醒绑定在这个锁上的所有等待线程
                        Desk.lock.notifyAll();
                    }
                }
            }
        }
    }
}
1.18.2.3 Foodie
java 复制代码
package com.example.a05threadcase1;

public class Foodie extends Thread {

    @Override
    public void run() {
        while (true) {
            // 2. 吃货去抢桌子的锁
            synchronized (Desk.lock) {
                // 3. 检查总数,如果 10 碗都吃完了,吃货吃饱溜了
                if (Desk.count == 0) {
                    break;
                } else {
                    // 4. 核心业务逻辑
                    // 先判断桌子上是否有面条(foodFlag == 0 表示没有)
                    if (Desk.foodFlag == 0) {
                        // 【等待机制】:如果没有面条,吃货只能流着口水等待
                        try {
                            // 让当前线程(吃货)跟锁进行绑定并进入等待状态,同时释放锁!
                            Desk.lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    } else {
                        // 如果有面条,吃货开吃!
                        
                        // 把要吃的总数减 1
                        Desk.count--;
                        System.out.println("吃货在吃面条,还能再吃" + Desk.count + "碗!!!");
                        
                        // 吃完之后,立马唤醒正在等待的厨师继续做
                        Desk.lock.notifyAll();
                        
                        // 修改桌子的状态,表示桌子现在空了(没面条了)
                        Desk.foodFlag = 0;
                    }
                }
            }
        }
    }
}

1.18.2.4 ThreadDemo
java 复制代码
package com.example.a05threadcase1;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
         * 需求:完成生产者和消费者(等待唤醒机制)的代码
         * 实现线程轮流交替执行的效果
         */

        // 1. 创建线程对象
        Cook c = new Cook();
        Foodie f = new Foodie();

        // 2. 给线程设置名字
        c.setName("厨师");
        f.setName("吃货");

        // 3. 开启线程
        // 两个线程启动后,会疯狂争抢 Desk.lock 这把锁。
        // 但由于 wait() 和 notifyAll() 的配合,它们会完美地实现:做一碗,吃一碗,交替进行。
        c.start();
        f.start();
    }
}

运行结果如下:


1.18.3 核心逻辑解析

  • 锁对象统一

    厨师和吃货必须围绕同一个 Desk.lock 加锁,否则等待唤醒机制无法正确协作。

  • 状态标记控制执行权
    foodFlag 表示桌子上是否有食物。生产者和消费者根据该标记决定执行、等待或唤醒对方。

  • 等待会释放锁

    调用 wait() 后,当前线程会进入等待状态,并释放手中的锁,让其他线程有机会进入同步代码块。

总结 :等待唤醒机制解决的不是"数据安全"一个问题,而是"线程之间如何有序协作"的问题。


1.19 阻塞队列

1.19.1 核心概念

阻塞队列是 Java 并发包中用于线程协作的重要工具。它可以在队列满时阻塞生产者,在队列空时阻塞消费者,从而自动完成等待和唤醒。

常见阻塞队列如下:

阻塞队列 底层结构 特点
ArrayBlockingQueue 数组 有界队列
LinkedBlockingQueue 链表 通常视为无界队列,但最大容量仍受 int 最大值限制

核心方法如下:

方法名 说明
put(E e) 将元素放入队列;如果队列已满,则阻塞等待
take() 取出队列头部元素;如果队列为空,则阻塞等待

1.19.2 阻塞队列实现生产者消费者



1.19.2.1 Cook
java 复制代码
package com.example.a05threadcase2;

import java.util.concurrent.ArrayBlockingQueue;

public class Cook extends Thread {

    // 声明一个阻塞队列的成员变量
    ArrayBlockingQueue<String> queue;

    // 通过有参构造,接收主线程传过来的那个唯一的 queue
    public Cook(ArrayBlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
    //此处为了突出阻塞队列特性,采用了死循环,实际开发中需加入退出条件
        while (true) {
            // 不断的把面条放到阻塞队列当中
            try {
                /*
                 * ==============================================================================
                 * 【核心原理解析:put() 方法的阻塞机制】
                 * 1. 厨师调用 queue.put("面条") 往队列里放食物。
                 * 2. 如果此时队列没满(比如容量是1,当前是空的),面条顺利放入。
                 * 3. 【重点】:如果此时队列已经满了(里面已经有1碗面条了),
                 * put() 方法会在底层自动把当前线程(厨师)给【阻塞】住!
                 * 厨师只能在原地干等,直到吃货把面条拿走,队列有了空位,厨师才会被自动唤醒继续放。
                 * ==============================================================================
                 * 
                 * 避坑指南:为什么控制台会连续打印两次?】
                 * 原因:put(放面条) 和 println(打印) 是两步独立操作。
                 * 现象:厨师刚放完面条,还没来得及打印,CPU 就切给吃货了。吃货光速吃完。
                 * 厨师下次醒来补发一句打印,加上他新做的一碗的打印,就出现了连续输出。
                 * 结论:只要用了阻塞队列,底层数据就是绝对安全的。别为了控制台好看去加 synchronized 大锁,那会拖垮性能!
                 */
                queue.put("面条");
                System.out.println("厨师放了一碗面条");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

1.19.2.2 Foodie
java 复制代码
package com.example.a05threadcase2;

import java.util.concurrent.ArrayBlockingQueue;

public class Foodie extends Thread {

    // 声明一个阻塞队列的成员变量
    ArrayBlockingQueue<String> queue;

    // 通过有参构造,接收主线程传过来的那个唯一的 queue
    public Foodie(ArrayBlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        while (true) {
            // 不断从阻塞队列中获取面条
            try {
                /*
                 * ==============================================================================
                 * 【核心原理解析:take() 方法的阻塞机制】
                 * 1. 吃货调用 queue.take() 从队列里拿食物。
                 * 2. 如果此时队列里有面条,顺利拿走并返回。
                 * 3. 【重点】:如果此时队列是空的(厨师还没做),
                 * take() 方法会在底层自动把当前线程(吃货)给【阻塞】住!
                 * 吃货只能流着口水死等,直到厨师做好了面条放进队列,吃货才会被自动唤醒拿走面条。
                 * ==============================================================================
                 * 【避坑指南:交错打印是正常的】
                 * 同理,take(拿面条) 和 println(打印) 也可能被中途打断。
                 * 不要被控制台的错乱输出骗了,ArrayBlockingQueue 底层已经处理好了一切,
                 * 保证了同一时刻桌子上最多只有 1 碗面。
                 */
                String food = queue.take();
                System.out.println(food);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

1.19.2.3 ThreadDemo
java 复制代码
package com.example.a05threadcase2;

import java.util.concurrent.ArrayBlockingQueue;

public class ThreadDemo {
    public static void main(String[] args) {
        /*
         * 需求:利用阻塞队列完成生产者和消费者(等待唤醒机制)的代码
         * 细节:
         * 生产者和消费者必须使用同一个阻塞队列
         */

        // 1. 创建阻塞队列的对象
        // 参数 capacity: 1 表示这个队列的容量只有 1。
        // 也就是说,桌子上最多只能放 1 碗面条。
        ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(1);

        // 2. 创建线程的对象,并把阻塞队列通过构造方法传递过去
        // 这样 Cook 和 Foodie 内部使用的就是刚才创建的同一个 queue
        Cook c = new Cook(queue);
        Foodie f = new Foodie(queue);

        // 3. 开启线程
        c.start();
        f.start();
    }
}

运行结果如下:

最佳实践提示:在实际开发中,如果只是实现典型的生产者消费者模型,优先考虑使用阻塞队列。它把底层加锁、等待、唤醒逻辑封装起来,代码更简洁,也更不容易出错。


1.20 线程的状态与生命周期(完整版)

Java 线程在整个生命周期中会经历多种状态的复杂流转。我们可以从执行逻辑流转Java 原生状态枚举两个维度来深入理解。

1.20.1 线程执行逻辑流转图

根据底层执行机制,线程的流转核心在于执行资格执行权的获取:


1.20.2 Java Thread.State 枚举状态

在 Java 源码层面(Thread.State),并没有独立区分"就绪 "和"运行",而是统一归纳为 6 种标准状态:

状态枚举 说明 触发条件
NEW 新建状态 刚创建线程对象,尚未启动
RUNNABLE 就绪状态 调用了 start 方法(包含了逻辑上的抢 CPU 和正在运行阶段)
BLOCKED 阻塞状态 处于无法获得锁对象的状态
WAITING 等待状态 调用了无参的 wait 方法,无限期等待唤醒
TIMED_WAITING 计时等待状态 调用了带有时间参数的方法(如 sleep 方法),到达时间后自动唤醒
TERMINATED 结束状态 全部代码运行完毕,线程终止

1.21 小结

知识点 核心结论
线程创建 可通过继承 Thread、实现 Runnable、实现 Callable 三种方式创建线程
线程启动 必须调用 start() 才是真正开启新线程,直接调用 run() 只是普通方法调用
线程调度 Java 线程调度具有随机性,优先级只影响概率,不保证顺序
线程安全 多线程共享数据并进行多语句操作时,必须考虑同步控制
同步代码块 锁对象必须唯一,同步范围应尽量精确
同步方法 非静态同步方法锁是 this,静态同步方法锁是 类名.class
Lock 锁 显式加锁和解锁,释放锁应写在 finally
死锁 多线程嵌套获取多把锁且顺序不一致时容易发生
生产者消费者 通过共享区域、等待唤醒或阻塞队列实现线程协作
相关推荐
吴声子夜歌1 小时前
Java——通用容器类
java·容器
段ヤシ.1 小时前
回顾Java知识点,面试题汇总Day7(持续更新)
java·开发语言
云烟成雨TD2 小时前
Spring AI Alibaba 1.x 系列【53】Interrupts 中断机制:动态中断
java·人工智能·spring
用户298698530142 小时前
Java 操作 Word 文档:数学公式与符号的插入方法
java·后端
见青..2 小时前
JAVA安全靶场环境搭建
java·web安全·靶场·java安全
一坨阿亮2 小时前
Docker 离线部署
java·spring cloud·docker
LucaJu2 小时前
一次 OOM 线上排查实录
java·jvm·oom·内存溢出
SimonKing2 小时前
Firefox 太卡?换了这浏览器,内存占用直接降了 70%
java·后端·程序员
咖啡八杯2 小时前
GoF设计模式——建造者模式
java·后端