CompletableFuture还能这么玩(下)

2,736 阅读22分钟

前言

当我决定写这篇关于 CompletableFuture 的文章时,脑海中浮现出无数个曾经被异步编程折磨得死去活来的瞬间。

所以我希望能够用通俗且有趣的方式,帮列位看官逐步掌握这个Java异步编程的终极武器:CompletableFuture

同时这篇文章,会尽可能的将知识点切成碎片化,不用看到长文就头痛。

坦白说,这篇文章确实有点长!一口气读完还是有点费劲。所以,我决定把它拆分为两篇:

第一篇,主打基础和进阶,深入浅出,这是传送门

第二篇,重点在高级特性上,比如多任务编排等,还会围绕实战和性能优化展开讲讲。

无论你是Java新手还是资深开发,相信都能在这里找到值得学习的干货。

耐心看完,你一定有所收获。

加油啊.gif

正文

5. 多任务编排进阶

在实际开发中,我们经常需要处理多个异步任务。有时候需要等所有任务都完成,有时候只要其中一个完成就行。 CompletableFuture为我们提供了两个强大的工具:allOf()和anyOf()。

allOf:等待所有任务完成

先了解一下CompletableFuture.allOf()的基本用法。它接收多个CompletableFuture作为参数,返回一个新的CompletableFuture,这个新的Future会在所有任务都完成时才完成。

CompletableFuture<Void> allOf = CompletableFuture.allOf(
    future1, future2, future3
);

听起来有点抽象?举个例子来理解。

比方说你在准备一顿丰盛的午饭。你需要煮饭、炒菜、煲汤,这些任务可以同时进行,但午饭必须等所有菜品都准备好才能开始。

用代码来表达就是:

CompletableFuture<String> cookRice = CompletableFuture
    .supplyAsync(() -> {
        // 模拟煮饭
        sleep(1000);
        return "饭已就位";
    });

CompletableFuture<String> stirFry = CompletableFuture
    .supplyAsync(() -> {
        // 模拟炒菜
        sleep(2000);
        return "炒完了";
    });

CompletableFuture<String> makeSoup = CompletableFuture
    .supplyAsync(() -> {
        // 模拟煲汤
        sleep(3000);
        return "汤煮好了";
    });

// 等待所有任务完成
CompletableFuture.allOf(cookRice, stirFry, makeSoup)
    .thenRun(() -> {
        System.out.println("所有准备工作已完成,可以开始吃饭了!");
    });

这段代码中:

  • 每个任务都是独立的CompletableFuture
  • 通过allOf()将它们组合在一起
  • thenRun()是在所有任务完成后,执行回调
  • 三个任务是并行执行的,总耗时则取决于最慢的那个任务

anyOf:只要有一个任务完成

CompletableFuture.anyOf()则是另一种场景:只要有一个任务完成就可以继续。

CompletableFuture<Object> anyOf = CompletableFuture.anyOf(
    future1, future2, future3
);

这就像什么呢?设想你在等网约车,同时打开了滴滴、高德和美团三个平台叫车。只要有一个平台接单了,你就可以出发。这就是CompletableFuture.anyOf()的典型场景。

CompletableFuture<Object> taxi = CompletableFuture.anyOf(
    callDidi(),         // 叫滴滴
    callGaode(),        // 叫高德
    callMeituan()       // 叫美团
);

taxi.thenAccept(platform -> System.out.println(platform + "接单了,出发!"));

通过anyOf()接收多个CompletableFuture后,返回最先完成的任务结果,虽然其他任务可能仍在继续执行,但结果会被忽略。

实战案例:并行请求多个微服务

理解了基本概念,让我们来看一个比较典型的业务场景:商品详情页。

假设你正在开发一个电商平台的商品详情页,需要同时获取:

  • 商品基本信息
  • 库存数据
  • 价格信息
  • 用户评价 这些数据分别来自不同的微服务。如果串行请求这些服务,页面加载会非常慢,如果使用CompletableFuture,就可以优雅地实现并行请求:
public ProductDetailVO getProductDetail(Long productId) {
    // 1. 创建多个异步任务
    CompletableFuture<ProductInfo> productFuture = CompletableFuture
        .supplyAsync(() -> productService.getProductInfo(productId));
        
    CompletableFuture<Stock> stockFuture = CompletableFuture
        .supplyAsync(() -> stockService.getStock(productId));
        
    CompletableFuture<Price> priceFuture = CompletableFuture
        .supplyAsync(() -> priceService.getPrice(productId));
        
    CompletableFuture<List<Review>> reviewsFuture = CompletableFuture
        .supplyAsync(() -> reviewService.getReviews(productId));

    // 2. 等待所有数据都准备好
    return CompletableFuture.allOf(
            productFuture, stockFuture, priceFuture, reviewsFuture)
        .thenApply(v -> {
            // 3. 组装最终结果
            return new ProductDetailVO(
                productFuture.join(),  // 获取每个任务的结果
                stockFuture.join(),
                priceFuture.join(),
                reviewsFuture.join()
            );
        }).join();  // 4. 等待最终结果
}

让我们解读一下上面这段代码:

  1. 首先,我们为每个微服务调用创建了一个异步任务
  2. 使用allOf()等待所有任务完成
  3. thenApply()组装最终结果
  4. 最后用join()获取组装好的完整的商品数据

这样做的好处是显而易见的:

  1. 四个请求并行执行,减少了总耗时
  2. 代码结构清晰,易于维护
  3. 异常处理可以统一管理
  4. 扩展性好,随时可以添加新的数据源

小贴士

在实际项目中,记得加上超时控制和异常处理。毕竟在分布式环境下,网络请求随时可能失败。可以使用orTimeout()completeOnTimeout()来优雅地处理超时情况。

// 为每个请求添加3秒超时
CompletableFuture<ProductInfo> productFuture = CompletableFuture
    .supplyAsync(() -> productService.getProductInfo(productId))
    .orTimeout(3, TimeUnit.SECONDS)  // 超时控制
    .exceptionally(ex -> {
        log.error("获取商品信息失败", ex);
        return new ProductInfo();  // 返回默认值
    });

这样,即使某个服务出现问题,也不会影响整个页面的展示。

6. 性能优化与实战技巧

避免常见的性能陷阱

默认线程池的问题
// 不推荐:使用默认的ForkJoinPool
// 问题:共享的ForkJoinPool可能被其他任务占满,导致你的任务无法及时执行
CompletableFuture.supplyAsync(() -> doSomething());

// 推荐:指定自定义线程池
// 优势:可控的线程数量,避免资源争抢,便于监控和管理
ExecutorService executor = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> doSomething(), executor);

默认的ForkJoinPool是JVM级别共享的,线程数通常等于CPU核心数-1。在高并发场景下,如果有大量CPU密集型任务,可能会导致线程饥饿。例如:

// 实际案例:模拟订单处理系统
public class OrderProcessor {
    // 错误示范
    public CompletableFuture<OrderResult> processOrder(Order order) {
        return CompletableFuture.supplyAsync(() -> validateOrder(order))
            .thenApply(this::calculatePrice)
            .thenApply(this::applyDiscount);
    }
    
    // 优化版本
    private final ExecutorService orderExecutor = Executors.newFixedThreadPool(
        20,
        new ThreadFactoryBuilder()
            .setNameFormat("order-processor-%d")
            .setDaemon(true)
            .build()
    );
    
    public CompletableFuture<OrderResult> processOrder(Order order) {
        return CompletableFuture.supplyAsync(() -> validateOrder(order), orderExecutor)
            .thenApply(this::calculatePrice)
            .thenApply(this::applyDiscount);
    }
}
避免不必要的线程切换
// 反模式:过度使用异步转换
CompletableFuture.supplyAsync(() -> fetchUserData())     // 线程A
    .thenApply(user -> enrichUserData(user))             // 可能切换到线程B
    .thenApply(user -> formatUserData(user))             // 可能切换到线程C
    .thenAccept(user -> saveToCache(user));              // 可能切换到线程D

// 最佳实践:合理规划异步边界
CompletableFuture.supplyAsync(() -> fetchUserData(), ioExecutor)         // IO操作使用IO线程池
    .thenApplyAsync(user -> {
        // 计算密集型操作使用计算线程池
        user = enrichUserData(user);
        user = formatUserData(user);
        return user;
    }, computeExecutor)
    .thenAccept(user -> saveToCache(user));                              // 与上游使用相同线程

