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 多线程的基础概念,并深入了解了确保线程安全的核心要素。我们知道了如何创建线程,也理解了 synchronized
和 volatile
如何为我们的并发程序保驾护航。
然而,多线程的世界远不止于此。除了避免线程间的“冲突”,我们更需要让它们能够高效地“协作”,共同完成复杂的任务。本文将带你进入多线程的进阶领域,我们将学习:
- 线程间的优雅通信:如何使用
wait()
和notify()
机制,实现精准的线程间协作。 - 经典设计模式实战:深入剖析单例模式、生产者-消费者模型、线程池和定时器在并发场景下的实现原理与最佳实践。
1. wait
与 notify
:线程间的协作与通信
在并发编程中,我们不仅要处理线程间的竞争关系,还需要让它们能够相互协作,按照预定的顺序完成任务。这就好比一场篮球比赛,传球和投篮是两个不同的动作,必须由不同的球员(线程)协同完成,而且必须先传球,后投篮。
为了实现这种精密的线程协作,Java 的 Object
类提供了三个核心方法:wait()
、notify()
和 notifyAll()
。
核心要点:这三个方法都定义在
Object
类中,意味着任何 Java 对象都可以作为通信的“信物”。它们必须在synchronized
同步块中调用,否则会抛出IllegalMonitorStateException
异常。
1.1 wait()
:让出 CPU,并释放锁
当一个线程获取到锁,但发现继续执行的条件尚不满足时(例如,消费者发现队列是空的),它就可以调用 wait()
方法,主动进入等待状态。
wait()
方法会执行三个关键操作:
- 让当前线程进入等待(WAITING) 状态,并被放入该对象的等待队列中。
- 立即释放当前持有的
synchronized
锁。这一点至关重要,因为它给了其他线程获取锁并改变条件的机会。 - 当被唤醒后,该线程会重新尝试获取锁。只有成功获取锁之后,
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
不在同步块里:
- 线程 A 判断条件不满足,准备调用
wait()
。- 就在此时,线程切换,线程 B 开始执行。
- 线程 B 修改了条件,并调用了
notify()
。但此时线程 A 还没来得及wait
,所以这个notify
信号就丢失了。- 线程切换回 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();}
}
执行顺序分析
t1
启动,获取locker
锁,打印 “开始等待…”,然后调用wait()
,释放锁并进入等待。t2
启动,获取locker
锁,打印 “准备唤醒…”,然后调用notify()
。t1
被唤醒,但由于t2
仍持有锁,t1
无法立即执行,只能等待锁被释放。t2
继续执行,打印 “notify() 已调用…”,直到同步块结束,释放锁。t1
终于获取到锁,从wait()
方法返回,打印 “等待结束。”
关于
wait
和notify
的一个重要实践:
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 wait
与 sleep
的核心区别(高频面试题)
尽管 wait
和 sleep
都能让线程暂停执行,但它们的设计目的和行为有着本质的区别,是面试中的高频考点。
对比维度 | 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()
这两步操作并非原子性的。想象一下这个场景:
- 线程 A 调用
getInstance()
,判断instance
为null
,进入if
代码块。- 在线程 A 即将执行
new
操作之前,发生线程切换,线程 B 开始执行。- 线程 B 调用
getInstance()
,此时instance
仍然是null
,所以它也通过了if
判断。- 线程 B 创建了一个实例,并将其赋给
instance
。- 线程切换回 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
后,确实解决了线程安全问题。但新的问题随之而来:性能。
思路分析:无差别加锁的性能瓶颈
- 首次创建:加锁是必要的。当多个线程同时尝试创建实例时,
synchronized
会迫使它们排队,只有一个线程能成功创建。- 后续访问:一旦实例被创建,后续所有对
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
在这里起到了两个至关重要的作用:-
保证内存可见性:确保一个线程对
instance
的修改(赋值),能立刻被其他线程看到。 -
禁止指令重排序:这是更深层次、也更关键的原因。
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();}
}
手动实现一个阻塞队列
为了更深刻地理解其工作原理,我们可以尝试自己实现一个简易版的阻塞队列。
核心思路:
- 使用一个数组作为底层存储,实现循环队列。
- 使用
synchronized
保证put
和take
方法的原子性。 - 当队列满时,调用
this.wait()
使put
线程阻塞。 - 当队列空时,调用
this.wait()
使take
线程阻塞。 put
成功后,调用this.notify()
唤醒take
线程。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)”的风险。
什么是伪唤醒? Java官方文档中明确指出,线程有时可能会在没有被
notify()
的情况下“醒来”。
if
的隐患:如果使用if
,当一个线程被伪唤醒时,它会直接跳过if
判断,继续执行后续代码,但此时它等待的条件可能并未满足,从而导致程序出错。
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
的构造方法参数是经典的面试题:
corePoolSize
核心线程数:线程池中始终保持存活的线程数量。maximumPoolSize
最大线程数:线程池允许创建的线程总数上限。keepAliveTime
:临时线程(非核心线程)允许的最大空闲时间。unit
:keepAliveTime
的时间单位。workQueue
:工作队列,用于存放待执行的任务。threadFactory
:线程工厂,用于自定义线程的创建过程。handler
:拒绝策略,当线程池和工作队列都满了之后的处理方式。
一个类比:餐厅的满座策略
我们可以把线程池想象成一家餐厅,线程是厨师,任务队列是等候区。当厨师全忙、等候区也满了,新来的客人(任务)该怎么办?
AbortPolicy
(默认): 直接拒绝,抛出异常。CallerRunsPolicy
: 由提交者执行,将压力返还给任务提交者。DiscardOldestPolicy
: 丢弃最旧的,丢弃等候区排最前面的任务。DiscardPolicy
: 直接丢弃,默默丢弃新任务。
实现一个简易线程池
启发式提问与优化 (V2)
刚才我们实现的
MyThreadPool
看似解决了问题,但它其实隐藏着一个在生产环境中非常致命的缺陷。正如您刚才敏锐地指出的,当main
方法执行完毕后,我们的程序并不会退出。为什么呢?
因为我们创建的
worker
线程都是前台线程,并且它们都陷在while(true)
的无限循环里。只要还有一个前台线程没有终结,JVM 就不会退出。这是一个典型的资源泄露问题!一个健壮的线程池,必须提供一个能够优雅关闭的机制。接下来,让我们一起动手,为我们的线程池加上这个至关重要的功能。
核心思路:
- 我们需要一个方法,比如
shutdown()
,来发起关闭指令。- 线程池需要记录下它创建的所有工作线程,以便在关闭时能够管理它们。
- 工作线程的
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);
实现一个简易定时器
核心构成:
- 一个带优先级的阻塞队列: 用
PriorityQueue
实现,确保时间最早的任务总是在队首。 - 一个
Task
对象: 封装了要执行的Runnable
和执行时刻time
。 - 一个
worker
线程: 负责扫描队首任务,检查是否到期。
实现步骤:
schedule
方法将任务和计算出的绝对执行时间封装成Task
对象,放入优先级队列。worker
线程循环检查队首任务。- 如果队首任务未到期,或队列为空,
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
机制是解决此问题的理想方案:
- 队列为空时:调用
locker.wait()
,worker
线程进入无限期等待。- 任务时间未到时:调用带超时的
locker.wait(timeout)
。worker
线程会精确地等待到任务执行时刻,或者被新加入的更早任务notify()
提前唤醒。这种“按需等待”的方式,极大地提升了定时器的效率。需要注意,
sleep()
方法在这里不适用,因为它在等待期间不会释放锁,会导致schedule
方法阻塞,无法添加新任务。
一个补充:时间轮
除了基于优先级队列(堆)的实现,还有一种高效的定时器实现方式叫“时间轮”。
- 原理:可以想象成一个钟表,表盘上每个刻度(数组索引)代表一个时间单位。每个刻度上挂着一个链表,存放着应在该时刻执行的任务。一个指针随着时间推移,依次扫过每个刻度,并执行挂在上面的所有任务。
- 优劣:时间轮的插入和删除任务操作非常快(O(1)),性能更高,适合任务量巨大的场景。但它的时间精度受限于刻度的粗细,不如优先级队列精确。
3. 总结
经过对多线程世界的深入探索,我们可以总结出保证线程安全的三条核心思路:
-
避免共享: 这是最彻底的线程安全策略。如果数据不需要在线程间共享,那么就从根本上杜绝了并发问题。每个线程操作自己的数据,互不干涉。
-
不可变共享: 如果数据必须共享,那么将其设计为“只读”的不可变(Immutable)对象是保证线程安全的绝佳方式。
- 一个典型的例子就是
String
类。任何对String
对象的操作都会返回一个新的String
对象,而不是修改原始对象。 - 我们也可以通过使用
final
关键字来创建不可变字段,确保其在初始化后无法被修改。
- 一个典型的例子就是
-
互斥同步 (直面线程安全): 当共享资源必须被多个线程修改时,就必须采用同步机制来保证操作的正确性。这通常需要解决以下三个问题:
- 保证原子性: 确保一系列操作作为一个不可分割的整体执行。主要工具:
synchronized
,java.util.concurrent.locks.Lock
。 - 保证可见性: 确保一个线程对共享变量的修改能被其他线程立即看到。主要工具:
synchronized
,volatile
,final
。 - 保证有序性: 确保代码的执行顺序符合预期,防止指令重排序带来的意外。主要工具:
synchronized
,volatile
。
理**:可以想象成一个钟表,表盘上每个刻度(数组索引)代表一个时间单位。每个刻度上挂着一个链表,存放着应在该时刻执行的任务。一个指针随着时间推移,依次扫过每个刻度,并执行挂在上面的所有任务。
- 保证原子性: 确保一系列操作作为一个不可分割的整体执行。主要工具: