性能优化 - 案例篇:并行计算

在这里插入图片描述


Pre

性能优化 - 理论篇:常见指标及切入点

性能优化 - 理论篇:性能优化的七类技术手段

性能优化 - 理论篇:CPU、内存、I/O诊断手段

性能优化 - 工具篇:常用的性能测试工具

性能优化 - 工具篇:基准测试 JMH

性能优化 - 案例篇:缓冲区

性能优化 - 案例篇:缓存

性能优化 - 案例篇:数据一致性

性能优化 - 案例篇:池化对象_Commons Pool 2.0通用对象池框架

性能优化 - 案例篇:大对象的优化

性能优化 - 案例篇:使用设计模式优化性能


在这里插入图片描述

  1. 引言:多核时代与并行编程意义;
  2. 并行获取数据示例:CountDownLatch 封装与使用;
  3. 线程池设计与调优:核心/最大线程数、队列、拒绝策略;
  4. SpringBoot 中的异步执行:@EnableAsync、@Async 与自定义线程池;
  5. 线程安全与常见并发陷阱:不安全集合、SimpleDateFormat 等案例;
  6. ThreadLocal 与 Netty FastThreadLocal:底层实现与性能差异;
  7. 多线程常见问题盘点与解决思路;
  8. 小结:并行编程的关键要点与注意事项;

1. 引言:多核时代与并行编程意义

如今的计算设备(PC、服务器,甚至手机)几乎都配备了多颗 CPU 核心,正确地利用多核并发能力,才能最大化提升系统吞吐量与响应速度。对于业务场景而言,如果某个接口仅串行地等待 20+ 个下游接口返回,就会产生明显的性能瓶颈。Java 平台通过丰富的并发 API(java.util.concurrent 包),让我们无需直接操心底层线程创建与同步,就能轻松实现高效并行。

接下来将从一个“并行获取数据”的核心示例切入,深入讲解:

  • CountDownLatch 封装并行任务,协同等待整体结果;
  • 线程池 设计要素与常见参数(核心线程数、最大线程数、队列容量、拒绝策略);
  • SpringBoot 异步任务@EnableAsync@Async 与自定义 ThreadPoolTaskExecutor
  • 线程安全陷阱:并发下的集合、安全日期类、异常丢失等常见问题;
  • ThreadLocal vs FastThreadLocal:内部数据结构与查找性能对比;
  • 并行编程中需要重点关注的几大考点与实践经验。

2. 并行获取数据示例:CountDownLatch 封装

2.1 场景描述

假设有一个“用户画像”接口,需要在 50ms 内返回给前端。

  • 系统需从 20+ 个下游服务接口并行汇总数据;
  • 每个下游接口最优耗时20ms,如果串行调用,总耗时 ≈ 20ms × 20 = 400ms,远超 50ms SLA;
  • 解决思路:并行调用所有下游接口,最后汇总结果,超时则放弃未返回的子任务。

2.2 CountDownLatch 工具类封装

Java 的 CountDownLatch 本质上是一个可重用的倒计时计数器。

  • 构造时指定“请求总数” N
  • 每当一个并行任务完成后,调用 countDown() 将计数器减 1;
  • 主线程调用 await(long timeout, TimeUnit) 阻塞,直到计数器变为 0或超时。

下面提供一个通用封装示例 ParallelFetcher

public class ParallelFetcher {
    private final long timeout;
    private final CountDownLatch latch;
    private final ThreadPoolExecutor executor;

    public ParallelFetcher(int jobSize, long timeoutMill) {
        // 初始化倒计时锁:任务数 = jobSize
        this.latch = new CountDownLatch(jobSize);
        this.timeout = timeoutMill;

        // 自定义线程池:核心 100,最大 200,队列容量 100,空闲线程 1 小时后回收
        this.executor = new ThreadPoolExecutor(
            100, 200,
            1, TimeUnit.HOURS,
            new ArrayBlockingQueue<>(100)
        );
    }

    /**
     * 提交一个并行任务,任务执行完毕后自动 countdown()
     */
    public void submitJob(Runnable task) {
        executor.execute(() -> {
            try {
                task.run();
            } finally {
                latch.countDown();
            }
        });
    }

