Java 虚拟线程实战:打造一个网站存活雷达

假设你的博客友情链接越来越长,某天突然想知道:谁还在线,谁已经变成了数字废墟,谁只是慢得像在火星上托管?

逐个打开当然可以,但程序员更适合造一台“网站存活雷达”。这篇文章会使用 JDK 21 正式提供的 Virtual Threads(虚拟线程),同时探测多个网址,记录 HTTP 状态、耗时和失败原因,最后列出最慢的几个站点。

整个示例只有一个 .java 文件,不依赖 Spring、第三方 HTTP 客户端或构建工具。它会用到:

  • java.net.http.HttpClient 发送同步 HTTP 请求;
  • Executors.newVirtualThreadPerTaskExecutor() 为每个探测任务创建一个虚拟线程;
  • Semaphore 限制真正同时访问外部网站的数量;
  • 连接超时与单次请求超时,避免任务永远挂起;
  • record 保存不可变的探测结果。

本文资料按 2026-09-17 的官方状态核对。Virtual Threads 已通过 JEP 444 在 JDK 21 正式交付,不再是 Preview Feature(预览特性);JDK 24 又通过 JEP 491 改善了 synchronized 导致的固定问题,后文会准确区分版本。

1. 先理解:虚拟线程不是“更快的线程”

传统 Platform Thread(平台线程)通常与操作系统线程一一对应。操作系统线程是有限资源,线程栈、调度和上下文切换都有成本,因此过去经常用固定大小的线程池复用它们。

虚拟线程仍然是 java.lang.Thread,但由 JDK 调度。它运行时会挂载到一个称为 Carrier Thread(载体线程)的平台线程上;当它在支持的 I/O 操作中阻塞时,JDK 可以暂停并卸载这个虚拟线程,让载体去执行其他任务。

可以把二者想象成:

平台线程:任务 A ─────────等待网络─────────> 完成
整条 OS 线程被占用

虚拟线程:任务 A ──等待网络──┐
├─ 同一个载体线程继续执行 B、C……
网络就绪后 A <──┘

这让“一个任务一个线程”的同步写法重新具备很高的并发扩展能力。代码可以继续顺序书写,不必为了并发把业务流程拆成层层 CompletableFuture。

但要注意,Oracle 的 Virtual Threads 指南明确强调:虚拟线程提供的是 scale(扩展能力、吞吐量),不是让一条请求获得更低延迟。一个网站原本需要 800 ms 才响应,换成虚拟线程不会把它变成 80 ms;好处是等待这 800 ms 时,昂贵的平台线程可以去处理别的任务。

它最适合大量“等待多、计算少”的工作,例如 HTTP、数据库和文件 I/O。本例正好属于这种场景。

2. 我们要做出的雷达

程序接受若干 URL:

java WebsiteRadar https://example.com https://openjdk.org https://www.oracle.com

如果不传参数,它会使用代码中的三个演示地址。每个地址对应一个独立任务,最终输出三类结果:

  • OK:收到 200~399 响应;
  • HTTP_ERROR:服务器有响应,但状态码是 400~599;
  • FAILED:超时、DNS 失败、连接被拒绝或其他 I/O 异常。

这里把 3xx 归为健康,是因为客户端配置了正常重定向后,最终仍可能遇到合法重定向响应。实际生产规则应按业务定义:例如 API 健康检查通常只接受 200,而博客首页可能允许 301。

程序的并发结构如下:

输入 URL
│
├──虚拟线程 1──┐
├──虚拟线程 2──┤
├──虚拟线程 3──┼── Semaphore──> 共享 HttpClient──> 外部网站
└──虚拟线程 N──┘ 最多 8 个请求同时在途
│
└── ProbeResult──> 统计与慢站点排序

3. 准备 JDK 21+

先确认运行环境:

java -version
javac -version

主版本至少应为 21。Executors.newVirtualThreadPerTaskExecutor() 在 Java 21 成为正式 API,因此这份代码不需要 --enable-preview。

如果电脑上同时装了多个 JDK,可以直接使用目标 JDK 的绝对路径,或者临时设置 JAVA_HOME。不要只看 IDE 的项目设置,终端里的 java 与 javac 也可能仍指向旧版本。

