Java多线程设计模式案例:带代码的全景梳理(单例 / 阻塞队列 / 定时器 / 线程池)
本文梳理了四种多线程设计模式的实现方法:1)单例模式(饿汉/懒汉/双重检查),保证全局唯一实例;2)阻塞队列(生产者-消费者模型),实现线程安全与自动阻塞;3)定时器,支持到点执行任务;4)线程池(未展示代码)。重点分析了wait/sleep区别、单例模式的线程安全实现、阻塞队列的手写实现(循环数组+同步机制),并提供了各模式的典型应用场景和代码示例。这些模式通过同步控制、条件等待等机制,有效解决
多线程设计模式案例:带代码的全景梳理(单例 / 阻塞队列 / 定时器 / 线程池)
0. wait 和 sleep(后面两个案例会用到)
来看这段面试总结:wait 必须配合 synchronized 使用,sleep 不需要;并且 wait 是 Object 的方法,sleep 是 Thread 的静态方法。
synchronized (locker) {
locker.wait(); // Object.wait:需要拿到 locker 这把锁
}
Thread.sleep(1000); // Thread.sleep:不要求持有任何锁
解释一下:wait() 会让线程进入等待队列,并且释放当前锁;sleep() 只是让线程暂停一段时间,不负责线程间协作。而且对于sleep来说,他会“抱着锁睡觉” 这个差异会直接决定“阻塞队列”和“定时器”的写法。
8.1 单例模式:保证“全局只有一个实例”
来看这里对单例的定义:单例模式要保证某个类在程序中只存在唯一一份实例,比如 JDBC 的 DataSource 就常常只需要一个。
class Singleton {
private static Singleton instance = new Singleton(); // 唯一实例
private Singleton() {} // 禁止外部 new
public static Singleton getInstance() {
return instance;
}
}
解释一下:构造器私有化让外部无法随意 new;唯一实例放在类的静态字段里,统一通过 getInstance() 返回,这就把“唯一入口”钉死了。
8.1.1 饿汉模式:类加载就创建(简单、天然线程安全)
来看这段“饿汉”:类加载时直接把实例建出来。
class Singleton {
private static Singleton instance = new Singleton(); // 类加载即创建
private Singleton() {}
public static Singleton getInstance() { return instance; }
}
解释一下:优点是实现极其简单,并且类初始化过程本身就能保证线程安全;缺点是如果实例很重、但一直用不上,会有资源浪费。
8.1.2 懒汉模式(单线程版):第一次用到才创建(但多线程会翻车)
来看这段“懒汉单线程版”:第一次调用 getInstance() 时发现 instance == null 才创建。
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 第一次用到才创建
}
return instance;
}
}
解释一下:在单线程里没问题;但多线程同时进来都看到 instance == null 时,可能会各自 new 一次,直接破坏“唯一性”。线程安全问题主要发生在首次创建实例这一刻。
8.1.3 懒汉模式(加锁版):用 synchronized 把创建过程互斥掉
来看这段修复:给 getInstance() 加 synchronized,保证同一时间只有一个线程能创建实例。
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 互斥区:只会创建一次
}
return instance;
}
}
解释一下:正确性稳了,但每次调用都要竞争锁,实例创建完成后仍然要付出加锁成本。
8.1.4 懒汉模式(改进版):双重检查 + volatile(兼顾性能与正确性)
来看这段“改进版”:外层 if 先挡住大多数已经初始化的情况,只有第一次才会进入锁;并且给 instance 加上 volatile 来处理可见性问题。
class Singleton {
private static volatile Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:避免无谓加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:保证只创建一次
instance = new Singleton();
}
}
}
return instance;
}
}
解释一下:加锁/解锁开销高,而懒汉的不安全只发生在首次创建;外层 if 就是为了减少后续的锁竞争;volatile 用来避免读取到不一致的 instance 值(可见性偏差)。
8.2 阻塞队列:线程安全 + “满/空自动阻塞”
来看这里的定义:阻塞队列仍然遵守 FIFO,但它是线程安全结构;队列满继续入队会阻塞,队列空继续出队也会阻塞。
BlockingQueue<String> q = new LinkedBlockingQueue<>();
q.put("abc"); // 队列满会阻塞
String x = q.take(); // 队列空会阻塞
解释一下:put/take 这类 API 自带阻塞语义;而 offer/poll/peek 这一类不阻塞(更多用于“试一下,不行就算了”的场景)。
8.2.1 生产者-消费者模型:用队列解耦 + 削峰填谷
来看这段模型描述:生产者与消费者不直接通信,而是通过阻塞队列通信;生产者生产完直接扔队列,消费者从队列取,不需要互相等待对方。
BlockingQueue<Integer> q = new LinkedBlockingQueue<>();
Thread producer = new Thread(() -> {
try {
while (true) q.put((int)(Math.random() * 1000));
} catch (InterruptedException ignored) {}
}, "生产者");
Thread consumer = new Thread(() -> {
try {
while (true) System.out.println("消费: " + q.take());
} catch (InterruptedException ignored) {}
}, "消费者");
producer.start();
consumer.start();
解释一下:这类结构最常见的两个收益——缓冲区平衡两端处理能力(削峰填谷),以及解耦。这里举了“秒杀请求先入队,消费者慢慢处理,避免服务被瞬时洪峰冲垮”的例子。
8.2.2 手写阻塞队列:循环数组 + synchronized + while(wait) + notifyAll
来看这里的实现要点:用循环队列存储;用 synchronized 加锁;put 遇到满就 wait(而且要在 while 里等);take 遇到空也 wait;状态变化后用 notifyAll 唤醒等待线程。
class BlockingQueue {
private final int[] items = new int[1000];
private int size = 0, head = 0, tail = 0;
public void put(int value) throws InterruptedException {
synchronized (this) {
while (size == items.length) { // 满了就等
wait();
}
items[tail] = value;
tail = (tail + 1) % items.length;
size++;
notifyAll(); // 唤醒可能在等“非空”的线程
}
}
public int take() throws InterruptedException {
synchronized (this) {
while (size == 0) { // 空了就等
wait();
}
int ret = items[head];
head = (head + 1) % items.length;
size--;
notifyAll(); // 唤醒可能在等“非满”的线程
return ret;
}
}
}
解释一下:while 不用 if 的原因也写得很明确——线程被唤醒后不一定立刻抢到锁;等真正抢到锁时,队列状态可能又变回“满/空”,所以需要循环验证条件,条件不满足就继续等。
8.3 定时器:把“到点执行”变成一个组件
来看这里对定时器的定位:像“闹钟”一样,到设定时间就执行指定代码;常见场景包括网络超时重连、Map 的 key 过期自动删除等。
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
}, 3000);
解释一下:schedule 两个参数——任务代码 + 延迟多久执行(毫秒)。
8.3.1 手写定时器:优先队列 + worker 线程扫队首 + wait(剩余时间)
来看这里的“构成”:核心是一个优先级队列(明确提示不要用 PriorityBlockingQueue,容易死锁)、队列里每个元素是带执行时间戳的 Task,队首永远是最早要执行的任务;再配一个 worker 线程不停扫描队首,时间没到就等待,到了就执行。
class MyTask implements Comparable<MyTask> {
Runnable runnable;
long time; // 绝对时间戳
MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + delay;
}
@Override
public int compareTo(MyTask o) {
return Long.compare(this.time, o.time); // 时间小的优先
}
}
解释一下:用“绝对时间戳”而不是“剩余延迟”非常关键——这样每次比较只看 time 大小即可,优先队列 peek() 永远拿到最早要执行的任务。
class MyTimer {
private final PriorityQueue<MyTask> queue = new PriorityQueue<>();
private final Object locker = new Object();
public void schedule(Runnable command, long after) {
synchronized (locker) {
queue.offer(new MyTask(command, after));
locker.notify(); // 新任务来了,叫醒 worker 重新算等待时间
}
}
public MyTimer() {
Thread t = new Thread(() -> {
while (true) {
try {
synchronized (locker) {
while (queue.isEmpty()) locker.wait();
MyTask task = queue.peek();
long now = System.currentTimeMillis();
if (now >= task.time) {
queue.poll();
task.runnable.run();
} else {
locker.wait(task.time - now); // 睡到差不多该执行
}
}
} catch (InterruptedException ignored) {}
}
});
t.start();
}
}
解释一下:worker 线程做的事情非常“机械但正确”——队列空就无限期 wait();队首任务没到点就 wait(差值);到点就 poll() 并执行。这个结构把 CPU 空转降到最低,同时保证始终按最早触发顺序执行。
8.4 线程池:复用线程,限制并发上限,队列缓冲任务
来看这里的类比:每来一个任务就临时招人送快递再解雇,成本很高;于是固定雇几个人,忙不过来就把任务记在本子上排队,这就是线程池模式。它的最大好处是减少线程启动/销毁的损耗。
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> System.out.println("hello"));
解释一下:Executors.newFixedThreadPool(10) 创建固定 10 线程的线程池,通过 submit 把任务提交进去执行。
8.4.1 Executors 的几种常见线程池
来看这里的四种:固定线程数、动态增长、单线程、以及带延迟/定期执行能力的 scheduled(相当于 Timer 的进阶版)。
Executors.newFixedThreadPool(10);
Executors.newCachedThreadPool();
Executors.newSingleThreadExecutor();
Executors.newScheduledThreadPool(4);
解释一下:这些工厂方法本质上都是对 ThreadPoolExecutor 的封装,提供“开箱即用”的默认参数组合。
8.4.2 ThreadPoolExecutor:把线程池行为参数化(含拒绝策略)
来看这里的参数清单:corePoolSize / maximumPoolSize / keepAliveTime / workQueue / threadFactory / RejectedExecutionHandler,以及四种拒绝策略(直接抛异常、调用者执行、丢最老、丢最新)。
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 8,
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
解释一下:核心线程相当于“正式员工”,最大线程数包含“临时工”,临时工空闲超过 keepAliveTime 会被回收;队列负责缓冲任务;拒绝策略负责在系统过载时给出“怎么处理多出来任务”的明确答案。
8.4.3 手写固定线程数线程池:BlockingQueue + N 个 worker 不停 take().run()
来看这里的核心套路:submit 把任务放入阻塞队列;每个 worker 线程做的事就是不断从队列取任务并执行。
class MyThreadPool {
private final BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
public MyThreadPool(int n) {
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
while (true) {
try {
Runnable r = queue.take(); // 没任务就阻塞
r.run(); // 有任务就执行
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
}
解释一下:阻塞队列把“提交任务”和“执行任务”彻底解耦——提交方只负责 put;worker 只负责 take + run;并发上限由 worker 数量天然限定(n 个线程同时最多跑 n 个任务)。
总结:四个案例其实是同一个并发约束力
来看最后的统一视角:单例用锁/可见性约束“唯一性”;阻塞队列用条件等待约束“生产与消费速率差”;定时器用“时间排序 + 超时等待”约束“到点触发”;线程池用“固定 worker + 队列缓冲”约束“资源上限与吞吐”。这四类积木拼起来,就能覆盖大量真实业务中的并发问题。
// 多线程里最常用的四件套就是:
// 1) Singleton:全局唯一
// 2) BlockingQueue:线程间通信 + 削峰
// 3) Timer/MyTimer:延迟触发
// 4) ThreadPool:线程复用 + 任务队列
本质是把“不确定的线程调度”变成“确定的协议”(锁、队列、时间、worker)
更多推荐
所有评论(0)