虚拟线程调用外部服务时阻塞载体线程问题排查
你遇到的核心矛盾是:虚拟线程在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