4. 完整代码

新建 WebsiteRadar.java:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;

public class WebsiteRadar {
private static final int MAX_CONCURRENT_REQUESTS = 8;
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
private static final int SLOWEST_LIMIT = 3;

private static final List<String> DEFAULT_URLS = List.of(
"https://example.com/",
"https://openjdk.org/",
"https://www.oracle.com/"
);

private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NORMAL)
.build();

private final Semaphore permits = new Semaphore(MAX_CONCURRENT_REQUESTS);

public static void main(String[] args) {
List<String> rawUrls = args.length == 0
? DEFAULT_URLS
: Arrays.asList(args);

List<URI> targets;
try {
targets = rawUrls.stream()
.map(WebsiteRadar::parseHttpUri)
.toList();
} catch (IllegalArgumentException exception) {
System.err.println("参数错误:" + exception.getMessage());
System.exit(2);
return;
}

System.out.printf(
"准备探测 %d 个站点,最大 HTTP 并发数:%d,请求超时:%d 秒%n%n",
targets.size(),
MAX_CONCURRENT_REQUESTS,
REQUEST_TIMEOUT.toSeconds()
);

List<ProbeResult> results = new WebsiteRadar().scan(targets);
printResults(results);
printSummary(results);
}

private List<ProbeResult> scan(List<URI> targets) {
List<Future<ProbeResult>> futures = new ArrayList<>(targets.size());

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (URI target : targets) {
futures.add(executor.submit(() -> probe(target)));
}

List<ProbeResult> results = new ArrayList<>(targets.size());
for (int index = 0; index < futures.size(); index++) {
try {
results.add(futures.get(index).get());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
results.add(ProbeResult.failed(
targets.get(index),
0,
"主线程等待结果时被中断"
));
} catch (ExecutionException exception) {
Throwable cause = exception.getCause();
results.add(ProbeResult.failed(
targets.get(index),
0,
"任务异常:" + errorMessage(cause)
));
}
}
return results;
}
}

private ProbeResult probe(URI target) {
long startedAt = System.nanoTime();
boolean acquired = false;

try {
permits.acquire();
acquired = true;

HttpRequest request = HttpRequest.newBuilder(target)
.timeout(REQUEST_TIMEOUT)
.header("User-Agent", "WebsiteRadar/1.0")
.GET()
.build();

HttpResponse<Void> response = client.send(
request,
HttpResponse.BodyHandlers.discarding()
);

return ProbeResult.responded(
target,
response.statusCode(),
elapsedMillis(startedAt)
);
} catch (HttpTimeoutException exception) {
return ProbeResult.failed(
target,
elapsedMillis(startedAt),
"请求超时"
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return ProbeResult.failed(
target,
elapsedMillis(startedAt),
"探测任务被中断"
);
} catch (IOException exception) {
String detail = exception.getMessage();
String reason = exception.getClass().getSimpleName();
if (detail != null && !detail.isBlank()) {
reason += ":" + detail;
}
return ProbeResult.failed(
target,
elapsedMillis(startedAt),
reason
);
} finally {
if (acquired) {
permits.release();
}
}
}

private static URI parseHttpUri(String rawUrl) {
URI uri;
try {
uri = URI.create(rawUrl);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("不是合法 URL:" + rawUrl, exception);
}

String scheme = uri.getScheme();
if (scheme == null
|| !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))
|| uri.getHost() == null) {
throw new IllegalArgumentException("只接受包含主机名的 http/https URL:" + rawUrl);
}
return uri;
}

private static long elapsedMillis(long startedAt) {
return Duration.ofNanos(System.nanoTime() - startedAt).toMillis();
}

private static String errorMessage(Throwable throwable) {
String message = throwable.getMessage();
return message == null || message.isBlank()
? throwable.getClass().getSimpleName()
: message;
}

private static void printResults(List<ProbeResult> results) {
for (ProbeResult result : results) {
if (result.error() != null) {
System.out.printf(
"[FAILED ] ---- %6d ms %s (%s)%n",
result.elapsedMillis(),
result.uri(),
result.error()
);
} else {
String label = result.isHealthy() ? "OK" : "HTTP_ERROR";
System.out.printf(
"[%-10s] %4d %6d ms %s%n",
label,
result.statusCode(),
result.elapsedMillis(),
result.uri()
);
}
}
}