每次线程切换都会带来上下文切换开销,包括:

  • 保存当前线程的执行状态
  • 加载新线程的执行状态
  • 可能的CPU缓存失效
  • 线程调度开销
  1. 合理处理异常
// 不推荐:简单的异常处理
completableFuture.exceptionally(ex -> {
    log.error("操作失败", ex);
    return null;  // 丢失了异常上下文
});

// 最佳实践:结构化的异常处理
public class Result<T> {
    private final T data;
    private final boolean success;
    private final String errorCode;
    private final String errorMessage;
    
    // ... 构造方法和访问器
}

CompletableFuture<Result<UserData>> future = CompletableFuture
    .supplyAsync(() -> fetchUserData())
    .handle((data, ex) -> {
        if (ex != null) {
            log.error("获取用户数据失败", ex);
            if (ex instanceof TimeoutException) {
                return Result.error("TIMEOUT", "服务调用超时");
            } else if (ex instanceof IllegalArgumentException) {
                return Result.error("INVALID_PARAM", "参数错误");
            }
            return Result.error("SYSTEM_ERROR", "系统异常");
        }
        return Result.success(data);
    });

CompletableFuture的性能优化

批量任务

在实际业务场景中,我们经常需要并行处理大量任务,比如批量发送消息、批量处理订单等。但简单地将所有任务转换为CompletableFuture并行执行可能会带来以下问题:

  • 线程资源消耗过快,甚至耗尽
  • 内存压力过大
  • 系统CPU占用过高,负载陡增

这里提供一个BatchProcessor作为参考,先看代码:

// 不推荐:直接转换为CompletableFuture列表
List<CompletableFuture<Result>> futures = tasks.stream()
    .map(task -> CompletableFuture.supplyAsync(() -> task.execute()))
    .collect(Collectors.toList());

// 推荐:控制并发度
public class BatchProcessor {
    private final ExecutorService executor;
    private final int maxConcurrency;
    
    // 优化版本:带有限流和监控的批处理执行器
    public <T> List<T> executeBatch(List<Supplier<T>> tasks) {
        Semaphore semaphore = new Semaphore(maxConcurrency);
        AtomicInteger activeTaskCount = new AtomicInteger(0);
        AtomicInteger completedTaskCount = new AtomicInteger(0);
        
        List<CompletableFuture<T>> futures = tasks.stream()
            .map(task -> CompletableFuture.supplyAsync(() -> {
                try {
                    semaphore.acquire();
                    activeTaskCount.incrementAndGet();
                    return task.get();
                } catch (Exception e) {
                    throw new CompletionException(e);
                } finally {
                    semaphore.release();
                    activeTaskCount.decrementAndGet();
                    completedTaskCount.incrementAndGet();
                }
            }, executor))
            .collect(Collectors.toList());
        
        // 添加批处理进度监控
        CompletableFuture.runAsync(() -> {
            while (completedTaskCount.get() < tasks.size()) {
                log.info("批处理进度: {}/{}, 当前活动任务数: {}",
                    completedTaskCount.get(), tasks.size(), activeTaskCount.get());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        });
        
        return futures.stream()
            .map(CompletableFuture::join)
            .collect(Collectors.toList());
    }
}

主要优化的点在于:

  • 通过Semaphore实现最大并发数限制,避免线程资源被用完
  • 通过计数器实时跟踪活动的任务数量和完成进度
  • 当系统负载较高时,通过信号量自动降低并发度
  • 提供简单的处理进度日志,便于问题定位和后续的诊断
超时控制

在分布式系统中,超时控制是保证系统稳定性的关键。但简单的超时策略有时也会有些问题,比如下面这些:

