虚拟线程用了一年,我真香了——10倍吞吐量是真实存在的

一年前我把线上服务的线程模型从线程池换成了虚拟线程,QPS从800涨到8500。今天我把这一年的实战经验和踩过的坑,全部告诉你。

大家好,我是卷毛。

去年Java 21正式发布虚拟线程后,我第一时间在两个项目上做了试点。一年下来,有真香的体验,也有血泪的教训。这篇文章,不吹不黑,只讲真实经历。


一、为什么要用虚拟线程?

先说结论:如果你的服务是I/O密集型的,虚拟线程能让你性能起飞。

传统线程池的问题:

java 复制代码
// 传统线程池 ------ 200个线程就是天花板
ExecutorService pool = Executors.newFixedThreadPool(200);

// 每个线程占用约1MB栈空间
// 200个线程 = 200MB内存
// 而且线程数一多,上下文切换的开销就很大
// 数据库连接池通常也就50-100个连接
// 200个线程抢100个连接,大部分时间在等待

虚拟线程的本质区别:

java 复制代码
// 虚拟线程 ------ 可以轻松创建几万个
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 同时提交10000个任务,毫无压力
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            // 每个任务都在自己的虚拟线程中运行
            // I/O阻塞时自动让出载体线程
            return fetchUserFromDB(i);
        });
    });
}

我的服务实测数据

makefile 复制代码
# 测试环境:4核8G,MySQL 8.0,Redis 7.0
# 接口:用户信息聚合查询(查DB + 查Redis + 调外部API)

# 线程池模式(200线程)
QPS: 812
平均响应时间: 245ms
P99响应时间: 890ms
CPU使用率: 45%
内存使用: 1.2GB

# 虚拟线程模式
QPS: 8,530  (10.5倍提升)
平均响应时间: 118ms
P99响应时间: 320ms
CPU使用率: 62%
内存使用: 1.5GB

为什么QPS能涨10倍? 因为虚拟线程在I/O阻塞时会自动让出载体线程给其他虚拟线程。200个平台线程可以承载几万个虚拟线程,I/O等待时间不再被浪费。


二、迁移实战:3步完成改造

Step 1:替换线程池

java 复制代码
// ❌ 改造前
@Bean
public ExecutorService orderExecutor() {
    return new ThreadPoolExecutor(
        20, 200, 60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(1000),
        new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(),
        new ThreadPoolExecutor.CallerRunsPolicy()
    );
}

// ✅ 改造后
@Bean
public ExecutorService orderExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

Step 2:Spring Boot配置开启虚拟线程

yaml 复制代码
# application.yml
spring:
  threads:
    virtual:
      enabled: true  # Tomcat使用虚拟线程处理请求

一行配置,Tomcat的请求处理线程就自动切换到虚拟线程了。

Step 3:处理synchronized代码块

这是最大的坑。虚拟线程在synchronized代码块中会pin住载体线程,无法让出。Java 21中这是一个已知问题,Java 24+已经修复了大部分pinning场景。

java 复制代码
// ❌ 有pinning风险(Java 21)
public synchronized User getUser(Long id) {
    // synchronized + I/O操作 = 载体线程被钉住
    return userDao.findById(id);  // 数据库查询阻塞时无法让出
}

// ✅ 改用ReentrantLock
private final Lock lock = new ReentrantLock();

public User getUser(Long id) {
    lock.lock();
    try {
        return userDao.findById(id);
    } finally {
        lock.unlock();
    }
}

检测pinning :启动时加JVM参数-Djdk.tracePinnedThreads=full,会打印所有pinning事件。


三、一年踩过的5个坑

坑1:数据库连接池被打满

虚拟线程下,并发量暴增,但数据库连接池还是原来的大小。

java 复制代码
// 问题:10000个虚拟线程同时查DB,连接池只有50个
// 结果:大量虚拟线程在等待连接,反而更慢

// 解决方案:合理设置连接池大小 + 使用Adaptive Lupos连接池
// HikariCP推荐配置
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(50);  // 不要盲目调大,DB扛不住
config.setMinimumIdle(10);
config.setConnectionTimeout(30000);

// 或者使用支持虚拟线程的连接池
// 如: AgroalDataSource(支持虚拟线程感知)

坑2:ThreadLocal内存爆炸

java 复制代码
// ❌ 每个虚拟线程都有自己的ThreadLocal副本
// 10万个虚拟线程 × 每个ThreadLocal存1KB = 100MB!

// ✅ 改用ScopedValue(Java 21+)
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();

ScopedValue.where(CONTEXT, userContext).run(() -> {
    userService.process();
});
// scope结束后自动清理,不会累积

坑3:synchronized + I/O 导致性能下降

java 复制代码
// ❌ 这个方法在高并发下会pin住载体线程
public synchronized void writeToCache(String key, String value) {
    redisClient.set(key, value);  // I/O操作在synchronized块内
}

// ✅ 用ReentrantLock替代
private final ReentrantLock cacheLock = new ReentrantLock();

