嘿,哥们儿!是不是也遇到过这种情况:你的应用在开发环境跑得飞快,用户量一上来,或者某个功能一调用,系统就卡得像老爷车,CPU 蹭蹭往上涨,用户那边直抱怨"太慢了"?查了半天日志,发现好几个请求挤在一起,要么是互相等待,要么干脆把数据给搞乱了。头大不?
别慌,这很可能就是并发编程没处理好惹的祸。咱们日常开发,尤其是做后端服务的,跟并发打交道是家常便饭。用户请求是并发的,后台任务是并发的,消息处理也可能是并发的。如果处理不好,轻则性能低下,重则数据错乱、系统崩溃。
"我知道要用多线程啊,
new Thread().start()
不就行了?"------打住!如果还停留在"大力出奇迹"的手撸线程阶段,那多半会掉进各种坑里。今天,我就以一个老码农的身份,跟你唠唠嗑,分享 6 个实战中超有用的多线程设计模式。理解了它们,你就能更从容、更优雅地应对并发场景,写出更健壮、更高性能的代码。
一、干活得有信号:Signaling 模式
想象一下,你在家做饭,需要等你妈把菜买回来才能下锅。这个"等菜买回来"的动作,就是一种信号。在多线程世界里,线程之间也常常需要互相通知,"嘿,我这儿准备好了,你可以开始了"或者"等等我,我这还没完呢"。
Signaling 模式 就是用来解决这种线程间协调问题的。它确保一个线程可以暂停执行,直到它收到另一个线程发来的"信号"才继续。
咋实现呢? Java 里有不少工具,比如:
Object
的wait()
,notify()
,notifyAll()
:这是比较底层的机制,用起来需要小心翼翼,容易出错(比如经典的"虚假唤醒"问题)。java.util.concurrent
包下的工具类:像CountDownLatch
,CyclicBarrier
,Semaphore
等,它们是更高层、更安全的封装,推荐使用。
举个栗子 (CountDownLatch): 假设主线程需要等待 3 个初始化任务全部完成才能继续执行。
java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SignalingDemo {
public static void main(String[] args) throws InterruptedException {
int taskCount = 3;
// 初始化一个计数器为3的CountDownLatch
CountDownLatch latch = new CountDownLatch(taskCount);
ExecutorService executor = Executors.newFixedThreadPool(taskCount);
System.out.println("主线程:开始分发初始化任务...");
for (int i = 0; i < taskCount; i++) {
final int taskId = i + 1;
executor.submit(() -> {
try {
System.out.println("任务 " + taskId + ":正在初始化...");
Thread.sleep((long) (Math.random() * 1000)); // 模拟耗时
System.out.println("任务 " + taskId + ":初始化完成!");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 每个任务完成后,计数器减1
latch.countDown();
}
});
}
System.out.println("主线程:等待所有初始化任务完成...");
// 主线程在这里阻塞,直到latch的计数变为0
latch.await();
System.out.println("主线程:所有任务已完成,系统启动!");
executor.shutdown();
}
}
用在哪? 主线程等待子任务完成、服务启动时等待依赖资源加载完毕等场景。
二、别老自己 new Thread 了:线程池 (Thread Pool) 模式
每次来个请求就 new Thread()
?哥们儿,线程的创建和销毁是很耗资源的!就像你每次要喝水都去买个新杯子,喝完就扔,多浪费啊。
线程池模式 就是你的"杯子收纳箱"。它预先创建好一堆线程放在池子里,需要执行任务时,就从池子里取一个空闲线程来用,用完了再还回去,而不是销毁。这样就大大减少了线程创建和销毁的开销,还能控制并发线程的总数,防止资源耗尽。
核心思想: 复用线程,管理并发。
Java 实现: java.util.concurrent.ExecutorService
就是线程池的标准接口,Executors
工厂类提供了创建不同类型线程池的方法(比如 newFixedThreadPool
, newCachedThreadPool
),当然更推荐直接用 ThreadPoolExecutor
的构造函数,可以更精细地控制参数。
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolDemo {
public static void main(String[] args) {
// 创建一个固定大小为5的线程池
ExecutorService threadPool = Executors.newFixedThreadPool(5);
System.out.println("向线程池提交10个任务...");
for (int i = 0; i < 10; i++) {
final int taskId = i;
// submit方法提交任务,任务会被池中的线程执行
threadPool.submit(() -> {
System.out.println(Thread.currentThread().getName() + " 正在执行任务 " + taskId);
try {
Thread.sleep(500); // 模拟任务执行
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// 记得关闭线程池,否则程序不会退出
threadPool.shutdown();
System.out.println("线程池已安排关闭,不再接受新任务。");
}
}
优劣势对比:
特性 | 优点 | 缺点 |
---|---|---|
性能 | 减少线程创建销毁开销,响应速度快 | 需要预先占用一定资源 |
管理 | 方便控制并发数,避免资源耗尽 | 配置不当可能导致性能问题(如线程数过多/过少) |
功能 | 提供任务排队、拒绝策略、定时执行等高级功能 | 相对直接创建线程更复杂一些 |
实战小贴士:
- 线程数设置: 不是越多越好。CPU 密集型任务,线程数建议设为 CPU 核心数 + 1;IO 密集型任务,可以设置得更大,比如
2 * CPU 核心数
,具体需要根据实际压测调整。 - 队列选择:
ThreadPoolExecutor
可以指定任务队列,LinkedBlockingQueue
(无界队列) 要小心内存溢出,ArrayBlockingQueue
(有界队列) 更常用,配合合适的拒绝策略。 - 拒绝策略: 当线程池和队列都满了,新任务怎么办?
AbortPolicy
(抛异常,默认)、CallerRunsPolicy
(让提交任务的线程自己执行)、DiscardPolicy
(直接丢弃)、DiscardOldestPolicy
(丢弃队列中最老任务)。
三、我的地盘我做主:线程特有存储 (Thread-Specific Storage / ThreadLocal) 模式
多个线程访问同一个共享变量,最头疼的就是线程安全问题。加锁?性能可能受影响。不加锁?数据可能就乱了。有没有办法让每个线程都拥有自己专属的"变量副本",互不干扰呢?
线程特有存储模式 就是干这个的。它为每个线程提供了一个独立的存储空间,用来存放该线程私有的数据。最典型的实现就是 Java 的 ThreadLocal
类。
核心思想: 空间换时间,避免共享,消除竞争。
怎么用?
java
import java.text.SimpleDateFormat;
import java.util.Date;
public class ThreadLocalDemo {
// 为每个线程创建一个独立的SimpleDateFormat实例
private static final ThreadLocal<SimpleDateFormat> dateFormatHolder =
ThreadLocal.withInitial(() -> {
System.out.println(Thread.currentThread().getName() + " 初始化 SimpleDateFormat");
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
});
public static String formatDate(Date date) {
// 每个线程通过get()方法拿到自己的SimpleDateFormat实例
return dateFormatHolder.get().format(date);
}
public static void main(String[] args) {
Runnable task = () -> {
Date now = new Date();
System.out.println(Thread.currentThread().getName() + " 格式化时间: " + formatDate(now));
// 注意:如果在线程池中使用ThreadLocal,任务结束后最好调用remove()清理,防止内存泄漏
// dateFormatHolder.remove();
};
new Thread(task, "线程A").start();
new Thread(task, "线程B").start();
}
}
// 输出会看到每个线程都初始化了自己的 SimpleDateFormat
用在哪?
- 保存每个请求的用户身份信息、事务上下文等。
- 解决
SimpleDateFormat
这类非线程安全类的并发使用问题。 - 一些框架(如 Spring)用它来管理事务、安全上下文。
注意: 在线程池环境中使用 ThreadLocal
要特别小心内存泄漏!因为线程池里的线程是复用的,如果不手动 remove()
掉 ThreadLocal
变量,那么上一个任务设置的值可能会被下一个任务拿到,并且这个对象会一直存在,直到线程被销毁(可能很久以后)。
四、活儿我先干着,结果回头给你:Future & Promise 模式
有时候,你发起一个耗时操作(比如网络请求、数据库查询),但你不想傻等着结果回来,希望先去干点别的,等结果好了再来取。
Future & Promise 模式 就是为了解决这种异步编程的需求。Future
像一张"提货单",你拿着它,可以在未来的某个时刻去"提取"操作的结果。而 Promise
通常是执行操作的那一方,负责在操作完成后把结果"放"到对应的 Future
里。
Java 实现: java.util.concurrent.Future
接口代表了异步计算的结果。ExecutorService.submit()
方法提交任务后就会返回一个 Future
对象。Java 8 引入的 CompletableFuture
更是强大,支持链式调用、组合、异常处理等,写异步代码更方便。
java
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
public class FuturePromiseDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
System.out.println(Thread.currentThread().getName() + ": 开始执行任务...");
// 异步执行一个耗时任务
CompletableFuture<String> futureResult = CompletableFuture.supplyAsync(() -> {
System.out.println(Thread.currentThread().getName() + ": 正在执行耗时操作...");
try {
TimeUnit.SECONDS.sleep(2); // 模拟耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(Thread.currentThread().getName() + ": 耗时操作完成!");
return "异步结果来了!";
});
System.out.println(Thread.currentThread().getName() + ": 主线程不阻塞,先干点别的...");
// 这里主线程可以做其他事情
System.out.println(Thread.currentThread().getName() + ": 尝试获取异步结果...");
// get()方法会阻塞,直到结果可用
String result = futureResult.get();
System.out.println(Thread.currentThread().getName() + ": 成功拿到结果 - " + result);
// 或者使用非阻塞的回调方式
futureResult.thenAccept(res ->
System.out.println(Thread.currentThread().getName() + ": 回调函数拿到结果 - " + res)
);
System.out.println(Thread.currentThread().getName() + ": 主线程任务结束。");
// 等待回调执行完(实际项目中可能不需要这样显式等待)
Thread.sleep(100);
}
}
用在哪?
- 需要发起耗时操作但不想阻塞当前线程的场景,如 GUI 应用的后台任务、Web 服务的外部 API 调用。
- 构建响应式的、非阻塞的系统。
五、你生产,我消费,中间加个仓库:生产者-消费者 (Producer-Consumer) 模式
这个模式太经典了!就像工厂流水线,有人负责生产零件(生产者),有人负责组装(消费者),中间通过传送带(缓冲区/队列)连接。生产者只管生产,往传送带上放;消费者只管消费,从传送带上取。
生产者-消费者模式 的核心是解耦。生产者和消费者不用直接打交道,它们只关心那个共享的缓冲区(通常是个队列)。这能很好地平衡两者的速度差异,提高系统吞吐量。
实现关键: 一个线程安全的共享队列(缓冲区)。
Java 实现: java.util.concurrent.BlockingQueue
接口就是为这个模式量身定做的。它提供了 put()
(队列满时阻塞) 和 take()
(队列空时阻塞) 方法,天然支持线程安全和阻塞等待。
java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ProducerConsumerDemo {
public static void main(String[] args) {
// 创建一个容量为5的阻塞队列
BlockingQueue<String> queue = new ArrayBlockingQueue<>(5);
// 生产者线程
Runnable producer = () -> {
int i = 0;
try {
while (true) {
String data = "Data-" + (i++);
System.out.println("生产者 " + Thread.currentThread().getName() + " 生产了: " + data);
queue.put(data); // 队列满时,put方法会阻塞
Thread.sleep(100); // 模拟生产间隔
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("生产者被中断");
}
};
// 消费者线程
Runnable consumer = () -> {
try {
while (true) {
String data = queue.take(); // 队列空时,take方法会阻塞
System.out.println("消费者 " + Thread.currentThread().getName() + " 消费了: " + data);
Thread.sleep(500); // 模拟消费耗时
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("消费者被中断");
}
};
// 启动生产者和消费者
new Thread(producer, "P1").start();
new Thread(producer, "P2").start();
new Thread(consumer, "C1").start();
new Thread(consumer, "C2").start();
new Thread(consumer, "C3").start();
}
}
用在哪?
- 任务分发系统(如日志处理,一个线程写日志,多个线程异步写入文件/数据库)。
- 消息队列的底层实现。
- 任何需要解耦生产者和消费者,并进行流量削峰填谷的场景。
六、读多写少?上读写锁!:读写锁 (Read-Write Lock) 模式
咱们常用的 synchronized
关键字或者 ReentrantLock
都是排他锁,意思是同一时间只能有一个线程访问被保护的资源,不管它是读还是写。但在很多场景下,"读"操作是不会改变数据的,多个线程同时读取是安全的。如果"读"操作远多于"写"操作,用排他锁就会导致性能瓶颈,因为读操作也得排队。
读写锁模式 就是来优化这种情况的。它允许多个线程同时读取共享资源,但只允许一个线程进行写入操作。当有线程在写入时,其他所有读线程和写线程都必须等待。
核心思想: 读共享,写独占。
Java 实现: java.util.concurrent.locks.ReadWriteLock
接口,常用实现是 ReentrantReadWriteLock
。它提供了两个锁:一个读锁 (readLock()
),一个写锁 (writeLock()
)。
java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockDemo {
private final Map<String, String> cache = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// 获取缓存(读操作)
public String get(String key) {
rwLock.readLock().lock(); // 获取读锁
try {
System.out.println(Thread.currentThread().getName() + " 正在读取 key: " + key);
Thread.sleep(100); // 模拟读取耗时
return cache.get(key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
System.out.println(Thread.currentThread().getName() + " 释放读锁");
rwLock.readLock().unlock(); // 释放读锁
}
}
// 写入缓存(写操作)
public void put(String key, String value) {
rwLock.writeLock().lock(); // 获取写锁
try {
System.out.println(Thread.currentThread().getName() + " 正在写入 key: " + key);
Thread.sleep(500); // 模拟写入耗时
cache.put(key, value);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println(Thread.currentThread().getName() + " 释放写锁");
rwLock.writeLock().unlock(); // 释放写锁
}
}
public static void main(String[] args) {
ReadWriteLockDemo demo = new ReadWriteLockDemo();
// 模拟多个读线程和一个写线程
Runnable readTask = () -> demo.get("myKey");
Runnable writeTask = () -> demo.put("myKey", "newValue");
// 启动3个读线程
new Thread(readTask, "Reader-1").start();
new Thread(readTask, "Reader-2").start();
// 启动1个写线程
new Thread(writeTask, "Writer-1").start();
// 再启动1个读线程,它需要等写线程结束
new Thread(readTask, "Reader-3").start();
}
}
用在哪?
- 实现缓存系统。
- 配置文件读取。
- 任何读操作频率远大于写操作频率的共享数据结构访问。
对比普通锁 vs 读写锁:
特性 | 普通锁 (synchronized , ReentrantLock ) |
读写锁 (ReentrantReadWriteLock ) |
---|---|---|
并发性 | 读和写都互斥,同一时间只有一个线程能访问 | 读-读不互斥,读-写互斥,写-写互斥 |
适用场景 | 写操作频繁,或读写操作都需要严格串行化 | 读操作远多于写操作,且读操作耗时相对较长 |
性能 | 在读多写少场景下性能较低 | 在读多写少场景下显著提升读操作的并发性能 |
复杂度 | 使用相对简单 | 使用稍复杂,需要区分获取读锁和写锁 |
好了,今天一口气跟大家分享了 6 个非常有用的多线程设计模式:Signaling、线程池、线程特有存储、Future & Promise、生产者-消费者和读写锁。它们就像是咱们并发编程工具箱里的瑞士军刀,针对不同的场景,选用合适的模式,能让你的代码更清晰、更健壮、性能也更好。
当然,理论归理论,最重要的还是要在实践中去体会、去运用。下次遇到并发问题,不妨想想,这里是不是可以用上哪个模式?尝试着用起来,你会发现并发编程其实也没那么可怕。
我叫老码小张,一个喜欢研究技术原理,并且在实践中不断踩坑、不断成长的老码农。希望今天的分享对你有帮助,也欢迎大家一起交流学习,共同进步!