还在手撸线程?搞懂这 6 大多线程设计模式,并发编程不再难!

嘿,哥们儿!是不是也遇到过这种情况:你的应用在开发环境跑得飞快,用户量一上来,或者某个功能一调用,系统就卡得像老爷车,CPU 蹭蹭往上涨,用户那边直抱怨"太慢了"?查了半天日志,发现好几个请求挤在一起,要么是互相等待,要么干脆把数据给搞乱了。头大不?

别慌,这很可能就是并发编程没处理好惹的祸。咱们日常开发,尤其是做后端服务的,跟并发打交道是家常便饭。用户请求是并发的,后台任务是并发的,消息处理也可能是并发的。如果处理不好,轻则性能低下,重则数据错乱、系统崩溃。

"我知道要用多线程啊,new Thread().start() 不就行了?"------打住!如果还停留在"大力出奇迹"的手撸线程阶段,那多半会掉进各种坑里。今天,我就以一个老码农的身份,跟你唠唠嗑,分享 6 个实战中超有用的多线程设计模式。理解了它们,你就能更从容、更优雅地应对并发场景,写出更健壮、更高性能的代码。

一、干活得有信号:Signaling 模式

想象一下,你在家做饭,需要等你妈把菜买回来才能下锅。这个"等菜买回来"的动作,就是一种信号。在多线程世界里,线程之间也常常需要互相通知,"嘿,我这儿准备好了,你可以开始了"或者"等等我,我这还没完呢"。

Signaling 模式 就是用来解决这种线程间协调问题的。它确保一个线程可以暂停执行,直到它收到另一个线程发来的"信号"才继续。

咋实现呢? Java 里有不少工具,比如:

  • Objectwait(), 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) 模式

这个模式太经典了!就像工厂流水线,有人负责生产零件(生产者),有人负责组装(消费者),中间通过传送带(缓冲区/队列)连接。生产者只管生产,往传送带上放;消费者只管消费,从传送带上取。

生产者-消费者模式 的核心是解耦。生产者和消费者不用直接打交道,它们只关心那个共享的缓冲区(通常是个队列)。这能很好地平衡两者的速度差异,提高系统吞吐量。

实现关键: 一个线程安全的共享队列(缓冲区)。

graph TD subgraph Producer Threads P1(生产者1) --> Q{阻塞队列}; P2(生产者2) --> Q; end subgraph Consumer Threads Q --> C1(消费者1); Q --> C2(消费者2); end style Q fill:#f9f,stroke:#333,stroke-width:2px

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、生产者-消费者和读写锁。它们就像是咱们并发编程工具箱里的瑞士军刀,针对不同的场景,选用合适的模式,能让你的代码更清晰、更健壮、性能也更好。

当然,理论归理论,最重要的还是要在实践中去体会、去运用。下次遇到并发问题,不妨想想,这里是不是可以用上哪个模式?尝试着用起来,你会发现并发编程其实也没那么可怕。

我叫老码小张,一个喜欢研究技术原理,并且在实践中不断踩坑、不断成长的老码农。希望今天的分享对你有帮助,也欢迎大家一起交流学习,共同进步!

相关推荐
南玖yy5 分钟前
C++ 成员变量缺省值:引用、const 与自定义类型的初始化规则详解,引用类型和const类型的成员变量自定义类型成员是否可以用缺省值?
c语言·开发语言·c++·后端·架构·c++基础语法
北漂老男孩26 分钟前
微服务架构下的熔断与降级:原理、实践与主流框架深度解析
微服务·架构
不爱总结的麦穗28 分钟前
面试常问!Spring七种事务传播行为一文通关
后端·spring·面试
小虚竹1 小时前
claude 3.7,极为均衡的“全能型战士”大模型,国内直接使用
开发语言·后端·claude·claude3.7
JarvanMo1 小时前
借助FlutterFire CLI实现Flutter与Firebase的多环境配置
前端·flutter
Yharim1 小时前
两个客户端如何通过websocket通信
spring boot·后端·websocket
bcbnb1 小时前
iOS 性能调优实战:三款工具横向对比实测(含 Instruments、KeyMob、Xlog)
后端
信码由缰1 小时前
Netflix系统架构解析
架构
Jedi Hongbin1 小时前
echarts自定义图表--仪表盘
前端·javascript·echarts