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)。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。