private static void printSummary(List<ProbeResult> results) {
long healthy = results.stream().filter(ProbeResult::isHealthy).count();
long httpErrors = results.stream().filter(ProbeResult::isHttpError).count();
long failures = results.stream().filter(result -> result.error() != null).count();

System.out.printf(
"%n汇总:总数 %d,健康 %d,HTTP 异常 %d,网络失败 %d%n",
results.size(), healthy, httpErrors, failures
);

List<ProbeResult> slowest = results.stream()
.filter(result -> result.error() == null)
.sorted(Comparator.comparingLong(ProbeResult::elapsedMillis).reversed())
.limit(SLOWEST_LIMIT)
.toList();

if (!slowest.isEmpty()) {
System.out.println("响应最慢的站点:");
for (int index = 0; index < slowest.size(); index++) {
ProbeResult result = slowest.get(index);
System.out.printf(
" %d. %6d ms %s%n",
index + 1,
result.elapsedMillis(),
result.uri()
);
}
}
}

private record ProbeResult(
URI uri,
int statusCode,
long elapsedMillis,
String error
) {
static ProbeResult responded(URI uri, int statusCode, long elapsedMillis) {
return new ProbeResult(uri, statusCode, elapsedMillis, null);
}

static ProbeResult failed(URI uri, long elapsedMillis, String error) {
return new ProbeResult(uri, -1, elapsedMillis, error);
}

boolean isHealthy() {
return error == null && statusCode >= 200 && statusCode < 400;
}

boolean isHttpError() {
return error == null && !isHealthy();
}
}
}

5. 编译与运行

代码只使用 JDK 标准库:

javac WebsiteRadar.java
java WebsiteRadar

也可以传入自己的博客、友链或 API 健康检查地址:

java WebsiteRadar \
https://example.com/ \
https://openjdk.org/ \
https://www.oracle.com/

程序会按输入顺序打印结果。任务实际上同时执行,只是主线程依次读取 Future,所以某个排在前面的慢任务可能让后面的已完成结果晚一点显示;它不会让网络请求退化成串行。若需要“谁先完成就先显示”,可以进一步使用 ExecutorCompletionService。

用本地服务做确定性测试

网络状态和公共网站随时会变化,验证代码时不应依赖某个外站恰好在线。JDK 自带的 jwebserver 可以启动一个只用于测试的简单静态文件服务器:

mkdir radar-test
echo "radar is alive" > radar-test/index.html
jwebserver -p 8000 -d radar-test

保留这个终端,再打开另一个终端运行:

java WebsiteRadar \
http://127.0.0.1:8000/ \
http://127.0.0.1:8000/not-found \
http://127.0.0.1:1/

你应该观察到一个 OK、一个 HTTP_ERROR 和一个 FAILED。耗时与异常文本取决于操作系统和当时环境,不应把一次输出当作性能基准。测试结束后在 jwebserver 所在终端按 Ctrl+C。

6. 逐段拆解关键设计

6.1 一个任务,一个虚拟线程

核心入口只有这一行:

Executors.newVirtualThreadPerTaskExecutor()

Java 21 Executors API 对它的定义是:每提交一个任务,就启动一个新的虚拟线程,线程数量本身不设上限。它不是“装有若干虚拟线程的池”。

因此,别把过去的习惯机械地写成 newFixedThreadPool(100) 再配虚拟线程工厂。虚拟线程很便宜,通常应该直接表示任务;真正稀缺的资源——远端服务容量、数据库连接、文件描述符和带宽——再分别限流。

ExecutorService 在 Java 21 实现了 AutoCloseable。离开 try-with-resources 时,close() 会等待已经提交的任务结束,因此示例不会在结果收集完之前丢掉执行器。

6.2 同步 send 反而更直观

代码调用:

client.send(request, HttpResponse.BodyHandlers.discarding());

send 是阻塞 API。对平台线程而言,大量请求同时阻塞通常意味着需要大量昂贵线程;对虚拟线程而言,这正是它擅长的使用方式。等待网络时,虚拟线程通常可以卸载,载体线程继续服务其他任务。