  • 无法应对网络抖动
  • 缺乏重试的机制
  • 固定的超时时间,不够灵活
  • 缺乏降级机制

这里提供一个TimeoutHandler作为优化方向的参考:

// 原始的写法:基础的超时控制
CompletableFuture<Result> future = CompletableFuture
    .supplyAsync(() -> doSomething())
    .orTimeout(3, TimeUnit.SECONDS);

public class TimeoutHandler {
    // 带有动态超时和重试的处理器
    public <T> CompletableFuture<T> executeWithSmartTimeout(
            Supplier<T> task,
            long timeout,
            TimeUnit unit,
            int maxRetries) {
        
        AtomicInteger retryCount = new AtomicInteger(0);
        
        CompletableFuture<T> future = new CompletableFuture<>();
        
        CompletableFuture.supplyAsync(() -> {
            while (retryCount.get() < maxRetries) {
                try {
                    CompletableFuture<T> attempt = CompletableFuture
                        .supplyAsync(task)
                        .orTimeout(timeout, unit);
                    
                    return attempt.get();
                } catch (TimeoutException e) {
                    log.warn("第{}次尝试超时", retryCount.incrementAndGet());
                    // 指数退避
                    Thread.sleep(Math.min(1000 * (1 << retryCount.get()), 10000));
                } catch (Exception e) {
                    throw new CompletionException(e);
                }
            }
            throw new TimeoutException("重试次数耗尽");
        }).handle((result, ex) -> {
            if (ex != null) {
                future.completeExceptionally(ex);
            } else {
                future.complete(result);
            }
            return null;
        });
        
        return future;
    }
}

这个TimeoutHandler还是比较有特点的,但也有不少优化空间,这里属于抛砖引玉。

来看下它的优点:

  • 支持配置最大重试次数
  • 采用指数退避算法,避免立即重试对系统造成冲击
  • 每次重试都有独立的超时控制,实现了基础的隔离
  • 支持自定义降级策略,可以对结果进行降级处理

也提供一个简单的示例:

TimeoutHandler handler = new TimeoutHandler();
CompletableFuture<UserProfile> future = handler.executeWithSmartTimeout(
    () -> remoteService.getUserProfile(userId),
    500, // 500ms超时
    TimeUnit.MILLISECONDS,
    3    // 最多重试3次
);
资源释放

这其实是个老生常谈的问题,与CompletableFuture关联不大,是整个异步编程领域都要面临的问题,这里也就扯一些闲篇。

在处理涉及数据库连接、网络连接等资源的异步任务时,常见问题包括:

  • 资源泄露
  • 释放时机不当
  • 异常情况下资源未释放
  • 缺乏统一的资源管理方案

针对上述问题,我们也提供一个ResourceAwareCompletableFuture,但也和上一节的参考类一致,这些优化都是抛砖引玉,不能一股脑用到生产上,也可能并一定适用于你的真实场景,必须要针对不同的业务去进行调整与修改。

public class ResourceAwareCompletableFuture<T> implements AutoCloseable {
    private final CompletableFuture<T> future;
    private final ExecutorService executor;
    private final List<AutoCloseable> resources;
    
    public ResourceAwareCompletableFuture(
            Supplier<T> supplier,
            ExecutorService executor,
            List<AutoCloseable> resources) {
        this.executor = executor;
        this.resources = resources;
        this.future = CompletableFuture.supplyAsync(() -> {
            try {
                return supplier.get();
            } finally {
                // 确保资源在任务完成后释放
                closeResources();
            }
        }, executor);
    }
    
    private void closeResources() {
        resources.forEach(resource -> {
            try {
                resource.close();
            } catch (Exception e) {
                log.error("关闭资源失败", e);
            }
        });
    }
    
    @Override
    public void close() {
        closeResources();
        if (!executor.isShutdown()) {
            executor.shutdown();
        }
    }
}

同样来看下这个优化的点:

  • 实现AutoCloseable接口,支持try-with-resources语法
  • 在任务完成后自动释放资源
  • 异常安全,即使发生异常也能保证资源释放
  • 具备良好的可扩展性,支持管理多个相关资源

给个使用示例:

try (ResourceAwareCompletableFuture<Data> future = new ResourceAwareCompletableFuture<>(
        () -> processWithConnection(connection),
        executor,
        Arrays.asList(connection, tempFile))) {
    Data result = future.get(5, TimeUnit.SECONDS);
    // 资源会在这里自动释放
}

7. 高级特性与实战案例

异步任务的取消与中断

让我们用一个生活中的例子:假设你正在点外卖。下单后,外卖小哥已经接单开始配送,这时你突然接到老板的紧急电话,说要马上去客户那里开会。没办法,只能忍痛取消订单了。这就很像我们在程序中需要取消一个正在执行的异步任务的情况。

CompletableFuture提供了 .cancel() 方法,用来方便地取消异步任务。

不过使用起来还是要注意一些细节,我们来看看具体代码:

// 不推荐的写法 - 没有合理的取消处理
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    try {
        Thread.sleep(5000);  // 假装在做一些耗时的事情
        return "任务完成";
    } catch (InterruptedException e) {
        return "任务被中断";
    }
});