    /**
     * 阻塞等待:直到所有任务 countdown() 或者超时
     */
    public void await() {
        try {
            latch.await(timeout, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            throw new IllegalStateException("并行任务等待被中断", e);
        }
    }

    /** 释放线程池资源 */
    public void dispose() {
        executor.shutdown();
    }
}

2.3 使用示例

假设我们有一个 SlowInterfaceMock 类,模拟 20 个耗时随机(0–60ms)的下游接口:

public class ParallelFetcherDemo {
    public static void main(String[] args) {
        String userId = "123";
        SlowInterfaceMock mock = new SlowInterfaceMock();

        // 并行任务数 20,整体等待超时 50ms
        ParallelFetcher fetcher = new ParallelFetcher(20, 50);
        // 线程安全的集合存储并发结果
        Map<String, String> resultMap = new ConcurrentHashMap<>();

        // 提交 20 个并行任务
        for (int i = 0; i < 20; i++) {
            int idx = i;
            fetcher.submitJob(() -> {
                // 假设 mock.methodX(userId) 模拟调用下游服务
                resultMap.put("method" + idx, mock.call(userId, idx));
            });
        }

        // 主线程阻塞最多 50ms,或者直到 20 个任务全部完成
        fetcher.await();

        // 查看未超时时到底收集了多少结果
        System.out.println("Latch 值(剩余任务数): " + fetcher.latch.getCount());
        System.out.println("收集到的结果数量: " + resultMap.size());
        System.out.println("结果内容: " + resultMap);

        // 记得释放线程池
        fetcher.dispose();
    }
}

注意事项

  1. 结果集合要线程安全:示例中使用了 ConcurrentHashMap,若直接用 HashMap,在并发写入时会出现数据丢失或线程冲突。

  2. await(timeout) 超时后仍有未完成任务CountDownLatch.await 超时后只是唤醒主线程,但工作线程仍在继续执行,若要彻底中断,需在任务内部对超时逻辑做特殊处理(如设置连接超时、检查中断标志等)。

  3. 线程池参数配置

    • 核心线程数 corePoolSize = 100,最大线程数 200,队列容量 100
    • 若业务并发量预计不止 1 个,此处可根据 “任务量 / 50ms” 的最差并发做估算。
    • I/O 密集型:线程数可远超 CPU 核数;
    • 计算密集型:线程数应接近 CPU 核数,过多线程反而增加上下文切换。

3. 线程池设计与调优

ThreadPoolExecutor 是 Java 并发包里最灵活的线程池实现,常见构造参数如下:

public ThreadPoolExecutor(int corePoolSize, 
                          int maximumPoolSize, 
                          long keepAliveTime, 
                          TimeUnit unit, 
                          BlockingQueue<Runnable> workQueue, 
                          ThreadFactory threadFactory, 
                          RejectedExecutionHandler handler)
  • corePoolSize:核心线程数,线程池会一直维持至少这个数量的线程常驻;
  • maximumPoolSize:最大线程数,超过 corePoolSize、且队列已满时才会创建;
  • keepAliveTime + unit:非核心线程空闲多久后会被回收;
  • workQueue:阻塞队列,用来缓存提交但尚未执行的任务;
  • threadFactory:可自定义线程名称、守护状态等;
  • handler(拒绝策略):当线程池和队列都已满时,如何处理新的任务。

3.1 常用工作队列类型

  1. 无界队列 LinkedBlockingQueue

    • 构造示例:

      new ThreadPoolExecutor(nThreads, nThreads, 
                             0L, TimeUnit.MILLISECONDS,
                             new LinkedBlockingQueue<>());
      
    • 特点:无限制容量,不会触发 maximumPoolSize;所有任务都排队等待,可能导致内存使用过大。

    • 适用场景:任务总量可控、内存充足,且希望核心线程数固定。

  2. 有界队列 ArrayBlockingQueueLinkedBlockingQueue(capacity)

    • 构造示例:

      new ThreadPoolExecutor(coreSize, maxSize, 
                             1, TimeUnit.HOURS,
                             new ArrayBlockingQueue<>(100));
      
    • 特点:队列容量固定,若队列满后才会创建非核心线程;能平滑控制任务量与线程数。

    • 适用场景:既要防止过度排队占内存,又要允许峰值并发时扩充线程。

  3. SynchronousQueue(不存储任务)

    • 构造示例:

      new ThreadPoolExecutor(0, Integer.MAX_VALUE, 
                             60, TimeUnit.SECONDS,
                             new SynchronousQueue<>());
      
    • 特点:提交任务时,必须有空闲线程立即接收,否则创建新线程;没有队列缓存,每个任务要么立即执行,要么创建线程。