public void writeToCache(String key, String value) {
    cacheLock.lock();
    try {
        redisClient.set(key, value);
    } finally {
        cacheLock.unlock();
    }
}

坑4:第三方库的隐式synchronized

java 复制代码
// 有些第三方库内部用了synchronized
// 比如: SimpleDateFormat, 某些JDBC驱动
// 解决: 查看库的文档是否支持虚拟线程
// 或升级到支持虚拟线程的版本

// 日期格式化 ------ 用DateTimeFormatter替代SimpleDateFormat
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// DateTimeFormatter是线程安全的,不需要synchronized

坑5:虚拟线程不是银弹

java 复制代码
// ❌ CPU密集型任务用虚拟线程,反而更慢
// 虚拟线程的优势在于I/O阻塞时让出载体线程
// CPU密集型任务不会阻塞,虚拟线程没有优势
// 反而多了调度开销

// ✅ CPU密集型任务继续用平台线程
ExecutorService cpuPool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);

// ✅ I/O密集型任务用虚拟线程
ExecutorService ioPool = Executors.newVirtualThreadPerTaskExecutor();

四、最佳实践总结

该用虚拟线程的场景

java 复制代码
// 1. HTTP请求处理(Spring Boot已内置支持)
// 2. 数据库查询
// 3. 调用外部API
// 4. 消息队列消费
// 5. 文件I/O操作

// 典型模式:聚合多个I/O调用
public UserDetailDTO getUserDetail(Long userId) {
    try (var scope = StructuredTaskScope.open()) {
        var userTask = scope.fork(() -> userService.findById(userId));        // DB查询
        var orderTask = scope.fork(() -> orderService.findByUserId(userId));   // DB查询
        var logTask = scope.fork(() -> logService.findByUserId(userId));       // DB查询
        var tagTask = scope.fork(() -> tagService.findByUserId(userId));       // Redis查询

        scope.join();

        return UserDetailDTO.builder()
            .user(userTask.get())
            .orders(orderTask.get())
            .logs(logTask.get())
            .tags(tagTask.get())
            .build();
    }
    // 4个查询并行执行,总耗时 ≈ 最慢的那个查询
    // 而不是串行执行的4个查询时间之和
}

不该用虚拟线程的场景

java 复制代码
// 1. CPU密集型计算
// 2. 需要限制并发的场景(如爬虫限流)
// 3. 依赖ThreadLocal累积的场景
// 4. 大量使用synchronized的老代码(未重构)

迁移检查清单

  • 排查所有synchronized + I/O的组合
  • 评估ThreadLocal的使用量和内存占用
  • 检查数据库连接池配置是否合理
  • 确认第三方库是否兼容虚拟线程
  • 添加pinning检测监控
  • 压测对比迁移前后的性能指标

五、性能监控

上线后用这几个指标持续监控:

java 复制代码
// 查看虚拟线程数量
ManagementFactory.getThreadMXBean().dumpAllThreads(true, true);

// 监控pinning事件(开发环境)
// JVM参数: -Djdk.tracePinnedThreads=short

// 使用Micrometer监控
@Bean
public MeterRegistry meterRegistry() {
    return new SimpleMeterRegistry();
}

// 监控线程数、QPS、响应时间
// 推荐配合Prometheus + Grafana看板

写在最后

虚拟线程是我这一年用得最爽的Java新特性,没有之一。但我也见过不少团队盲目上线翻车的------不是虚拟线程的问题,是没有做好迁移评估。

虚拟线程很强大,但它不是银弹。理解原理,评估场景,做好迁移,才能真正享受10倍吞吐量的红利。


📌 我是卷毛,9年Java开发,专注Java技术实战分享。

虚拟线程系列还会继续更新,包括结构化并发实战虚拟线程排障指南

关注《卷毛的技术笔记》,第一时间收到更新通知! 🔥

你在用虚拟线程了吗?踩过什么坑?评论区聊聊 👇

相关推荐
折哥的程序人生 · 物流技术专研1 小时前
第4篇:抽象工厂模式——产品族一键创建
java·设计模式·抽象工厂模式·创建型模式·编程进阶·产品族·扩充系列
正儿八经的少年1 小时前
Spring Boot
java·spring boot·后端
帅次1 小时前
Android 高级工程师面试:Flow 与状态流 近1年高频追问 22 题
android·面试·职场和发展
xiaogai_gai1 小时前
钉钉数据集成到金蝶云星空案例分享:传给钉钉后,回传金蝶字段
java·前端·钉钉
摇滚侠1 小时前
云原生 Java 架构师的第一课 K8s+Docker+KubeSphere+DevOps 19-25
java·云原生·kubernetes
标致的钢铁侠1 小时前
c# 温故而知新: 线程篇(一)
java·jvm·c#
宠友信息1 小时前
资源权限与钱包流水在即时通讯源码后端架构中的设计
java·spring boot·redis·websocket·缓存
不知名的老吴1 小时前
浅谈:编程语言中的那些「锁」事(二)
java·开发语言
程序员小八7772 小时前
AOP 切面
java