Synchronous response parsing pins Java 21 virtual threads during body reads
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 3/5
- 预计耗时
- 半天
- 新手友好度
- 42/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 活跃
- 领域
- api, performance
调研方向
Start with HttpResponseFor.kt, where parseable wraps the parsed response in Kotlin's synchronized lazy, and trace how the initializer reads the body through ObjectMapper.readValue. The pinning repro in the issue is a standalone Java program with a raw control mode, so run it before and after a change. Done means parsed mode no longer reports SynchronizedLazyImpl in jdk.VirtualThreadPinned events while one-time parsing semantics stay intact.
由索引模型根据 Issue 内容生成。
描述
Problem
Synchronous non-streaming calls can pin Java 21 virtual threads while waiting for response body data, including with the SDK's default OkHttp transport.
HttpResponseFor.kt caches the parsed response using Kotlin's default synchronized lazy. Its initializer performs JSON parsing and reads the response body while holding the lazy monitor. If that read blocks, the virtual thread cannot unmount from its carrier on Java 21.
With slow responses and concurrent calls, this can occupy the available carriers and delay unrelated virtual-thread work.
Reproduction conditions
- Java 21.0.4
openai-java-client-okhttp:4.71.0(default OkHttp 4.12.0)- JVM option:
-Djdk.virtualThreadScheduler.parallelism=2 - A local HTTP/1.1 server running on platform threads returns a valid non-streaming chat completion: send the response headers and the first byte of JSON, flush, wait 500ms, then send the remaining body.
- Run
client.chat().completions().create(params)on virtual threads, submitting seven calls together per round, for three rounds. - Record JFR
jdk.VirtualThreadPinnedevents with a 10ms threshold and stack traces.
Observed pinning
JFR recorded nine pinning events containing SynchronizedLazyImpl, with a maximum duration of approximately 505ms. A representative stack, shown from the call entry to the blocking read, is:
ChatCompletionServiceImpl.create
HttpResponseForKt$parseable$1.parse
kotlin.SynchronizedLazyImpl.getValue
HttpResponseForKt$parseable$1$parsed$1.invoke
ObjectMapper.readValue
okhttp3.internal.connection.Exchange$ResponseBodySource.read
okhttp3.internal.http1.Http1ExchangeCodec$FixedLengthSource.read
okio.InputStreamSource.read
sun.nio.ch.NioSocketImpl.timedRead
sun.nio.ch.Poller.pollIndirect
java.lang.VirtualThread.parkOnCarrierThread
As a control, reading response.body() directly after withRawResponse().create(params), under the same slow-body conditions, produced no pinning events above the threshold.
Expected behavior
Response parsing should preserve its one-time initialization semantics without holding a JVM monitor across blocking response-body reads, so synchronous calls can release their carriers while waiting for I/O on Java 21.
Runnable reproduction
Use JDK 21 and Maven. The program uses only a local server and a dummy API key.
Save this as pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>repro</groupId>
<artifactId>virtual-thread-pinning</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.openai</groupId>
<artifactId>openai-java-client-okhttp</artifactId>
<version>4.71.0</version>
</dependency>
</dependencies>
</project>
Save this as PinningRepro.java in the same directory:
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.models.chat.completions.ChatCompletionCreateParams;
import com.sun.net.httpserver.HttpServer;
import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordingFile;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
public class PinningRepro {
public static void main(String[] args) throws Exception {
boolean raw = args.length > 0 && args[0].equals("raw");
byte[] json = ("{\"id\":\"test\",\"object\":\"chat.completion\",\"created\":1,"
+ "\"model\":\"test\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\","
+ "\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}")
.getBytes(StandardCharsets.UTF_8);
var serverThreads = Executors.newFixedThreadPool(16, Thread.ofPlatform().factory());
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(serverThreads);
server.createContext("/v1/chat/completions", exchange -> {
try (exchange) {
exchange.getRequestBody().readAllBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, json.length);
var out = exchange.getResponseBody();
out.write(json, 0, 1);
out.flush();
try {
Thread.sleep(500);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
return;
}
out.write(json, 1, json.length - 1);
}
});
server.start();
var client = OpenAIOkHttpClient.builder()
.baseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/v1")
.apiKey("dummy").maxRetries(0).timeout(Duration.ofSeconds(5)).build();
var params = ChatCompletionCreateParams.builder().model("test").addUserMessage("test").build();
Path file = Path.of(raw ? "raw.jfr" : "pinning.jfr");
try (var recording = new Recording();
var callers = Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("repro-", 1).factory())) {
// Exclude class initialization and the platform-thread warmup from JFR.
client.chat().completions().create(params);
recording.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ofMillis(10)).withStackTrace();
recording.start();
for (int round = 0; round < 3; round++) {
var start = new CountDownLatch(1);
var futures = new ArrayList<Future<?>>();
for (int i = 0; i < 7; i++) {
futures.add(callers.submit(() -> {
start.await();
if (raw) {
try (var response = client.chat().completions().withRawResponse().create(params)) {
response.body().readAllBytes();
}
} else {
client.chat().completions().create(params);
}
return null;
}));
}
start.countDown();
for (var future : futures) {
future.get(10, TimeUnit.SECONDS);
}
}
recording.stop();
recording.dump(file);
} finally {
client.close();
server.stop(0);
serverThreads.shutdownNow();
}
var pins = RecordingFile.readAllEvents(file).stream()
.filter(e -> e.getEventType().getName().equals("jdk.VirtualThreadPinned")).toList();
System.out.println("mode=" + (raw ? "raw" : "parsed") + ", completed=21, pinned events=" + pins.size());
pins.stream().findFirst().ifPresent(System.out::println);
}
}
Run:
mvn -q dependency:build-classpath -Dmdep.outputFile=classpath.txt
javac --release 21 -cp "$(cat classpath.txt)" PinningRepro.java
java -Djdk.virtualThreadScheduler.parallelism=2 -cp ".:$(cat classpath.txt)" PinningRepro
jfr print --events jdk.VirtualThreadPinned pinning.jfr
# Control: read the raw response body without SDK lazy parsing.
java -Djdk.virtualThreadScheduler.parallelism=2 -cp ".:$(cat classpath.txt)" PinningRepro raw
The parsed mode produces pinning events whose full JFR stacks include SynchronizedLazyImpl and the socket read. The raw mode is the control. Event counts vary with scheduling; the standalone program produced eight events in my Java 21.0.4 run.
- 主要语言
- Kotlin
- 星标
- 1.5k
- 派生
- 268
- 平均合并
- 13 小时 30 分钟
- 30 天内合并 PR
- 160
环境准备
在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。
- 没有 Dockerfile 或 Docker Compose 文件
- 没有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
openai/openai-java 的其他 Issue
-
HttpRequest.url() logs spaces in path segments as literal plus signs可能已有人在做 @sylvesterkaczmarek 于 58 天前认领。 未关闭
难度 2/5 1-3 小时 新手友好度 78/100
openai/openai-java#886 · 2 条评论 ·
维护者通常 1 天内回复
-
Make diarized transcription duration and task optional可能已有人在做 @wskr00 于 89 天前认领。 未关闭
难度 2/5 1-3 小时 新手友好度 68/100
openai/openai-java#802 · 2 条评论 · 3 个 reaction ·
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 30/100
openai/openai-java#1175 ·
维护者通常 1 天内回复
-
难度 5/5 一周以上 新手友好度 35/100
openai/openai-java#956 ·
维护者通常 1 天内回复
-
Async internal plumbing bypasses both `dispatcherExecutorService` and `streamHandlerExecutor`, forcing a hop through `ForkJoinPool.commonPool()`可能已有人在做 @sylvesterkaczmarek 于 36 天前认领。 未关闭
难度 5/5 一周以上 新手友好度 35/100
openai/openai-java#952 ·
维护者通常 1 天内回复
查看 openai/openai-java 的全部 Issue
相似的 Issue
-
Dev
难度 2/5 1-3 小时 新手友好度 62/100
ankidroid/Anki-Android#22555 ·
维护者通常 1 天内回复
-
enhancement good first issue help wanted
难度 2/5 1-3 小时 新手友好度 78/100
-
core: RAC_LLM_OPTIONS_DEFAULT.min_p disagrees with the IDL's annotated default可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭bug core p3
难度 2/5 1-3 小时 新手友好度 70/100
RunanywhereAI/runanywhere-sdks#1083 ·
维护者通常 5 天内回复
-
enhancement
难度 2/5 1-3 小时 新手友好度 75/100
维护者通常 1 天内回复
-
area: detector eco: java help wanted lane: free-agent roadmap size: S type: parser
难度 2/5 1-3 小时 新手友好度 62/100
arminazimi/evergreen#355 ·
维护者通常 1 天内回复