去年把公司订单服务重构了,用Java 21虚拟线程替换了CompletableFuture。代码简洁了很多,但上线第二天出了个并发bug,库存扣了两次。记录一下踩坑过程。
原来的代码
订单创建要调三个服务:库存校验、价格计算、优惠券验证。用CompletableFuture并行:

CompletableFuture<Stock> stockFuture = CompletableFuture.supplyAsync(() -> stockService.check(order));
CompletableFuture<Price> priceFuture = CompletableFuture.supplyAsync(() -> priceService.calc(order));
CompletableFuture<Coupon> couponFuture = CompletableFuture.supplyAsync(() -> couponService.validate(order));

CompletableFuture.allOf(stockFuture, priceFuture, couponFuture).join();

能跑,但代码像面条。三个Future,allOf等待,异常处理分散,超时控制麻烦。新同事看半天才能懂。
换成虚拟线程
Java 21虚拟线程,写同步代码享受异步性能:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<Stock> stockFuture = executor.submit(() -> stockService.check(order));
    Future<Price> priceFuture = executor.submit(() -> priceService.calc(order));
    Future<Coupon> couponFuture = executor.submit(() -> couponService.validate(order));
    
    stockFuture.get();
    priceFuture.get();
    couponFuture.get();
}

看着像同步代码,实际是三个虚拟线程并行执行。简洁多了,新同事一眼看懂。
bug怎么来的
库存服务用了ThreadLocal缓存用户上下文:

public class StockService {
    private static final ThreadLocal<UserContext> context = new ThreadLocal<>();
    
    public Stock check(Order order) {
        context.set(getUserContext());
        // ... 校验库存
        context.get(); // 取用户信息
    }
}

虚拟线程是挂载在平台线程(Carrier Thread)上的,一个平台线程可能运行多个虚拟线程。ThreadLocal的值在虚拟线程切换时不会自动清理,导致上下文串了。
场景:虚拟线程A设置context,执行一半让出,平台线程切到虚拟线程B,B设置新context,切回A时,A读到了B的context。
结果:用户A的订单,扣了用户B的库存权限。上线第二天,库存数据对不上,排查了四小时。
解决方案
不是改虚拟线程,是改ThreadLocal。Java 21引入了ScopedValue,虚拟线程安全:

private static final ScopedValue<UserContext> context = ScopedValue.newInstance();

public Stock check(Order order) {
    ScopedValue.where(context, getUserContext()).run(() -> {
        // ... 校验库存
        context.get(); // 虚拟线程安全
    });
}

但ScopedValue是预览特性,生产环境不敢用。最后方案:把ThreadLocal改成方法参数传递,彻底去掉隐式上下文。
重构成本
以为虚拟线程是"替换ExecutorService,代码更简洁"。实际要排查所有ThreadLocal、InheritableThreadLocal、同步锁的使用。
我们代码库搜了一下,ThreadLocal用了17处,InheritableThreadLocal用了3处,synchronized块用了200+处。虚拟线程和synchronized不冲突,但和固定平台线程的假设冲突。
排查+重构+测试,花了三周。比预期多两周。
现在的用法
虚拟线程用在无状态、无ThreadLocal、无复杂锁的服务:日志上报、消息推送、文件处理。这些服务逻辑简单,虚拟线程确实提升了吞吐量。
订单服务这种有状态、有上下文、有事务的,继续用线程池+CompletableFuture。代码丑点,但可控。
一句话总结
虚拟线程是好东西,但不是"替换"关系,是"新增"关系。适合的场景用,不适合的场景别硬上。评估成本时,要算上线程局部变量排查、锁模型审查、测试回归的时间。
我们花了三周学费,现在知道了:新技术先看边界,再看收益。

更多推荐