当前位置: 首页 > news >正文

Java多线程初阶-线程协作与实战案例

文章目录

  • Java多线程初阶:从线程协作到实战设计模式
    • 1. `wait` 与 `notify`:线程间的协作与通信
      • 1.1 `wait()`:让出 CPU,并释放锁
      • 1.2 `notify()`:唤醒一个等待的线程
      • 1.3 `notifyAll()`:唤醒所有等待的线程
      • 1.4 `wait` 与 `sleep` 的核心区别(高频面试题)
    • 2. 多线程实战:经典设计模式解析
      • 2.1 单例模式:确保对象的唯一性
        • 饿汉模式:简单直接的线程安全
        • 懒汉模式:延迟加载与线程安全挑战
      • 2.2 阻塞队列与生产者-消费者模型
        • 什么是阻塞队列?
        • 生产者-消费者模型
        • 标准库中的阻塞队列
        • 手动实现一个阻塞队列
      • 2.3 线程池
        • 线程池是什么
        • 标准库中的线程池
        • 实现一个简易线程池
      • 2.4 定时器
        • 定时器是什么
        • 标准库中的定时器
        • 实现一个简易定时器
    • 3. 总结

Java多线程初阶:从线程协作到实战设计模式

大家好!在前面的文章中,我们一起探索了 Java 多线程的基础概念,并深入了解了确保线程安全的核心要素。我们知道了如何创建线程,也理解了 synchronizedvolatile 如何为我们的并发程序保驾护航。

然而,多线程的世界远不止于此。除了避免线程间的“冲突”,我们更需要让它们能够高效地“协作”,共同完成复杂的任务。本文将带你进入多线程的进阶领域,我们将学习:

  • 线程间的优雅通信:如何使用 wait()notify() 机制,实现精准的线程间协作。
  • 经典设计模式实战:深入剖析单例模式、生产者-消费者模型、线程池和定时器在并发场景下的实现原理与最佳实践。

1. waitnotify:线程间的协作与通信

在并发编程中,我们不仅要处理线程间的竞争关系,还需要让它们能够相互协作,按照预定的顺序完成任务。这就好比一场篮球比赛,传球和投篮是两个不同的动作,必须由不同的球员(线程)协同完成,而且必须先传球,后投篮。

在这里插入图片描述

为了实现这种精密的线程协作,Java 的 Object 类提供了三个核心方法:wait()notify()notifyAll()

核心要点:这三个方法都定义在 Object 类中,意味着任何 Java 对象都可以作为通信的“信物”。它们必须在 synchronized 同步块中调用,否则会抛出 IllegalMonitorStateException 异常。

1.1 wait():让出 CPU,并释放锁

当一个线程获取到锁,但发现继续执行的条件尚不满足时(例如,消费者发现队列是空的),它就可以调用 wait() 方法,主动进入等待状态。

wait() 方法会执行三个关键操作:

  1. 让当前线程进入等待(WAITING) 状态,并被放入该对象的等待队列中。
  2. 立即释放当前持有的 synchronized 锁。这一点至关重要,因为它给了其他线程获取锁并改变条件的机会。
  3. 当被唤醒后,该线程会重新尝试获取锁。只有成功获取锁之后,wait() 方法才会返回,线程继续执行。

wait() 结束等待的条件通常有三个:

  • 其他线程调用了同一个对象的 notify()notifyAll() 方法。
  • 调用了 wait(long timeout),且等待时间超时。
  • 其他线程调用了该等待线程的 interrupt() 方法。
public class WaitDemo {public static void main(String[] args) throws InterruptedException {Object locker = new Object();// 必须先获取锁,才能调用 waitsynchronized (locker) {System.out.println("即将进入等待状态...");// 调用 wait() 后,main 线程会释放 locker 锁并在此处阻塞locker.wait(); System.out.println("等待结束。");}}
}

深度思考:为什么 wait 必须在 synchronized 中?

这是为了避免一种经典的竞态条件——“丢失的唤醒(Lost Wake-up)”。想象一下,如果 wait 不在同步块里:

  1. 线程 A 判断条件不满足,准备调用 wait()
  2. 就在此时,线程切换,线程 B 开始执行。
  3. 线程 B 修改了条件,并调用了 notify()。但此时线程 A 还没来得及 wait,所以这个 notify 信号就丢失了。
  4. 线程切换回 A,它执行 wait(),但可能再也没有其他线程来唤醒它了,从而造成永久等待。

synchronized 确保了“检查条件”和“进入等待”这两个操作成为一个原子操作,杜绝了“丢失的唤醒”问题。

1.2 notify():唤醒一个等待的线程

当一个线程修改了某个条件,使得其他正在等待的线程可以继续执行时,它就可以调用 notify() 方法来唤醒一个正在该对象上等待的线程。

notify() 的工作机制:

  • 它会从该对象的等待队列中,随机选择一个线程进行唤醒。
  • 被唤醒的线程并不会立即执行,而是进入就绪(Ready) 状态,重新参与到锁的竞争中。
  • 调用 notify() 的线程在执行完同步代码块、释放锁之后,被唤醒的线程才有机会获取锁并继续执行。

代码示例:
我们来创建两个线程,t1 负责等待,t2 负责唤醒。它们通过共享的 locker 对象进行通信。

public class WaitNotifyDemo {public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(() -> {synchronized (locker) {try {System.out.println("t1: 开始等待...");locker.wait(); // 释放锁并等待System.out.println("t1: 被唤醒,等待结束。");} catch (InterruptedException e) {e.printStackTrace();}}}, "t1");Thread t2 = new Thread(() -> {// 确保 t1 先进入等待状态try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }synchronized (locker) {System.out.println("t2: 准备唤醒一个线程...");locker.notify();System.out.println("t2: notify() 已调用,但锁还未释放。");// t2 会继续执行完同步块中的所有代码} // t2 在这里释放锁System.out.println("t2: 同步块执行完毕,已释放锁。");}, "t2");t1.start();t2.start();}
}

执行顺序分析

  1. t1 启动,获取 locker 锁,打印 “开始等待…”,然后调用 wait(),释放锁并进入等待。
  2. t2 启动,获取 locker 锁,打印 “准备唤醒…”,然后调用 notify()
  3. t1 被唤醒,但由于 t2 仍持有锁,t1 无法立即执行,只能等待锁被释放。
  4. t2 继续执行,打印 “notify() 已调用…”,直到同步块结束,释放锁。
  5. t1 终于获取到锁,从 wait() 方法返回,打印 “等待结束。”

关于 waitnotify 的一个重要实践:

wait 通常应该在 while 循环中被调用,而不是 if 语句中。这被称为“伪唤醒(spurious wakeup)”问题。有时,线程可能在没有被 notify 的情况下被唤醒。使用 while 循环可以确保线程在被唤醒后,再次检查执行条件是否真的满足,如果不满足,则继续等待。

synchronized (locker) {while (conditionIsNotMet) {locker.wait();}// do something
}

1.3 notifyAll():唤醒所有等待的线程

notify() 每次只唤醒一个随机的线程,这在某些场景下可能不够用。如果希望唤醒所有正在该对象上等待的线程,让他们都参与到新一轮的锁竞争中,就可以使用 notifyAll()

代码示例:
我们创建 3 个等待线程和 1 个唤醒线程。

class WaitTask implements Runnable {private final Object locker;public WaitTask(Object locker) { this.locker = locker; }@Overridepublic void run() {synchronized (locker) {try {System.out.println(Thread.currentThread().getName() + ": 开始等待...");locker.wait();System.out.println(Thread.currentThread().getName() + ": 等待结束。");} catch (InterruptedException e) {e.printStackTrace();}}}
}public class NotifyAllDemo {public static void main(String[] args) throws InterruptedException {Object locker = new Object();// 创建三个等待线程new Thread(new WaitTask(locker), "Wait-1").start();new Thread(new WaitTask(locker), "Wait-2").start();new Thread(new WaitTask(locker), "Wait-3").start();Thread.sleep(1000); // 确保所有等待线程都已启动new Thread(() -> {synchronized (locker) {System.out.println("Notify-Thread: 准备唤醒所有线程...");locker.notifyAll(); // 唤醒所有在 locker 上等待的线程System.out.println("Notify-Thread: notifyAll() 已调用。");}}).start();}
}

执行后会看到,notifyAll() 调用后,所有三个等待线程都会被唤醒,并依次(因为需要竞争锁)打印出 “等待结束” 的信息。

1.4 waitsleep 的核心区别(高频面试题)

尽管 waitsleep 都能让线程暂停执行,但它们的设计目的和行为有着本质的区别,是面试中的高频考点。

对比维度wait()sleep()
来源Object 类的方法,任何对象都能调用。Thread 类的静态方法。
核心用途用于线程间的通信与协作用于让当前线程暂停执行一段时间。
锁的释放会释放当前持有的 synchronized 锁。不会释放任何锁。
使用场景必须在 synchronized 同步块或方法中使用。可以在任何地方使用。
唤醒方式需要被其他线程通过 notify/notifyAll 唤醒,或等待超时,或被中断。休眠时间到达后自动唤醒,或被中断。

简单来说,wait 是“交出钥匙去休息室等人”,而 sleep 是“拿着钥匙在办公室打个盹”。

小试牛刀:
有三个线程,分别负责打印 A、B、C。请设计一个程序,让它们能够严格按照 ABCABC… 的顺序循环打印 10 次。

public class DemoTest2 {public static void main(String[] args) throws InterruptedException {Object lockerA = new Object();Object lockerB = new Object();Object lockerC = new Object();Thread t1 = new Thread(() -> {try {for (int i = 0; i < 10; i++) {synchronized (lockerA) {lockerA.wait(); // 等待轮到自己}System.out.print("A");synchronized (lockerB) {lockerB.notify(); // 通知下一个}}}catch (InterruptedException e) {throw new RuntimeException(e);}});Thread t2 = new Thread(() -> {try {for (int i = 0; i < 10; i++) {synchronized (lockerB) {lockerB.wait();}System.out.print("B");synchronized (lockerC) {lockerC.notify();}}}catch (InterruptedException e) {throw new RuntimeException(e);}});Thread t3 = new Thread(() -> {try {for (int i = 0; i < 10; i++) {synchronized (lockerC) {lockerC.wait();}System.out.print("C");// 换行,方便观察System.out.println(); synchronized (lockerA) {lockerA.notify();}}}catch (InterruptedException e) {throw new RuntimeException(e);}});t1.start();t2.start();t3.start();// 确保三个线程都已启动并进入等待状态Thread.sleep(1000);// 由主线程发出第一个信号,启动循环synchronized (lockerA) {lockerA.notify();}}
}

2. 多线程实战:经典设计模式解析

掌握了线程间的协作机制后,我们就可以来探索一些更高级、更结构化的并发解决方案——设计模式。

什么是设计模式?

我们可以把设计模式想象成象棋中的“棋谱”。比如,面对“当头炮”,就有“马来跳”这样的成熟应对策略。遵循这些经过千锤百炼的套路,就能在开局时占据有利位置。

同样,在软件开发中,我们也面临着许多反复出现的“典型问题场景”。前人早已针对这些场景,总结出了一系列高效、可靠的解决方案,这就是“设计模式”。遵循这些模式,就像是站在巨人的肩膀上,能写出更健壮、更易于维护的代码。

2.1 单例模式:确保对象的唯一性

单例模式(Singleton Pattern)是在面试和实际开发中都经常遇到的一个基础设计模式。它的核心目标非常明确:确保一个类在整个程序运行期间,只有一个实例(对象)存在

这在许多场景下都是必要的。例如,一个应用程序通常只需要一个数据库连接池(DataSource)实例,或者一个全局的配置管理器。创建多个实例不仅没有意义,还可能引发数据不一致等问题。

思路分析:像数据库连接池这类管理全局资源的对象,资源本身(数据库)是唯一的。创建多个实例去管理同一个资源,不仅浪费内存,还可能因为状态不同步而导致混乱。因此,强制其唯一性是合理的设计。

单例模式的实现方式主要分为两大流派:“饿汉模式”和“懒汉模式”。

饿汉模式:简单直接的线程安全

饿汉模式的特点是“迫不及待”,它在类被加载到内存时,就立刻创建好唯一的实例。

// 饿汉模式实现
class Singleton {// 1. 类加载时就直接创建实例,并用 static 成员变量持有它private static Singleton instance = new Singleton();// 2. 提供一个 public static 方法,作为获取该唯一实例的全局入口public static Singleton getInstance() {return instance;}// 3. 将构造方法私有化,杜绝从外部直接 new 一个新实例的可能性private Singleton() {}
}

这种方式的实现非常简单,并且是天然线程安全的。因为实例的创建是在类加载阶段完成的,这个过程由 JVM 保证其原子性和线程安全性。无论多少个线程在何时调用 getInstance(),它们获取到的永远是同一个、早已创建好的实例。

懒汉模式:延迟加载与线程安全挑战

与饿汉模式相反,懒汉模式奉行“非必要,不创建”的原则。它只在第一次被使用时,才真正创建实例。

  • 饿汉:尽早创建,不管后续用不用。
  • 懒汉:尽量推迟创建,直到第一次需要时才动手。如果一次都没用过,就一次也不创建。

1. 懒汉模式 - 单线程版 (线程不安全)

// 懒汉模式 - 线程不安全的版本
class SingletonLazy {private static SingletonLazy instance = null;public static SingletonLazy getInstance() {// 只有在 instance 为 null 时,才进行创建if (instance == null) {instance = new SingletonLazy();}return instance;}private SingletonLazy() {}
}

在单线程环境下,这段代码工作得很好。但在多线程环境下,它却隐藏着风险。

思路分析:懒汉模式为何线程不安全?

问题的根源在于 if (instance == null)instance = new SingletonLazy() 这两步操作并非原子性的。

想象一下这个场景:

  1. 线程 A 调用 getInstance(),判断 instancenull,进入 if 代码块。
  2. 在线程 A 即将执行 new 操作之前,发生线程切换,线程 B 开始执行。
  3. 线程 B 调用 getInstance(),此时 instance 仍然是 null,所以它也通过了 if 判断。
  4. 线程 B 创建了一个实例,并将其赋给 instance
  5. 线程切换回 A,它并不知道 instance 已经被创建,于是继续执行,又创建了一个新的实例,覆盖了线程 B 刚刚创建的那个。

这样一来,构造方法就被调用了两次,程序中出现了多个实例,违背了单例模式的初衷。

2. 懒汉模式 - 加锁版 (线程安全但低效)

为了解决上述问题,最直接的办法就是加锁,确保“检查并创建”这个过程的原子性。

// 懒汉模式 - 简单的线程安全版本
class SingletonLazy {private static SingletonLazy instance = null;private SingletonLazy() {}// 对整个方法加锁,确保同一时间只有一个线程能进入public synchronized static SingletonLazy getInstance() {if (instance == null) {instance = new SingletonLazy();}return instance;}
}

加上 synchronized 后,确实解决了线程安全问题。但新的问题随之而来:性能

思路分析:无差别加锁的性能瓶颈

  1. 首次创建:加锁是必要的。当多个线程同时尝试创建实例时,synchronized 会迫使它们排队,只有一个线程能成功创建。
  2. 后续访问:一旦实例被创建,后续所有对 getInstance() 的调用,都只是一个纯粹的“读”操作,并不涉及线程安全问题。

然而,由于 synchronized 修饰了整个方法,导致每一次调用都会触发锁竞争,严重影响程序性能。我们的目标应该是“按需加锁”。

3. 懒汉模式 - 双重检查锁定 (推荐)

为了兼顾线程安全与性能,我们引入了“双重检查锁定(Double-Checked Locking)”这种优化方案。这也是面试中考察单例模式时,最希望看到的答案。

// 懒汉模式 - 双重检查锁定版本
class SingletonLazy {// 关键点1: 使用 volatile 关键字确保可见性和禁止指令重排序private static volatile SingletonLazy instance = null;private SingletonLazy() {}public static SingletonLazy getInstance() {// 关键点2: 第一次检查,避免不必要的加锁if (instance == null) {// 关键点3: 只在实例未创建时才进入同步块synchronized (SingletonLazy.class) {// 关键点4: 第二次检查,防止多个线程重复创建if (instance == null) {instance = new SingletonLazy();}}}return instance;}
}

这个实现看起来有些复杂,让我们来拆解其中的精妙之处。

  • 双重 if 检查

    • 外层 if:这是性能优化的关键。绝大多数情况下,当实例已经创建后,线程访问 getInstance() 会在这里直接返回,完全避免了进入 synchronized 同步块。
    • 内层 if:这是保证线程安全的关键。假设有多个线程同时通过了外层 if 的检查,它们会排队等待进入同步块。第一个进入的线程会创建实例。当它退出后,后续进入的线程会通过内层 if 发现实例已经被创建,从而不会重复创建。
  • volatile 关键字
    volatile 在这里起到了两个至关重要的作用:

    1. 保证内存可见性:确保一个线程对 instance 的修改(赋值),能立刻被其他线程看到。

    2. 禁止指令重排序:这是更深层次、也更关键的原因。instance = new SingletonLazy() 这行代码在底层并非原子操作,大致可以分为三步:
      a. 为 SingletonLazy 对象分配内存空间。
      b. 调用构造方法,初始化对象。
      c. 将 instance 引用指向分配好的内存地址。

      在没有 volatile 的情况下,编译器或 CPU 可能会进行指令重排序,将执行顺序优化为 a -> c -> b。如果发生这种情况,一个线程可能在 b(初始化)完成之前,就拿到了一个不为 null 但尚未完全构造好的 instance 对象。volatile 能够杜绝这种重排序,保证对象一定是在完全初始化之后,才被赋值给 instance 引用。

2.2 阻塞队列与生产者-消费者模型

在多线程编程中,阻塞队列(Blocking Queue)是一个非常核心且实用的工具,它常常与“生产者-消费者模型”一同出现,用于解决线程间的协作问题。

什么是阻塞队列?

我们可以将阻塞队列理解为一个自带“流量控制”功能的特殊队列。它首先是一个队列,通常遵循“先进先出”(FIFO)的原则。其次,它是一个线程安全的数据结构,内部已经处理好了并发访问的同步问题。

其最核心的特性体现在“阻塞”二字上:

  • 当队列已满时:如果一个线程尝试向队列中添加(put)新元素,该线程会被阻塞,直到队列中有空间被释放。
  • 当队列为空时:如果一个线程尝试从队列中获取(take)元素,该线程同样会被阻塞,直到队列中有新的元素被加入。

这种“自动挡”的阻塞机制,使得阻塞队列成为构建高效、稳定的多线程应用的利器。

生产者-消费者模型

这是一种非常经典的并发设计模式,它通过一个共享的容器(通常就是阻塞队列)来协调两类线程的工作:

  • 生产者(Producer):负责创建数据或任务,并将其放入队列。
  • 消费者(Consumer):负责从队列中取出数据或任务,并进行处理。

在这里插入图片描述

在这个模型中,生产者和消费者之间不直接通信,而是通过阻塞队列这个“中转站”进行交互。这种设计带来了两大核心优势:

1. 解耦合(Decoupling)

生产者和消费者被完全分离开来,各自专注于自己的任务,互不干扰。可以用过年时全家一起包饺子的场景来类比:负责擀饺子皮的人是“生产者”,负责包饺子的人是“消费者”,案板就是“阻塞队列”。他们通过案板协作,互不干扰。

2. 平衡生产与消费能力(“削峰填谷”)

阻塞队列就像一个缓冲区,能够有效地平衡生产者和消费者之间处理速度不匹配的问题,尤其是在应对突发流量时。

一个类比:系统架构中的“削峰填谷”

想象一个典型的Web应用架构,上游服务器A接收用户请求,然后转发给下游服务器B进行处理。

在这里插入图片描述

在“秒杀”活动中,服务器A可能会在短时间内收到海量请求,如果全部直接转发给B,B很可能会崩溃。这时,可以在A和B之间引入一个消息队列(其核心就是阻塞队列)。

  • 削峰:流量洪峰到来时,A将请求快速写入队列后即可响应用户,将瞬时的高并发压力平摊开来。
  • 填谷:B可以按照自己的节奏,从队列中平稳地取出请求进行处理,充分利用系统资源。
标准库中的阻塞队列

Java标准库 java.util.concurrent 包中已经为我们提供了成熟的阻塞队列实现,如 LinkedBlockingQueue

在这里插入图片描述

BlockingQueue 的核心方法是:

  • put(E e): 阻塞式地将元素 e 放入队列。
  • take(): 阻塞式地从队列中取出并返回一个元素。

生产者-消费者模型示例:

import java.util.Random;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;public class ProducerConsumerDemo {public static void main(String[] args) throws InterruptedException {// 创建一个容量为 10 的阻塞队列BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(10);// 消费者线程Thread consumer = new Thread(() -> {while (true) {try {int value = queue.take(); // 如果队列为空,此处将阻塞System.out.println("消费元素: " + value);Thread.sleep(500); // 模拟耗时的消费过程} catch (InterruptedException e) {e.printStackTrace();}}}, "消费者");// 生产者线程Thread producer = new Thread(() -> {Random random = new Random();while (true) {try {int num = random.nextInt(1000);System.out.println("生产元素: " + num);queue.put(num); // 如果队列已满,此处将阻塞Thread.sleep(200); // 生产速度快于消费速度} catch (InterruptedException e) {e.printStackTrace();}}}, "生产者");producer.start();consumer.start();}
}
手动实现一个阻塞队列

为了更深刻地理解其工作原理,我们可以尝试自己实现一个简易版的阻塞队列。

核心思路:

  1. 使用一个数组作为底层存储,实现循环队列。
  2. 使用 synchronized 保证 puttake 方法的原子性。
  3. 当队列满时,调用 this.wait() 使 put 线程阻塞。
  4. 当队列空时,调用 this.wait() 使 take 线程阻塞。
  5. put 成功后,调用 this.notify() 唤醒 take 线程。
  6. take 成功后,调用 this.notify() 唤醒 put 线程。
// 一个基于数组实现的简易阻塞队列(仅用于学习理解)
class MyBlockingQueue {private final String[] items;private int head = 0;private int tail = 0;private int size = 0;public MyBlockingQueue(int capacity) {this.items = new String[capacity];}// 入队操作public synchronized void put(String elem) throws InterruptedException {// 使用 while 循环防止“伪唤醒”while (size >= items.length) {this.wait(); // 队列已满,阻塞等待}items[tail] = elem;tail = (tail + 1) % items.length;size++;this.notify(); // 唤醒一个可能在等待的 take 线程}// 出队操作public synchronized String take() throws InterruptedException {// 使用 while 循环防止“伪唤醒”while (size == 0) {this.wait(); // 队列为空,阻塞等待}String result = items[head];head = (head + 1) % items.length;size--;this.notify(); // 唤醒一个可能在等待的 put 线程return result;}
}

一个关键细节:为何必须用 while 循环调用 wait()

这是实现线程安全阻塞队列时一个至关重要的细节。我们可能会想,用 if (size == 0) 来判断不就可以了吗?答案是不行的,这存在着“伪唤醒(Spurious Wakeup)”的风险。

  1. 什么是伪唤醒? Java官方文档中明确指出,线程有时可能会在没有被 notify() 的情况下“醒来”。
    在这里插入图片描述

  2. if 的隐患:如果使用 if,当一个线程被伪唤醒时,它会直接跳过 if 判断,继续执行后续代码,但此时它等待的条件可能并未满足,从而导致程序出错。

  3. while 的健壮性:使用 while 循环,线程在被唤醒后,会再次检查循环条件。如果条件仍然不满足,它会继续调用 wait(),重新进入等待状态。

因此,在 synchronized 块中使用 wait() 的标准范式永远是 while(condition) { wait(); }

2.3 线程池

线程池是什么

想象一下,一家新开的快递店,每次有业务,老板就临时招聘一个同学去送,送完就解雇。很快老板发现,每次招聘和解雇的成本太高了。这就是我们每次来一个任务就创建一个新线程的模式。

于是老板改变策略:固定雇佣3名快递员。有业务来了,如果快递员没满3个,就再招一个;如果满了,就把业务记在本子上,等快递员有空了再处理。这就是线程池的模式。

线程池最大的好处就是减少每次启动、销毁线程的损耗。

我们可以把线程提前创建好,放到一个“池子”里,需要用的时候随时去取,用完了再放回池子中。这个过程完全在应用程序内部(用户态)完成,比向操作系统申请创建新线程(需要进入内核态)要高效得多。

标准库中的线程池

Java 标准库通过 Executors 类提供了一些快捷创建线程池的方法。

  • Executors.newFixedThreadPool(10): 创建一个固定包含 10 个线程的线程池。
  • ExecutorService.submit(Runnable task): 将一个任务提交到线程池中执行。
public static void main(String[] args) {ExecutorService threadPool = Executors.newCachedThreadPool();for (int i = 0; i < 1000; i++) {int id = i;threadPool.submit(() -> {System.out.println("hello " + id + ", " + Thread.currentThread().getName());});}
}

在阿里巴巴的Java编程规范手册中,推荐使用 ThreadPoolExecutor 的构造方法来创建线程池,而不是直接使用 Executors 的静态方法,因为后者可能隐藏一些配置,导致资源耗尽的风险。

ThreadPoolExecutor 的构造方法参数是经典的面试题:

在这里插入图片描述

  1. corePoolSize 核心线程数:线程池中始终保持存活的线程数量。
  2. maximumPoolSize 最大线程数:线程池允许创建的线程总数上限。
  3. keepAliveTime:临时线程(非核心线程)允许的最大空闲时间。
  4. unitkeepAliveTime 的时间单位。
  5. workQueue工作队列,用于存放待执行的任务。
  6. threadFactory线程工厂,用于自定义线程的创建过程。
  7. handler拒绝策略,当线程池和工作队列都满了之后的处理方式。

一个类比:餐厅的满座策略

我们可以把线程池想象成一家餐厅,线程是厨师,任务队列是等候区。当厨师全忙、等候区也满了,新来的客人(任务)该怎么办?

  • AbortPolicy (默认): 直接拒绝,抛出异常。
  • CallerRunsPolicy: 由提交者执行,将压力返还给任务提交者。
  • DiscardOldestPolicy: 丢弃最旧的,丢弃等候区排最前面的任务。
  • DiscardPolicy: 直接丢弃,默默丢弃新任务。
实现一个简易线程池

启发式提问与优化 (V2)

刚才我们实现的 MyThreadPool 看似解决了问题,但它其实隐藏着一个在生产环境中非常致命的缺陷。正如您刚才敏锐地指出的,当 main 方法执行完毕后,我们的程序并不会退出。

为什么呢?

因为我们创建的 worker 线程都是前台线程,并且它们都陷在 while(true) 的无限循环里。只要还有一个前台线程没有终结,JVM 就不会退出。这是一个典型的资源泄露问题!

一个健壮的线程池,必须提供一个能够优雅关闭的机制。接下来,让我们一起动手,为我们的线程池加上这个至关重要的功能。

核心思路:

  1. 我们需要一个方法,比如 shutdown(),来发起关闭指令。
  2. 线程池需要记录下它创建的所有工作线程,以便在关闭时能够管理它们。
  3. 工作线程的 while(true) 必须被打破。最优雅的方式就是响应 Java 的中断机制

下面是经过改进后的 V2 版本:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;// 实现一个固定线程个数的线程池 (V2 - 支持优雅关闭)
class MyThreadPool {private BlockingQueue<Runnable> queue;// 新增:用于保存工作线程的列表private List<Thread> workers;public MyThreadPool(int n) {queue = new LinkedBlockingQueue<>();workers = new ArrayList<>();// 创建N个工作线程for (int i = 0; i < n; i++) {Thread worker = new Thread(() -> {// 修改:当线程未被中断时,才持续从队列取任务while (!Thread.currentThread().isInterrupted()) {try {Runnable task = queue.take();task.run();} catch (InterruptedException e) {// 收到中断信号,是时候退出了System.out.println(Thread.currentThread().getName() + " 被中断,即将退出。");// 重新设置中断状态,并退出循环Thread.currentThread().interrupt(); break;}}}, "Worker-" + i);worker.start();workers.add(worker); // 将工作线程加入列表}}public void submit(Runnable task) throws InterruptedException {// 在这里可以增加一个判断,如果线程池已关闭,则拒绝新任务queue.put(task);}// 新增:关闭线程池的方法public void shutdown() {// 中断所有工作线程for (Thread worker : workers) {worker.interrupt();}}
}

V2 版本关键改进解析:

  • List<Thread> workers: 我们现在使用一个 ArrayList 来保存对每一个工作线程的引用,这是能够管理它们的前提。
  • shutdown() 方法: 这个新增的公开方法,是关闭线程池的入口。它会遍历所有工作线程并调用它们的 interrupt() 方法。这就像是给每个员工发送“可以下班了”的通知。
  • 响应中断: 工作线程的逻辑核心从 while(true) 变成了 while (!Thread.currentThread().isInterrupted())。更重要的是,当 queue.take() 因为被中断而抛出 InterruptedException 时,我们在 catch 块中不再是简单地打印信息,而是调用 break 来终止循环。这使得线程能够正常结束其 run 方法,从而被JVM回收。

经过这样的改造,我们的 MyThreadPool 才算具备了在真实场景中使用的基本素质,因为它现在能够被干净地关闭,避免了资源的永久占用。这个从“能用”到“好用”的优化过程,完美地体现了并发编程中严谨思维的重要性。

2.4 定时器

定时器是什么

定时器也是软件开发中的一个重要组件,类似于一个 “闹钟”,达到一个设定的时间之后, 就执行某个指定好的代码。例如,网络通信中,如果对方在500ms内没有返回数据,则断开连接;或者,希望Map中的某个key在3秒后自动过期。

标准库中的定时器

标准库中提供了一个 Timer 类,其核心方法是 schedule(TimerTask task, long delay)

import java.util.Timer;
import java.util.TimerTask;Timer timer = new Timer();
timer.schedule(new TimerTask() {@Overridepublic void run() {System.out.println("3秒后执行!");}
}, 3000);
实现一个简易定时器

核心构成:

  1. 一个带优先级的阻塞队列: 用 PriorityQueue 实现,确保时间最早的任务总是在队首。
  2. 一个 Task 对象: 封装了要执行的 Runnable 和执行时刻 time
  3. 一个 worker 线程: 负责扫描队首任务,检查是否到期。

实现步骤:

  1. schedule 方法将任务和计算出的绝对执行时间封装成 Task 对象,放入优先级队列。
  2. worker 线程循环检查队首任务。
  3. 如果队首任务未到期,或队列为空,worker 线程通过 wait(timeout) 机制进入等待,避免空转消耗CPU(即“忙等”)。

完整代码

import java.util.PriorityQueue;class MyTimerTask implements Comparable<MyTimerTask>{private Runnable task;private long time; // 任务执行的绝对时间戳public MyTimerTask(Runnable task, long time) {this.task = task;this.time = time;}@Overridepublic int compareTo(MyTimerTask o) {return (int)(this.time - o.time);}public long getTime() { return time; }public void run() { task.run(); }
}class MyTimer {private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();private final Object locker = new Object();public void schedule(Runnable task, long delay) {synchronized (locker) {long executeTime = System.currentTimeMillis() + delay;queue.add(new MyTimerTask(task, executeTime));locker.notify(); // 唤醒 worker 线程,可能有新的更早的任务}}public MyTimer() {Thread worker = new Thread(() -> {try {while (true) {synchronized (locker) {while (queue.isEmpty()) {locker.wait(); // 队列为空,无限期等待}MyTimerTask currentTask = queue.peek();long currentTime = System.currentTimeMillis();if (currentTask.getTime() > currentTime) {// 队首任务未到时间,等待指定时间locker.wait(currentTask.getTime() - currentTime);} else {// 时间到了,执行任务并移除currentTask.run();queue.poll();}}}} catch (InterruptedException e) {e.printStackTrace();}});worker.start();}
}

思路分析:如何避免“忙等”?

如果 worker 线程使用 while(true) 不断循环检查队首任务,当队列为空或任务时间未到时,会持续消耗大量CPU资源,这被称为“忙等”。

在这里插入图片描述

wait/notify 机制是解决此问题的理想方案:

  1. 队列为空时:调用 locker.wait()worker 线程进入无限期等待。
  2. 任务时间未到时:调用带超时的 locker.wait(timeout)worker 线程会精确地等待到任务执行时刻,或者被新加入的更早任务 notify() 提前唤醒。

这种“按需等待”的方式,极大地提升了定时器的效率。需要注意,sleep() 方法在这里不适用,因为它在等待期间不会释放锁,会导致 schedule 方法阻塞,无法添加新任务。

一个补充:时间轮

除了基于优先级队列(堆)的实现,还有一种高效的定时器实现方式叫“时间轮”。

  • 原理:可以想象成一个钟表,表盘上每个刻度(数组索引)代表一个时间单位。每个刻度上挂着一个链表,存放着应在该时刻执行的任务。一个指针随着时间推移,依次扫过每个刻度,并执行挂在上面的所有任务。
  • 优劣:时间轮的插入和删除任务操作非常快(O(1)),性能更高,适合任务量巨大的场景。但它的时间精度受限于刻度的粗细,不如优先级队列精确。

3. 总结

经过对多线程世界的深入探索,我们可以总结出保证线程安全的三条核心思路:

  1. 避免共享: 这是最彻底的线程安全策略。如果数据不需要在线程间共享,那么就从根本上杜绝了并发问题。每个线程操作自己的数据,互不干涉。

  2. 不可变共享: 如果数据必须共享,那么将其设计为“只读”的不可变(Immutable)对象是保证线程安全的绝佳方式。

    • 一个典型的例子就是 String 类。任何对 String 对象的操作都会返回一个新的 String 对象,而不是修改原始对象。
    • 我们也可以通过使用 final 关键字来创建不可变字段,确保其在初始化后无法被修改。
  3. 互斥同步 (直面线程安全): 当共享资源必须被多个线程修改时,就必须采用同步机制来保证操作的正确性。这通常需要解决以下三个问题:

    • 保证原子性: 确保一系列操作作为一个不可分割的整体执行。主要工具:synchronized, java.util.concurrent.locks.Lock
    • 保证可见性: 确保一个线程对共享变量的修改能被其他线程立即看到。主要工具:synchronized, volatile, final
    • 保证有序性: 确保代码的执行顺序符合预期,防止指令重排序带来的意外。主要工具:synchronized, volatile
      理**:可以想象成一个钟表,表盘上每个刻度(数组索引)代表一个时间单位。每个刻度上挂着一个链表,存放着应在该时刻执行的任务。一个指针随着时间推移,依次扫过每个刻度,并执行挂在上面的所有任务。
http://www.lryc.cn/news/614528.html

相关文章:

  • 在超算中心,除了立式机柜(rack-mounted)还有哪些形式?
  • 【大模型实战篇】部署GPT-OSS-120B踩得坑(vllm / ollama等推理框架)
  • 使用Prometheus + Grafana + node_exporter实现Linux服务器性能监控
  • 大语言模型的过去与未来——GPT-5发布小谈
  • (已解决)Mac 终端上配置代理
  • Document Picture-in-Picture API拥抱全新浮窗体验[参考:window.open]
  • 交流异步电机的定子与转子转速差产生的原因
  • KTH7111-离轴专用芯片,支持自校准,可替MA600和TLE5012,离轴精度可达±0.2
  • 对数函数分段定点实现
  • 单相交流异步电机旋转磁场产生原理
  • 力扣-53.最大子数组和
  • 从零构建TransformerP2-新闻分类Demo
  • Redis:集群(Cluster)
  • 修复C++14兼容性问题 逻辑检查
  • Vue3 计算属性与监听器
  • 项目一系列-第2章 Git版本控制
  • 贪心(set维护)
  • 「iOS」————优先级反转
  • Redis是单线程性能还高的原因
  • Redis缓存击穿、穿透雪崩
  • 【递归完全搜索】USACO Bronze 2018 December - 往返搬运Back and Forth
  • Python字典高阶操作:高效提取子集的技术与工程实践
  • RAG初步实战:从 PDF 到问答:我的第一个轻量级 RAG 系统(附详细项目代码内容与说明)
  • React 状态管理入门:从 useState 到复杂状态逻辑
  • React+TypeScript代码注释规范指南
  • HTML5 Web Workers 深度剖析:助力网页性能飞速提升
  • 3- Python 网络爬虫 — 如何抓取动态加载数据?Ajax 原理与实战全解析
  • 亚马逊广告运营如何平衡ASIN投放和关键词投放
  • 1688 图片搜图找货接口开发实战:从图像特征提取到商品匹配全流程
  • 塑料可回收物检测数据集-10,000 张图片 智能垃圾分类系统 环保回收自动化 智慧城市环卫管理 企业环保合规检测 教育环保宣传 供应链包装优化