You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

虚拟线程调用外部服务时阻塞载体线程问题排查

问题分析与解决方案

你遇到的核心矛盾是:虚拟线程在Thread.sleep时能正常释放载体线程,但执行SMTP发送时却持续占用载体线程,导致只能并行处理与载体线程数相等的任务。以下是具体原因和解决办法:

一、核心原因

1. 虚拟线程的阻塞点识别逻辑

虚拟线程只有在执行JVM标记的标准阻塞点时才会被挂起,释放载体线程。Thread.sleep()是标准阻塞点,虚拟线程会立即被调度器挂起,载体线程可以转而处理其他虚拟线程。但如果SMTP发送的底层IO操作未被识别为阻塞点,虚拟线程会一直占用载体线程直到IO完成。

可能的触发场景:

  • Java版本过低:虚拟线程对阻塞Socket的自动挂起支持是Java 19(预览特性)/Java 21(正式特性)才引入的。如果生产环境使用Java 18或更低版本,阻塞Socket操作不会触发虚拟线程挂起,载体线程会被持续占用。
  • JavaMail底层IO未被识别:部分旧版本JavaMail实现可能使用了自定义阻塞逻辑(比如非标准的Socket包装),导致虚拟线程调度器无法识别阻塞点,无法挂起虚拟线程。

2. SMTP连接池限制

如果你的JavaMailSender配置了SMTP连接池,且连接池大小设为4(与生产环境载体线程数一致),那么即使有100个虚拟线程,也只能同时建立4个SMTP连接,剩余任务会排队等待连接,表现为4条一组处理,总耗时25秒。

3. 代码中的潜在优化点

你的BatchMessageListener中,forEach循环串行调用每个Future的get()方法:

messageProcessor.processMessages(messages)
        .forEach(processingFuture -> processFuture(processingFuture, channel));

虽然虚拟线程是并行提交的,但Listener线程会逐个等待每个Future完成,这会导致Listener线程被长时间占用,影响后续批量消息的消费,但这不是导致25秒耗时的核心原因。

二、解决方案

1. 升级Java版本到21+

确保生产环境使用Java 21或更高版本,虚拟线程对阻塞Socket的支持是正式特性,能自动识别SMTP发送中的阻塞IO操作,挂起虚拟线程并释放载体线程。

2. 调整SMTP连接池配置

检查Spring Mail的连接池配置,增大连接池大小以支持并行发送:

# 示例配置:设置连接池大小为100
spring.mail.properties.mail.smtp.connectionpool.size=100
spring.mail.properties.mail.smtp.connectionpool.timeout=30000

如果使用Jakarta Mail的连接池实现,需对应调整参数。

3. 优化Listener的等待逻辑

将串行等待改为并行等待,避免Listener线程被串行占用:

@Override
@MeasureExecutionTime
public void onMessageBatch(final List<Message> messages, final Channel channel) {
    List<MessageProcessingFuture> processingFutures = messageProcessor.processMessages(messages);
    // 将Future转换为CompletableFuture,并行处理所有ACK/REJECT逻辑
    CompletableFuture<Void> allDone = CompletableFuture.allOf(
            processingFutures.stream()
                    .map(f -> CompletableFuture.runAsync(() -> processFuture(f, channel), executor))
                    .toArray(CompletableFuture[]::new)
    );
    allDone.join(); // 等待所有任务处理完成
}

这里可以复用虚拟线程池处理ACK/REJECT逻辑,避免占用Listener线程。

4. 验证JavaMail的异步支持

如果升级Java版本后仍有问题,可尝试使用Jakarta Mail的异步发送API,确保IO操作是异步的,让虚拟线程能正确挂起:

// 替换MessageSender的同步发送为异步
public CompletableFuture<Void> sendMessageAsync(final RabbitEmailDto emailDto) {
    return CompletableFuture.runAsync(() -> {
        MimeMessage message = sender.createMimeMessage();
        message.setRecipients(Message.RecipientType.TO, emailDto.getTo());
        MimeMessageHelper helper = new MimeMessageHelper(message, CharEncoding.UTF_8);
        helper.setSubject(emailDto.getData().getEmail().getSubject());
        helper.setText(emailDto.getHtml(), true);
        helper.setFrom(emailDto.getFrom());
        sender.send(message);
    }, executor);
}

这样能确保发送操作在虚拟线程中执行,且IO阻塞时能释放载体线程。

三、本地测试与生产环境差异的原因

本地测试时用Thread.sleep(1000)模拟延迟,Thread.sleep是标准阻塞点,虚拟线程会立即挂起,载体线程可以处理其他虚拟线程,所以100条消息能在1秒内完成。而生产环境中SMTP发送的IO操作未触发虚拟线程挂起,导致载体线程被占用,只能并行处理4条消息。

内容的提问来源于stack exchange,提问作者Alex Kolokolov

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.25 21:34:54