// 推荐的写法 - 优雅地处理取消操作
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    try {
        for (int i = 0; i < 5; i++) {
	        // 随时检查是否被要求停止
            if (Thread.currentThread().isInterrupted()) {
                throw new CompletionException(new InterruptedException("任务被取消"));
            }
            Thread.sleep(1000);
        }
        return "任务完成";
    } catch (InterruptedException e) {
        throw new CompletionException(e);
    }
});

// 发出取消指令
boolean cancelled = future.cancel(true);

这里有几个要特别注意的点:

  1. cancel(boolean mayInterruptIfRunning) 方法返回boolean值,表示取消是否成功
  2. 一旦任务被取消,就像泼出去的水,覆水难收,后面的所有操作都不会执行了
  3. 所以在写任务的时候,一定要好好处理 InterruptedException,不然可能会留下隐患

死锁预防

这里的死锁和数据库的死锁概念不太一样,举个简单的例子:两个I人在微信上聊天,但是互相都在等对方先发消息,结果两个人都一直在等,谁也不主动,这就陷入了死锁。

来看看代码中的情况:

// 容易产生死锁的写法
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
    try {
        return future2.get();  // 等待future2的结果
    } catch (Exception e) {
        return "error";
    }
});

CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
    try {
        return future1.get();  // 等待future1的结果
    } catch (Exception e) {
        return "error";
    }
});

两个future互相等待,不可避免的产生了死锁问题。

要解决这个问题,最简单的办法就是设置一个等待超时时间。

// 推荐的写法 - 使用超时机制
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
    try {
        return future2.get(1, TimeUnit.SECONDS);  // 设置超时时间
    } catch (TimeoutException e) {
        return "超时";
    } catch (Exception e) {
        return "error";
    }
});

要预防死锁,记住这几点就够了:

  1. 别让任务互相等待对方,也就是避免循环依赖
  2. 设置合理的超时时间
  3. 用 thenCompose 来代替直接 get()
  4. 合理规划任务执行顺序,提前想好任务该怎么排队执行

实战案例:构建异步请求框架

让我们来做一个实用的例子:构建一个处理HTTP请求的异步框架。这个框架要能处理大量并发请求,还得优雅地处理超时和错误情况。

public class AsyncHttpClient {
    private final ExecutorService executor;
    private final int timeout;

    public AsyncHttpClient(int threadPoolSize, int timeoutSeconds) {
        this.executor = Executors.newFixedThreadPool(threadPoolSize);
        this.timeout = timeoutSeconds;
    }

    public CompletableFuture<String> asyncGet(String url) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                // 模拟HTTP请求
                if (Math.random() < 0.1) { // 10%概率模拟超时
                    Thread.sleep(timeout * 1000 + 1000);
                }
                return "Response from " + url;
            } catch (InterruptedException e) {
                throw new CompletionException(e);
            }
        }, executor)
        .orTimeout(timeout, TimeUnit.SECONDS)
        .exceptionally(throwable -> {
            if (throwable instanceof TimeoutException) {
                return "请求超时了:" + url;
            }
            return "请求出错:" + throwable.getMessage();
        });
    }

    // 批量处理请求
    public List<String> batchGet(List<String> urls) {
        List<CompletableFuture<String>> futures = urls.stream()
            .map(this::asyncGet)
            .collect(Collectors.toList());

        return futures.stream()
            .map(CompletableFuture::join)
            .collect(Collectors.toList());
    }

    public void shutdown() {
        executor.shutdown();
    }
}

这个小工具的特点是:

  1. 使用线程池管理并发请求
  2. 统一的超时处理机制
  3. 优雅的错误处理
  4. 支持批量请求处理
  5. 资源可控,不会造成内存泄露

看看使用示例:

AsyncHttpClient client = new AsyncHttpClient(10, 5);  // 10个线程,5秒超时
List<String> urls = Arrays.asList(
    "http://api1.example.com",
    "http://api2.example.com",
    "http://api3.example.com"
);

// 异步处理多个请求
List<String> results = client.batchGet(urls);
results.forEach(System.out::println);

client.shutdown();

8. 踩坑指南

常见误区与解决方案

