Java 虚拟线程实战:打造一个网站存活雷达
Java 虚拟线程实战:打造一个网站存活雷达
zhangchangsheng假设你的博客友情链接越来越长,某天突然想知道:谁还在线,谁已经变成了数字废墟,谁只是慢得像在火星上托管?
逐个打开当然可以,但程序员更适合造一台“网站存活雷达”。这篇文章会使用 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 ─────────等待网络─────────> 完成 |
这让“一个任务一个线程”的同步写法重新具备很高的并发扩展能力。代码可以继续顺序书写,不必为了并发把业务流程拆成层层 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 |
3. 准备 JDK 21+
先确认运行环境:
java -version |
主版本至少应为 21。Executors.newVirtualThreadPerTaskExecutor() 在 Java 21 成为正式 API,因此这份代码不需要 --enable-preview。
如果电脑上同时装了多个 JDK,可以直接使用目标 JDK 的绝对路径,或者临时设置 JAVA_HOME。不要只看 IDE 的项目设置,终端里的 java 与 javac 也可能仍指向旧版本。
4. 完整代码
新建 WebsiteRadar.java:
import java.io.IOException; |
5. 编译与运行
代码只使用 JDK 标准库:
javac WebsiteRadar.java |
也可以传入自己的博客、友链或 API 健康检查地址:
java WebsiteRadar \ |
程序会按输入顺序打印结果。任务实际上同时执行,只是主线程依次读取 Future,所以某个排在前面的慢任务可能让后面的已完成结果晚一点显示;它不会让网络请求退化成串行。若需要“谁先完成就先显示”,可以进一步使用 ExecutorCompletionService。
用本地服务做确定性测试
网络状态和公共网站随时会变化,验证代码时不应依赖某个外站恰好在线。JDK 自带的 jwebserver 可以启动一个只用于测试的简单静态文件服务器:
mkdir radar-test |
保留这个终端,再打开另一个终端运行:
java WebsiteRadar \ |
你应该观察到一个 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。
生产实现至少需要:
- 优先使用域名白名单,而不是维护一份永远漏网的黑名单;
- 限制协议、端口、重定向次数和响应体大小;
- 检查域名解析得到的全部 IPv4/IPv6 地址,拒绝环回、链路本地、站点本地、私网和组播地址;
- 每次重定向都重新验证目标,不能只检查第一跳;
- 通过网络层出站规则阻断内部网段和元数据地址,防御 DNS 重绑定与应用校验竞态;
- 设置用户级配额、审计日志和总任务上限。
需要特别注意,本例的 HttpClient.Redirect.NORMAL 会自动处理部分重定向。如果 URL 来自不可信用户,生产版不应直接沿用这个设置,而应关闭自动重定向、逐跳读取 Location 并执行同样的安全验证。应用层校验与网络层隔离要一起做,不能把 SSRF 防护寄托在一个正则表达式上。
9. 可以继续怎么玩
这个小雷达很适合作为更多 Java 实验的起点:
- 读取 Hexo 友链配置,定时生成失效链接报告;
- 将结果输出为 JSON,再让博客页面绘制“友链星图”;
- 为每个域名设置独立
Semaphore,实现礼貌抓取; - 连续保存结果,计算可用率,而不是用一次失败判定站点永久离线;
- 使用
ScheduledExecutorService定时探测,但避免上一轮尚未结束时重叠执行; - 记录 JFR(JDK Flight Recorder),观察真实应用里的虚拟线程、I/O 和固定事件;
- 对大量动态任务引入背压和流式输入,控制排队任务的内存占用。
如果只是偶尔检查十几个链接,虚拟线程与普通线程都能完成工作,差别未必可见。它真正有价值的场景,是大量彼此独立、主要时间花在等待 I/O 的任务。网站雷达代码很短,却把现代 Java 的核心思路展示得很清楚:让线程表达任务,让专门的工具约束稀缺资源。