    • 适用场景:短平快任务,追求极低延迟;但需谨慎控制最大线程数,以免爆发大量线程导致 OOM。

3.2 常见拒绝策略(RejectedExecutionHandler

  • AbortPolicy(默认):抛出 RejectedExecutionException,由调用方捕获或让其冒泡失败;
  • CallerRunsPolicy:将任务回退到调用线程执行,既不会抛弃也不会爆错,但会导致调用者线程被阻塞;
  • DiscardPolicy:直接静默丢弃任务,不抛异常,不占用调用线程;
  • DiscardOldestPolicy:丢弃队列中最旧的任务,然后尝试将新任务加入队列;

选型要点

  1. 业务是否能容忍丢失任务?

    • 关键业务(如订单处理、支付)一般倾向于让请求快速失败,让上层有补偿机制;
    • 非关键异步任务可以选用丢弃策略,降低压力。
  2. 是否希望“退而求其次”CallerRunsPolicy)?

    • 若调用者线程可短暂承担执行压力,且能承受更长响应时延,可考虑;

3.3 线程池示例

// 核心池大小 = 50,最大池大小 = 100,队列容量 = 200
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    50, 100,
    60, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(200),
    new ThreadFactoryBuilder().setNameFormat("async-worker-%d").build(),
    new ThreadPoolExecutor.AbortPolicy()
);
  • 分析

    • 常住线程 50,当并发任务超过 50 时,先往队列(200)排队;
    • 队列也满时,再扩展到最大线程数 100;
    • 若 100 线程 + 200 队列都饱和,则触发抛弃策略,让后续调用方捕获异常;
    • 非核心(50~100)空闲 60s 后回收。

4. SpringBoot 中的异步执行

SpringBoot 提供了极简的方式去执行异步任务:

  1. 在主类上启用异步支持

    @SpringBootApplication
    @EnableAsync
    public class Application {}
    
  2. 在需要异步的方法上添加 @Async 注解

    @Service
    public class MyService {
        @Async("myThreadPool")
        public CompletableFuture<String> fetchData() {
            // 模拟耗时调用
            Thread.sleep(1000);
            return CompletableFuture.completedFuture("OK");
        }
    }
    
  3. 自定义线程池 ThreadPoolTaskExecutor

    @Configuration
    public class AsyncConfig {
    
        @Bean("myThreadPool")
        public ThreadPoolTaskExecutor myThreadPool() {
            ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
            executor.setCorePoolSize(20);
            executor.setMaxPoolSize(50);
            executor.setQueueCapacity(100);
            executor.setKeepAliveSeconds(30);
            executor.setThreadNamePrefix("async-worker-");
            executor.initialize();
            return executor;
        }
    }
    
  4. 调用异步方法

    @Autowired
    private MyService myService;
    
    public void process() {
        // 立即返回,不阻塞调用线程
        CompletableFuture<String> future = myService.fetchData();
        // 后续可以 .thenAccept()、.get(timeout) 等处理结果
    }
    

注意

  • 默认 @Async 若不指定 Executor 名称,会使用 Spring 提供的单例的 SimpleAsyncTaskExecutor,该 Executor 不做线程复用,新任务直接创建新线程,长期来看会导致线程暴增,不推荐
  • 自定义 ThreadPoolTaskExecutor 时,同样要参考上文“线程池设计”部分,避免无限制队列或过高的最大线程数;
  • 当异步方法需要返回结果且捕获异常时,应使用 CompletableFutureListenableFuture,否则默认抛出的异常会静默丢失。

5. 线程安全与常见并发陷阱

并发编程的最大挑战在于:多线程同时访问共享状态会出现竞态、可见性、重排序等问题。下面盘点一些常见的面试高频陷阱与注意点。

5.1 非线程安全集合示例

  • HashMap

    • 不是线程安全的,多线程并发 put/get 会造成数据丢失、死循环、甚至 CPU 100% 占用(HashMap 链表结构循环指向)。
    • 解决:使用 ConcurrentHashMap
  • ArrayList

    • 并发插入时可能触发扩容与内部数组复制,若不加锁会导致 IndexOutOfBoundsException 或数据覆盖。
    • 解决:使用 CopyOnWriteArrayList 或外层同步。

5.2 SimpleDateFormat 线程安全问题

SimpleDateFormat 不是线程安全的,内部维持可变的 Calendar 对象。并发调用 format()/parse() 会导致输出混乱、抛出 ArrayIndexOutOfBoundsException 等。

public class DateFormatDemo {
    private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100; i++) {
            executor.execute(() -> {
                System.out.println(SDF.format(new Date()));
            });
        }
        executor.shutdown();
    }
}

