JDK 自带的 HttpClient 走 HTTP/2 时,如果用 BodyHandlers.ofInputStream(),拿到响应头后先歇一会儿再读 body,已经完整收到的数据会被丢掉,读出来是 IOException: closed,根因是 EOFException。官方复现挂在 JDK-8394607,2026-10-21 以 5c1c7c1 合进 OpenJDK 主线,目标构建是 JDK 28 b21。我在 JDK 21.0.12.1、JDK 27+35、JDK 28-ea+20 上都能复现失败;同一套脚本改成 ofByteArray() 或立刻读流就正常;带着修复的 28-internal(SOURCE=bf83d326d844)则能读满 8000 字节。
这不是新接口的花样用法,是回归。JDK-8317808 改过 Stream.schedule() 的投递循环,连接关掉时先写 errorRef 再排空队列,循环条件变成「队列非空且 error 为空」,结果队列里已经到齐的 DATA 帧被直接跳过,订阅方收到的是 onError。
现象长什么样
服务端按 RFC 9113 允许的节奏来:HEADERS + 若干 DATA(最后一帧带 END_STREAM),再发 GOAWAY(NO_ERROR),然后关连接。客户端用 HTTP/2,ofInputStream() 拿到 HttpResponse 后 sleep 300ms,再 readAllBytes()。
JDK 21.0.12.1 上的原文日志:
openjdk version "21.0.12.1" 2026-08-18
VERSION=HTTP_2
TARGET_FAILURE=java.io.IOException: closed
Caused by: java.io.EOFException: EOF reached while reading
EXIT=2
BODY_SENT=8000
GOAWAY_SENT=NO_ERROR
SESSION_CLOSED=true
服务端已经把 8000 字节发完了。客户端却说连接关了。换成同版本的 ofByteArray():
VERSION=HTTP_2
BYTES=8000
SUCCESS=true
EXIT=0
同样是 ofInputStream(),不 sleep、一拿到就读(control 模式),JDK 21.0.12.1 也能成功。问题出在「流式 handler + 晚读 + 对端先关连接」这一条路径上。
三个未修版本都一样
JDK 27+35:
openjdk version "27" 2026-09-15
OpenJDK Runtime Environment (build 27+35-2325)
VERSION=HTTP_2
TARGET_FAILURE=java.io.IOException: closed
Caused by: java.io.EOFException: EOF reached while reading
EXIT=2
JDK 28-ea+20(当前 jdk.java.net/28 公开的 Early-Access):
openjdk version "28-ea" 2027-03-23
OpenJDK Runtime Environment (build 28-ea+20-1588)
VERSION=HTTP_2
TARGET_FAILURE=java.io.IOException: closed
Caused by: java.io.EOFException: EOF reached while reading
EXIT=2
三个版本上 bytearray 对照全部是 BYTES=8000 SUCCESS=true。bug 页还列了报告方测过的版本:Temurin 17.0.19、21.0.12.1、25.0.4.1 会失败;Temurin 17.0.16、11.0.32 不会。根因指向 JDK-8317808 那次改动。
怎么本地复现
附件来自 bug 页本身:一个 Node 的极简 h2 服务,一个 Java 客户端。服务端用 PKCS12 开 TLS,ALPN 只谈 h2;写满 8 个 1000 字节的 DATA 后 GOAWAY,再 session.close()。
stream.respond({ ':status': 200, 'content-type': 'application/octet-stream' });
// 写 8 帧 × 1000 字节,最后一帧 end
stream.end(chunk);
session.goaway(http2.constants.NGHTTP2_NO_ERROR, stream.id);
session.close();
客户端核心就这几行:
HttpClient client = HttpClient.newBuilder()
.sslContext(trustAllContext())
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest request = HttpRequest.newBuilder(
URI.create("https://localhost:" + port + "/data"))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<InputStream> response =
client.send(request, HttpResponse.BodyHandlers.ofInputStream());
Thread.sleep(300); // 故意晚读
byte[] body;
try (InputStream in = response.body()) {
body = in.readAllBytes();
}
跑法:先起 node h2-server.js,它打印 PORT=...;再 java Http2DropRepro <port> inputstream。第二参数改成 bytearray 或 control 就是对照。
修复改了什么
主线提交 5c1c7c1(Daniel Fuchs,2026-10-21)动的是 jdk.internal.net.http.Stream。旧循环在 errorRef 非空时直接停;新逻辑在 body 的 CF 还没异常完成时,继续把队列里已经缓冲的帧推给订阅者,让晚读的 InputStream 先把已到齐的字节读完。
公开 EA 还停在 build 20,没有这一笔。我这边有一份本地编出来的 28-internal,release 里写着 SOURCE=".:git:bf83d326d844"(该 merge 之后已包含 5c1c7c1)。对这份运行时做 javap,能看到新增的私有方法 getBody(BodySubscriber),以及 isCompletedExceptionally 分支;28-ea+20 的 Stream.class 没有这两个符号。
同一份复现脚本在这份 28-internal 上:
openjdk version "28-internal" 2027-03-23
SOURCE=".:git:bf83d326d844"
VERSION=HTTP_2
BYTES=8000
SUCCESS=true
EXIT=0
BODY_SENT=8000
GOAWAY_SENT=NO_ERROR
bug 页的 Resolved In Build 是 b21,和「EA 公开包还是 b20、主线已合入」一致。HTTP/3 路径也顺手加了同类「有序关闭时别把已收齐的流直接 cancel」的判断,本文只实测了 HTTP/2。
眼下怎么躲
生产若还停在 JDK 21.0.12.1、JDK 27+35、JDK 28-ea+20 这类未带 5c1c7c1 的构建,又必须流式读 body,可以先避开这条组合:
- 强制 HTTP/1.1:
HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) - 能整包吃下就用
ofByteArray()/ofString(),别用ofInputStream()再拖很久才读 - 拿到流之后马上读,不要在业务线程里先做别的事再回头拉 body
Spring 的 RestClient 或 WebClient 如果底层走到 JDK HttpClient 且以流式方式消费响应,也会落到同一条实现上。早些时候那篇 结构化并发转正实测 是 API 定稿;这次是标准库网络栈的回归修复,值得盯着 b21,以及后续 21u、25u 会不会把同一笔修带回去。
主源就这三处:JDK-8394607、合入提交 5c1c7c1、JDK 28 EA(当前仍是 Build 20)。
一起交流
分享你的思考,让讨论更进一步。