事象に遭遇したのは AdoptOpenJDK jdk-11.0.9.1+1。
事象発生コード
数年ぶりにJavaで開発を行うことになり、外部APIをHTTPリクエストで呼び出してレスポンスのJSONを受信する必要が出てきた。
最後にJavaで開発した時はJDK 8でHTTPリクエストを行う標準ライブラリは
java.net.HttpURLConnection
くらいしかなかったが、
JDK 11からはjava.net.http.HttpClient
が正式に標準ライブラリ入りしていたらしいので使用することにした。
JSONからのデシリアライズはJacksonを使おうと考えたが、HttpClient
はレスポンスボディをreactive-streamsで処理しているらしい。
最も簡単な取り扱い方だとHttpResponse.BodyHandlers#ofString()
で一度レスポンスボディをString
にしてからパースする方法だが、
せっかくのreactive-streamsなのでいい感じにデータ受信完了後にJSONを処理できる方法が無いか調べると
java.net.http.HttpResponse.BodySubscribers#mapping(HttpResponse.BodySubscriber<T>, Function<? super T,? extends U>)
のjavadocが見つかった。
javadocの中でJacksonを利用してレスポンスボディを直接デシリアライズしてJavaオブジェクトを返すサンプルコードが書かれていたので、
そこで紹介されているコードをほぼそのまま流用させてもらうことにした。
実装は大体以下のようなコードになった。
public static <T> BodySubscriber<T> jsonSubscriber(
ObjectMapper objectMapper,
Class<T> klazz) {
Objects.requireNonNull(objectMapper);
Objects.requireNonNull(klazz);
return BodySubscribers.mapping(
BodySubscribers.ofInputStream(),
(InputStream bodyStream) -> {
try (bodyStream) {
return objectMapper.readValue(bodyStream, klazz);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
上記のメソッドで生成したサブスクライバを
java.net.http.HttpResponse.BodyHandler<T>
の実装クラスで返すことで
HttpClient
でレスポンスを受信した時にデシリアライズが実施されるようにしていた。
public class FooResponseHandler implements HttpResponse.BodyHandler<Foo> {
private final HttpResponse.BodySubscriber<Foo> subscriber;
public FooResponseHandler(ObjectMapper objectMapper) {
this.subscriber = jsonSubscriber(objectMapper, Foo.class);
}
@Override
public HttpResponse.BodySubscriber<Foo> apply(HttpResponse.ResponseInfo responseInfo) {
if (responseInfo.statusCode() == 200) {
return this.subscriber;
}
throw new RuntimeException("Unknown response");
}
}
発生した問題
HttpClient
の動作確認のために外部APIを呼び出しすると、なぜか数十秒経過後にタイムアウトが発生する。
TCPダンプを見るとAPIからのレスポンスは1秒かからず返ってきており、レスポンスボディも想定された内容だった。
しかし、HttpClient
はタイムアウトが発生する。
デバッガーで原因個所を特定していくと、BodySubscribers.mapping()
の第2引数に渡された
マッピング関数が実行された後でスレッドがフリーズしているらしいことが分かった。
スレッドはフリーズしていたが、HTTPリクエストタイムアウトを発生させるスレッドは動いていたようで、
そのスレッドから例外が通知されるまでの数十秒間が待ち時間になっていたようだ。
原因と対処
このあたりでJDKの不具合を疑い調べてみるとStack Overflowなどに情報をまとめてくれている方たちがいた。
- https://stackoverflow.com/questions/57629401/deserializing-json-using-java-11-httpclient-and-custom-bodyhandler-with-jackson
- https://bugs.openjdk.java.net/browse/JDK-8217264
- https://bugs.openjdk.java.net/browse/JDK-8217627
HttpClient
は内部でI/O操作のためのExecutor
でCompletableFuture
を実行しているが、
HTTPレスポンスボディを処理する中でブロッキングAPIが呼び出しされるとExecutor
のスレッドが枯渇してしまうことがあるらしい。
具体的にどこでスレッドが枯渇しているのかは追いかけていないが、InputStream
からの読み取りでフリーズしているように見えたので、
レスポンスボディのバイト列をInputStream
に追加する処理と
InputStream
から読み取ってJSONデシリアライズする処理で競合のような状態が発生したと予想している。
対処方法は、紹介されているとおりBodySubscriber<T>
をBodySubscriber<Supplier<T>>
に変更するしかないと思われる。
HttpClient
内部処理が完了した後でレスポンスボディにアクセスする様になるため、スレッドの問題が回避できる。
ただ、この方法だとHTTPレスポンスボディのデータが全てInputStream
にバッファリングされていそうなので、
JSONデシリアライズみたいな目的の時はHttpResponse.BodyHandlers#ofString()
を使用するのとメモリ効率などがあまり変わらない気がするのが気になる。
この不具合はJDK 13で修正されているようだが、同時にjavadocも更新されており
BodySubscriber
ないぶでレスポンスボディのInputStream
などにアクセスする場合は
BodySubscriber<Supplier<T>>
を使用する方法が推奨されるようになったようだ。