除了前面讲到的性能陷阱,在实际开发中还有不少容易掉进去的坑。这里我挑几个最容易碰到的来聊一聊。(欢迎大家在评论区分享更多案例~)

异常处理的误区

说实话,异常处理可能是用 CompletableFuture 最容易翻车的地方了。很多开发者习惯了传统的 try-catch 模式,在使用 CompletableFuture 时容易忽略专门的异常处理。

最常见的问题是异常被静默吞掉:

// 错误示范
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    if (true) throw new RuntimeException("糟糕,出错了!");
    return "成功";
});
future.thenAccept(System.out::println);  // 这里异常被吞掉了,什么都看不到

这种情况下,如果发生异常,我们在日志中完全看不到任何错误信息,这对问题排查非常不利。正确的做法是要始终添加异常处理:

future
    .thenAccept(System.out::println)
    .exceptionally(throwable -> {
        System.err.println("捕获到异常:" + throwable.getMessage());
        return null;
    });
线程池管理问题

另一个常见的误区是对线程池的管理不当。创建线程池容易,但很多同学用完就扔,忘记关闭了。这可能导致应用无法正常退出或资源泄露。

比较靠谱的做法是把线程池交给一个专门的管理类来处理:

public class AsyncExecutor implements AutoCloseable {
    private final ExecutorService executor;
    
    public AsyncExecutor(int threadCount) {
        this.executor = Executors.newFixedThreadPool(threadCount);
    }
    
    public <T> CompletableFuture<T> submit(Supplier<T> task) {
        return CompletableFuture.supplyAsync(task, executor);
    }
    
    @Override
    public void close() {
        executor.shutdown();
        try {
            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
    }
}
混用 thenApply 和 thenCompose

最后要说的这个坑也挺有意思的。很多小伙伴分不清 thenApplythenCompose 该在什么时候用,用错了就容易把代码写得特别复杂。

简单来说:

  • thenApply 是做转换用的,比如把 A 转成 B
  • thenCompose 是用来组合另一个异步操作的,避免Future套Future

来看个例子:

// 错误示范:用 thenApply 导致嵌套的 Future
CompletableFuture<CompletableFuture<String>> nested = 
    CompletableFuture.supplyAsync(() -> "Hello")
        .thenApply(s -> CompletableFuture.supplyAsync(() -> s + " World"));

// 正确做法:用 thenCompose 保持链式调用
CompletableFuture<String> better = 
    CompletableFuture.supplyAsync(() -> "Hello")
        .thenCompose(s -> CompletableFuture.supplyAsync(() -> s + " World"));

小技巧

如果你的转换函数返回的是另一个 CompletableFuture,那就用 thenCompose,否则用 thenApply。这样代码就不会陷入"套娃"地狱了。

调试技巧

调试异步代码是比较麻烦的,因为执行流程不再是线性的,而是分散在多个线程中。这里介绍几个实用的调试技巧:

日志追踪

这是最简单粗暴的方法,在每个关键节点都添加日志,记得通过 MDC 加上统一的链路ID:

private static final Logger log = LoggerFactory.getLogger(AsyncTask.class);
    
public CompletableFuture<String> process(String taskId) {
	return CompletableFuture.supplyAsync(() -> {
		MDC.put("taskId", taskId);
		log.info("开始处理任务");
		try {
			// 业务逻辑
			return "处理结果";
		} finally {
			log.info("任务处理完成");
			MDC.remove("taskId");
		}
	});
}

建议把这些日志模板封装一下,这样团队的其他同学也能统一用:

public class AsyncTracer {
    public static <T> CompletableFuture<T> trace(String taskId, Supplier<T> task) {
        return CompletableFuture.supplyAsync(() -> {
            MDC.put("taskId", taskId);
            log.info("[{}] 任务开始", taskId);
            try {
                T result = task.get();
                log.info("[{}] 任务成功完成", taskId);
                return result;
            } catch (Exception e) {
                log.error("[{}] 任务失败: {}", taskId, e.getMessage());
                throw e;
            } finally {
                MDC.remove("taskId");
            }
        });
    }
}
使用 join() 进行断点调试

在开发环境下,有时候我们需要一步步跟踪代码执行。可以在关键节点使用 join() 方法:

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    // 这里可以打断点
    return "第一步";
}).thenApply(result -> {
    // 这里也可以打断点
    return result + "第二步";
});