解决方案

  1. 为每个线程关联一个 SimpleDateFormat

    private static final ThreadLocal<SimpleDateFormat> SDF_TL = ThreadLocal.withInitial(
        () -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
    );
    // 使用: SDF_TL.get().format(date);
    
  2. 使用 JDK8+ 的 java.time.format.DateTimeFormatter,它天然线程安全;

5.3 异常丢失问题:submit() vs execute()

  • execute(Runnable)

    • 如果任务运行时抛出未捕获异常,会触发 ThreadPoolExecutor.afterExecute(),若未重写,异常会打印到控制台,程序继续运行;
  • submit(Runnable) / submit(Callable)

    • 返回 Future<?> 对象;若任务抛异常,不会自动打印,需要在后续调用 future.get() 时才能看到 ExecutionException
    • 若开发者未调用 get(),异常会静默丢失,排错困难。

建议

  • 如果希望及时发现任务异常,可使用 execute()
  • 若必须使用 submit()(需要 Future 返回值),应在调用者处定期 future.get() 或为线程池自定义 ThreadFactory + UncaughtExceptionHandler

5.4 ParallelFetcher 示例的常见陷阱

  1. 结果 Map 必须使用线程安全集合,如 ConcurrentHashMap,否则并发 put 会丢失数据;
  2. CountDownLatch.await(timeout) 超时后,当前线程被唤醒,但还在执行的子任务不会被自动取消;若任务涉及网络 I/O,应为每个子任务单独设置连接超时或检查中断标志;
  3. 异常丢失:若子任务内部抛出 RuntimeException,若未在 runnable.run() 包裹 try/catch,会导致任务直接退出,且不会回倒计时;建议在 runnable.run() 内部捕捉所有异常,并在 finallycountDown(),保证倒计时

6. ThreadLocal 与 Netty FastThreadLocal

在多线程项目中,常用 ThreadLocal<T> 维护线程私有数据,以便在线程间传递上下文(如用户会话、事务信息等)。但其底层实现存有性能隐患:

6.1 ThreadLocal 底层:“开放寻址”数组

  • 每个 Thread 对象中都维护一个 ThreadLocalMap threadLocals

  • ThreadLocalMap 内部结构并未实现 java.util.Map,而是使用数组+开放寻址法存储键值对:

    • 数组长度通常比实际元素数大,通过简单哈希定位索引,若冲突则“线性探测”继续向后查找空位;
    • 查找与插入成本:在最坏情况下 O(n) 次探测。
  • 当线程中使用大量不同的 ThreadLocal 时,ThreadLocalMap 容易产生频繁的重哈希与探测,性能开销较高。

6.2 Netty FastThreadLocal 的优化

Netty 针对 ThreadLocal 的这一低效点,提出了 FastThreadLocal

  • 预先分配一个固定大小的 数组(称为 ‘indexed variable table’)

  • 每个 FastThreadLocal 在创建时分配一个全局唯一的 int 索引

  • 对应线程维护一个 InternalThreadLocalMap fastThreadLocals,该 map 仅是一个 Object[] indexedVariables 数组;

    • set/remove/get 时直接通过索引访问:indexedVariables[index] → O(1) 读写;
  • 极大减少了哈希探测、重哈希开销,适合 Netty 这一类底层高并发网络库频繁读写场景。

示例对比

// JDK ThreadLocal 写入
ThreadLocal<String> tl = ThreadLocal.withInitial(() -> "foo");
String val = tl.get(); // 底层需要 hash,然后开放寻址法查找

// Netty FastThreadLocal 写入
FastThreadLocal<String> ftl = new FastThreadLocal<>();
String val2 = ftl.get(); // 直接 indexedVariables[index]
  • 在多线程并发场景下,FastThreadLocal 读写性能优于 ThreadLocal,但会占用一点固定内存做数组;
  • 若应用本身并没有太多 ThreadLocal,且并发不特别极致,ThreadLocal 足矣;只有在追求每百万次操作极限性能时,才考虑 FastThreadLocal

7. 多线程常见问题盘点与解决思路