这里使用 BodyHandlers.discarding(),因为雷达只关心状态码,不保存响应正文。它会丢弃正文,但不等于取消正文的网络传输,因此不要拿它扫描超大下载地址。对自己控制的服务,最好提供响应很小的 /health;只有在确定站点正确支持 HEAD 时,才考虑把探测请求改成 HEAD。

HttpClient 构建后不可变,并能管理、复用连接,所以应在任务之间共享,而不是每探测一个 URL 就创建一个客户端。详见 Java 21 HttpClient API。

6.3 为什么虚拟线程还要 Semaphore

newVirtualThreadPerTaskExecutor() 不限制任务数。如果输入十万个 URL,它可以创建十万个虚拟线程;这不代表远端网站、NAT、DNS、文件描述符或你的网络出口也能承受十万个同时在途的请求。

所以代码用:

private final Semaphore permits = new Semaphore(MAX_CONCURRENT_REQUESTS);

每个任务先 acquire(),完成后在 finally 中 release(),确保最多只有 8 个任务进入真正的 HTTP 请求阶段。等待许可证的虚拟线程会被阻塞,但这正是轻量虚拟线程适合承载的等待。

这也是 Oracle 官方迁移指南推荐的思路:不要为了限流而池化虚拟线程,要使用 Semaphore 这类专门的并发控制工具。 如果底层已经有容量明确的资源池,例如最多 10 条连接的数据库连接池,它本身就承担了类似信号量的作用,通常不必再无脑叠一层相同限制。

生产环境还可以把一个全局信号量扩展成“每个域名单独限流”,避免某个站点占满全部配额。

6.4 两种超时解决不同问题

客户端级连接超时:

.connectTimeout(Duration.ofSeconds(3))

限制建立新连接时可以等待多久。请求级超时:

.timeout(Duration.ofSeconds(5))

为每个 HttpRequest 设置时间上限,超时会以 HttpTimeoutException 报告。两者不能互相替代:连接可能很快建立,但服务迟迟不返回完整响应;复用已有连接时,也可能根本不会发生新的连接过程。

相关定义可查看 HttpClient.Builder.connectTimeout、HttpRequest.Builder.timeout 与 HttpTimeoutException。

6.5 耗时必须使用单调时钟

示例用 System.nanoTime() 计算时间差,而不是用 System.currentTimeMillis()。后者代表墙上时钟,可能被系统校时调整;前者适合测量同一进程内的经过时间。

这只是一次探测的端到端观察值,包含等待 Semaphore、DNS、连接、TLS、重定向和响应正文传输,并不是严谨的 HTTP 性能基准。真正做性能测试时需要预热、重复采样、分位数统计以及隔离网络噪声,不能拿一轮 CLI 输出宣布某个框架“快了多少倍”。

7. 四个最容易误解的边界

7.1 虚拟线程不适合长期 CPU 密集任务

如果任务持续计算哈希、压缩视频或训练模型,它占用的是 CPU,而不是在等待 I/O。处理器有多少核心,同一时刻真正执行的 Java 代码就大致受多少核心约束;创建更多虚拟线程不会凭空增加算力,反而可能增加调度开销。

JEP 444 将“任务不属于 CPU-bound(CPU 密集)”列为获得吞吐收益的前提。CPU 密集工作仍应根据核心数使用合适的受限执行器,或交给专门的计算系统。虚拟线程可以调用少量计算代码,只是不该期待它加速长期占满 CPU 的任务。

7.2 ThreadLocal 能用,但不要拿它给每个线程缓存大对象

JDK 21 的虚拟线程完整支持 ThreadLocal,因此用于请求 ID、用户上下文等任务级信息通常没问题。

危险的是旧式“每个池线程缓存一个昂贵对象”:平台线程池可能只有几十个线程,所以只缓存几十份;而虚拟线程往往一任务一个,数量可能非常大,缓存就可能膨胀成一任务一份。优先共享线程安全的不可变对象,或让资源生命周期与任务/请求保持一致。官方指南也明确反对在虚拟线程的 ThreadLocal 中缓存昂贵的可复用对象。

7.3 synchronized 固定问题必须区分 JDK 版本