// 在这里调用 join() 方法,方便断点调试
String finalResult = future.join();
使用 CompletableFuture.allOf() 调试并行任务

当有多个并行任务时,可以用 allOf() 来统一查看所有任务的执行情况:

List<CompletableFuture<String>> futures = Arrays.asList(
    task1(), task2(), task3()
);

CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
    .whenComplete((v, ex) -> {
        futures.forEach(f -> {
            try {
                log.info("任务结果: {}", f.getNow(null));
            } catch (Exception e) {
                log.error("任务异常: {}", e.getMessage());
            }
        });
    });

其实就记住一点:调试异步代码最重要的,就是得能够追踪到完整的执行链路,只要链路清晰,什么问题都好排查。

9. 拥抱虚拟线程

传统的线程就像餐厅里的服务员,每个服务员都需要专门的工作空间和配备(占用系统资源),所以餐厅不可能无限制地雇佣服务员。

而Java 21带来的虚拟线程,就像是餐厅引入了智能点餐系统,客人坐下来就点餐,不再受限于实体服务员的数量。

新的线程工厂:Thread.ofVirtual()

还是刚才的餐厅。

按传统的模式,餐厅需要事先规划好到底要雇佣多少服务员。雇太多,人工成本高;雇太少,顾客要排队等。

但是虚拟线程解决了这个问题。来看下虚拟线程怎么创建:

// 创建虚拟线程的线程工厂
// 就像采购了一套点餐系统
ThreadFactory virtualThreadFactory = Thread.ofVirtual().factory();

// 使用这个工厂创建执行器
// 相当于把系统部署到餐厅使用
ExecutorService executor = Executors.newThreadPerTaskExecutor(virtualThreadFactory);

Java还提供了更简便的方式,直接创建支持虚拟线程的执行器:

// 直接创建虚拟线程执行器
// 就像直接启用了整套智能点餐系统
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

针对餐厅这个例子,其实能预想到虚拟线程的几个特点:

  • 创建成本极低(就像在客户坐下后自己点餐,系统自动派单)
  • 占用资源少(不需要给每个订单都配备专门的服务员)
  • 特别适合等待型任务(比如等待厨房出餐、等待支付完成等)

CompletableFuture与虚拟线程的整合

把 CompletableFuture 和虚拟线程结合起来非常自然:

// 创建基于虚拟线程的执行器
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

// 使用这个执行器来处理CompletableFuture任务
CompletableFuture<String> orderTask = CompletableFuture.supplyAsync(() -> {
    // 模拟处理订单
    return "订单已确认";
}, executor);

这种组合的优势在于:

  • CompletableFuture提供了异步处理的框架
  • 虚拟线程提供了高效的执行能力
  • 两者结合,既能处理大量并发订单,又不会消耗过多系统资源

这就像餐厅里,每来一个订单,智能系统就会自动分配一个虚拟服务员(虚拟线程)来处理,而不用担心服务员数量不够的问题。

系统可以轻松应对午高峰数百个并发订单,因为虚拟线程的创建和切换成本极低。

性能对比:传统线程池 vs 虚拟线程

还是通过餐厅的例子,直观对比传统线程池和虚拟线程的性能差异。

想象一下两家餐厅在午餐高峰期的场景:

传统餐厅(传统线程池):

  • 固定数量的服务员(比如10名)
  • 每个服务员只能处理一个订单
  • 当所有服务员都在忙时,新顾客需要排队等待

智能餐厅(虚拟线程):

  • 智能点餐系统可以同时处理成百上千的订单
  • 无需担心“服务员数量”的限制
  • 新订单可以立即被接受处理

让我们用代码来模拟这两种场景:

public class RestaurantPerformanceTest {
    // 模拟传统线程池餐厅
    private static void traditionalRestaurant() {
        // 创建固定大小的线程池,就像雇佣10个服务员
        ExecutorService executor = Executors.newFixedThreadPool(10);
        
        long startTime = System.currentTimeMillis();
        // 模拟1000个订单
        for (int i = 0; i < 1000; i++) {
            executor.submit(() -> {
                try {
                    // 模拟每个订单处理需要100ms
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        executor.shutdown();
        // ... 计算处理时间
    }

    // 模拟使用虚拟线程的餐厅
    private static void virtualThreadRestaurant() {
        // 创建虚拟线程执行器,相当于启用智能点餐系统
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        
        long startTime = System.currentTimeMillis();
        // 同样模拟1000个订单
        for (int i = 0; i < 1000; i++) {
            executor.submit(() -> {
                try {
                    // 同样的处理时间
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        executor.shutdown();
        // ... 计算处理时间
    }
}

在实际性能测试中,两种方案的差异非常明显。

  1. 传统线程池(10个线程):
    • 总耗时约10秒
    • 内存占用较大
    • CPU频繁切换线程上下文
  2. 虚拟线程:
    • 总耗时约0.2秒
    • 内存占用小
    • 切换成本低

可以看下差在哪里:

  1. 传统线程池受限于固定的线程数量,但是虚拟线程没有这个困扰,可以轻松处理成千上万的并发任务。
  2. 传统线程每个都需要约1MB栈内存,但是虚拟线程只需要几KB
  3. 传统线程池一旦进入高负载,必然需要排队等待,虚拟线程几乎可以立即处理新请求

比较官方的说法是,虚拟线程通过其轻量级的特性和高效的调度机制,完美解决了传统线程池在高并发场景下的瓶颈问题。它不仅能够支持更多的并发任务,还能更好地利用系统资源。

隐患

虽然虚拟线程带来了很多好处,但在使用时也需要注意一些潜在的问题。

  • 阻塞操作
// 不推荐的做法
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        // 长时间的同步阻塞操作
        synchronized (lockObject) {
            Thread.sleep(1000);
            // 处理逻辑
        }
    });
}

虽然虚拟线程在I/O操作时能够高效切换,但在进行同步阻塞操作时,仍然会占用平台线程,影响整体性能。

  • 线程局部变量的内存泄漏
// 潜在的内存问题
public class ThreadLocalExample {
    private static final ThreadLocal<byte[]> threadLocal = 
        ThreadLocal.withInitial(() -> new byte[1024 * 1024]); // 1MB数据

    public void processWithVirtualThread() {
        // 大量创建虚拟线程,每个都使用ThreadLocal
        Thread.startVirtualThread(() -> {
            threadLocal.get(); // 获取线程局部变量
            // 处理逻辑
            // 忘记调用 threadLocal.remove()
        });
    }
}

虽然虚拟线程本身很轻量,但如果每个虚拟线程都持有大量的ThreadLocal数据,并且没有及时清理,仍然会导致内存问题。

最佳实践

在使用虚拟线程时,有几点建议:

  1. IO密集型任务优先考虑虚拟线程
// 适合虚拟线程的场景
CompletableFuture.supplyAsync(() -> {
    // 数据库查询、HTTP请求等IO操作
}, virtualExecutor);
  1. CPU密集型任务还是用传统线程池更好
// 计算密集型任务保持使用传统线程池
ExecutorService computeExecutor = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);
  1. 注意资源的及时释放
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 使用虚拟线程执行任务
} // 自动关闭执行器

虚拟线程给异步编程带来了新的活力,特别是在处理大量IO操作时。它让我们可以用更简单的方式实现高并发,而不用过多担心线程资源的问题。

不过诸君谨记,这不是一个万能药,要根据具体场景选择合适的方案。

寄语

写到这里,不知不觉已经写了这么多。回想起最初决定写这篇文章的初衷,就是希望能帮助更多的开发者们攻克异步编程这座"大山"。

CompletableFuture 确实是个强大的工具,但就像所有的编程工具一样,它不是万能的。关键在于我们要理解它的本质,知道在什么场景下使用它最合适。

记得我刚接触异步编程时,也是一头雾水,经常被各种回调和线程问题折腾得够呛。但随着不断实践和深入理解,渐渐发现异步编程的优雅之处。

这也是我写这篇文章的动力 —— 希望能让后来者少走一些弯路。

如果这篇文章能帮你理清哪怕一个概念,或解决一个困扰你的问题,那就达到了我写作的目的。

道阻且长,但有趣的是,我们每解决一个问题,就会遇到新的挑战。这正是技术进步的动力所在。

希望这篇文章能成为你掌握异步编程的一个起点,而不是终点。

最后,感谢你能耐心读到这里。如果你有任何问题或建议,欢迎留言交流。让我们在技术的道路上共同进步!

派大星.gif