在工作与面试中,以下并发知识点尤其容易被考察:

  1. I/O 密集型 vs 计算密集型

    • I/O 密集型:大部分时间在等待数据库/网络响应,线程数可大于 CPU 核数;
    • 计算密集型:大部分时间在 CPU 计算,线程数应接近 CPU 核数。
  2. 线程安全集合

    非线程安全线程安全
    ArrayListCopyOnWriteArrayList
    HashMapConcurrentHashMap
    HashSetCopyOnWriteArraySet
    SimpleDateFormatDateTimeFormatter(Java8+)
    StringBuilderStringBuffer
  3. 异常丢失

    • executor.submit(...) 并不会自动打印异常,必须调用 Future.get() 才能捕获;
    • executor.execute(...) 中抛出的未捕获异常会进入 afterExecute,可借此定制日志。
  4. 任务取消

    • CountDownLatch.await(timeout) 只是唤醒等待线程,并不会中断子任务;
    • 若需真正取消,还必须让子任务自身在可中断状态下(如阻塞 I/O,检查 Thread.interrupted())及时退出。
  5. SimpleDateFormat 线程安全

    • 不是线程安全的,需用 ThreadLocal<SimpleDateFormat> 或者 DateTimeFormatter 替代;
  6. ThreadLocal 内存泄漏

    • 若线程池复用同一线程,又未及时调用 threadLocal.remove(),会导致下一次任务仍然拿到旧值;
    • 需在线程使用完成后显式 ThreadLocal.remove(),或在任务上下文结束时清理。
  7. 异常处理在循环任务中的重要性

    while (!Thread.currentThread().isInterrupted()) {
        try {
            // 处理业务逻辑
        } catch (Exception ex) {
            // 记录日志,避免循环直接中断
        }
    }
    
    • 如果循环内部的任意一次运行抛出未捕获 RuntimeException,就会退出循环,导致线程提前结束;
  8. 生产者—消费者模型

    • 使用 LinkedBlockingQueueArrayBlockingQueue 等超时 offer/poll,结合 ReentrantLockConditionSemaphore,实现线程安全、可控的任务队列;

8. 小结:并行编程的关键要点与注意事项

  1. 并行获取数据

    • 当单个接口需要汇总多个耗时调用时,串行执行常常会超过 SLA,必须并发多线程调用;
    • 使用 CountDownLatchExecutorService 等工具类,让代码更简洁可靠;
    • 尽量为每个并行任务设置单独的“调用超时时间”,避免单个子任务无限等待拖垮整体。
  2. 线程池设计与调优

    • 明确业务类型:I/O 密集型 vs 计算密集型,分别估算合适线程数;

    • 选择合理的工作队列

      • 无界队列:不会触发扩容,但易 OOM;
      • 有界队列:平滑过载,但需要配置队列容量与最大线程数;
      • SynchronousQueue:零队列,适合实时性要求高的任务;
    • 拒绝策略应根据业务可容忍方案选取:快速失败、回退到调用线程或丢弃策略。

  3. SpringBoot 异步任务

    • 使用 @Async 只需要加三行注解就能开启异步,但务必提供自定义线程池,避免默认无限线程浪涌;
    • 异步方法若需要返回值,应使用 CompletableFutureListenableFuture 等,及时捕获并处理异常;
  4. 线程安全&常见陷阱

    • 并发环境首选“线程安全集合”与“不可变对象”避免竞态;
    • SimpleDateFormat 必须在每个线程独立实例或用 DateTimeFormatter
    • submit() 提交任务异常不会自动打印,需显式调用 Future.get() 或自定义 UncaughtExceptionHandler
  5. ThreadLocal vs FastThreadLocal

    • ThreadLocal 基于“开放寻址”数组查找,少量 ThreadLocal 场景开销可忽略;
    • Netty FastThreadLocal 用预分配数组 + 索引的方式,保证了 O(1) 访问,适合高并发量底层网络库
  6. 并发设计习惯

    • 循环任务必须捕获所有异常,避免线程意外退出;
    • 并发代码中尽量使用简单易懂的并发原语(例如 CountDownLatchSemaphore),避免手写 wait/notify
    • 注意内存泄漏ThreadLocal 在线程池环境里一定要手动 remove(),防止后续任务读取到旧值;
    • 并发场景下绝不使用非线程安全的共享对象;

通过掌握以上要点,开发者能够在多核多线程时代更自信地编写并行代码,既满足业务响应时长要求,又能避免常见并发陷阱。


  • 建议

    1. 尽量用 CountDownLatchCyclicBarrierCompletableFuture 等高层原语封装并行逻辑,避免手写 Thread/wait-notify
    2. 线程池设计要基于业务类型(I/O vs 计算),合理配置核心/最大线程数、队列与拒绝策略;
    3. 异步任务要自定义线程池,并使用 CompletableFuture 捕获异常,避免提交后“异常静默丢失”;
    4. 多线程环境下常见集合与日期类并发陷阱要牢记:一定使用线程安全版本或可见性安全的替代品;
    5. 理解 ThreadLocal 底层实现局限,仅在必要时使用;若对性能要求极致,可考虑 Netty FastThreadLocal

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小小工匠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值