在 JDK 21~23 中,虚拟线程如果在 synchronized 方法或代码块内执行阻塞操作,可能被固定(pinned)在载体线程上。固定不会让结果自动出错,但频繁且长时间的固定会削弱扩展能力。旧教程因此常建议避免在 synchronized 内做慢 I/O,必要时使用 ReentrantLock。

但这条建议不能不加版本地照搬到今天:JDK 24 的 JEP 491 已让虚拟线程在持有监视器、等待获取监视器或执行 Object.wait() 时也能释放载体线程,消除了几乎所有由 synchronized 引起的固定。 因此在 JDK 24+,不需要仅仅为了虚拟线程就把正常的 synchronized 全部改成 ReentrantLock;应该根据公平性、可中断获取、条件变量等真实需求选择锁。

JEP 491 也说明仍存在少量与 Native Code(本地代码)、Foreign Function 调用以及某些类加载/初始化路径有关的固定场景。无论版本如何,缩小锁范围、避免持锁做慢 I/O 仍是降低竞争的好习惯。

本例没有在 synchronized 块中发送 HTTP 请求,因此在 JDK 21 上也不触发这个旧问题。

7.4 创建得起线程,不代表承受得起全部任务

虚拟线程比平台线程便宜,不等于零成本。每个任务仍有对象、栈帧、结果和外部资源。本例会一次性保存所有 Future 和结果,适合博客友链这类有限列表;面对百万级输入,应分页读取、分批提交和持续写出结果,避免一次把全部状态留在内存中。

8. 如果把它做成在线服务,先处理 SSRF

当前 CLI 假定 URL 来自操作者本人,只做了协议和主机名检查。这足以防止误传 file: 等协议,但绝不是在线服务所需的安全校验。

如果把“输入一个 URL,我替你探测”暴露给互联网,攻击者可能借它访问:

  • 127.0.0.1、::1 和内网地址;
  • 云平台 Metadata Service(元数据服务);
  • 只允许服务器访问的管理后台;
  • 首次解析为公网、稍后又解析为内网的 DNS Rebinding(DNS 重绑定)域名;
  • 先指向允许站点、再通过重定向跳到内网的 URL。

生产实现至少需要:

  1. 优先使用域名白名单,而不是维护一份永远漏网的黑名单;
  2. 限制协议、端口、重定向次数和响应体大小;
  3. 检查域名解析得到的全部 IPv4/IPv6 地址,拒绝环回、链路本地、站点本地、私网和组播地址;
  4. 每次重定向都重新验证目标,不能只检查第一跳;
  5. 通过网络层出站规则阻断内部网段和元数据地址,防御 DNS 重绑定与应用校验竞态;
  6. 设置用户级配额、审计日志和总任务上限。

需要特别注意,本例的 HttpClient.Redirect.NORMAL 会自动处理部分重定向。如果 URL 来自不可信用户,生产版不应直接沿用这个设置,而应关闭自动重定向、逐跳读取 Location 并执行同样的安全验证。应用层校验与网络层隔离要一起做,不能把 SSRF 防护寄托在一个正则表达式上。

9. 可以继续怎么玩

这个小雷达很适合作为更多 Java 实验的起点:

  • 读取 Hexo 友链配置,定时生成失效链接报告;
  • 将结果输出为 JSON,再让博客页面绘制“友链星图”;
  • 为每个域名设置独立 Semaphore,实现礼貌抓取;
  • 连续保存结果,计算可用率,而不是用一次失败判定站点永久离线;
  • 使用 ScheduledExecutorService 定时探测,但避免上一轮尚未结束时重叠执行;
  • 记录 JFR(JDK Flight Recorder),观察真实应用里的虚拟线程、I/O 和固定事件;
  • 对大量动态任务引入背压和流式输入,控制排队任务的内存占用。

如果只是偶尔检查十几个链接,虚拟线程与普通线程都能完成工作,差别未必可见。它真正有价值的场景,是大量彼此独立、主要时间花在等待 I/O 的任务。网站雷达代码很短,却把现代 Java 的核心思路展示得很清楚:让线程表达任务,让专门的工具约束稀缺资源。

10. 官方参考资料