リクエストのたびに java.net.http.HttpClient を生成すると制御用スレッドが増える

JDK 11ベースの話です。

## 背景

WEBアプリケーションで外部のHTTP APIを呼び出す必要があり利用していた。

ただ、そのAPIはWEBサイトに存在していたフォームからのサブミットで呼び出しされるものだった。

APIとして呼び出ししやすいようにパラメーターによってレスポンスがJSONになる位の改修はされていたが、
APIを呼び出すためにはWEBサイトへのログイン（認証）が必要であり、
認証状態はCookieで管理されているというものだった。

目的のAPI呼び出しまでに「ログイン」、「目的のAPI呼び出し」の最低2回のリクエストが必要であり、
セッションを破棄するために「ログアウト」もしなければいけない。

このAPI呼び出しのためには`java.net.http.HttpClient`を使用していたが、
`HttpClient`でCookieを管理するデフォルトで使用される`java.net.CookieHandler`実装の
`java.net.CookieManager`はスレッドセーフ実装になっていない。

より厳密には`CookieManager`がCookieデータを格納先に利用する`CookieStore`も含めてスレッドセーフ実装ではない。

WEBで利用されるCookieはステートなので、スレッドセーフにできないのは道理だと思えた。

API呼び出しのたびに`HttpClient`を生成すればよいと考えていたが、
実際に「`HttpClient`のインスタンス生成」 => 「API呼び出し」を繰り返していくとスレッド数が増え続けていることに気が付いた。

そして、そのスレッドはWEBアプリケーションをしばらく稼働していると減少していくことが分かった。

## `HttpClient`が使用するスレッド

`HttpClient`を生成してAPI呼び出しを行うとスレッドが増えるのは分かった。
なぜスレッドが増えるのかを調査した。

### 非同期タスクを実行する`java.util.concurrent.Executor`

`java.net.http.HttpClient`を生成するときに`java.net.http.HttpClient.Builder`を使用する。

この時に`HttpClient.Builder#executor​(Executor)`で内部で実行される非同期タスクと依存タスクを実行するための
`java.util.concurrent.Executor`を指定することが出来る。

ここで`Executor`を指定しなかった場合、`HttpClient`は`java.util.concurrent.ThreadPoolExecutor`を
**`HttpClient`のインスタンスごとに生成して使用する**。

`ThreadPoolExecutor`なので、タスクを実行してからもしばらくの間スレッドがプールに残り続けることになる。

この仕様は知っていたので、アプリケーション内で`HttpClient`には共有`ThreadPoolExecutor`を渡していたし、
スレッドダンプに出現するスレッド名も`ThreadPoolExecutor`が生成したスレッド名と一致していなかった。

今回のスレッド増加の原因は無かった。

#### デフォルト`Executor`の不具合

`HttpClient`に`Executor`を渡さなかった場合に、`HttpClient`は内部で`ThreadPoolExecutor`を使用するが、
`ThreadPoolExecutor#shutdown()`が呼び出しされない不具合がある。

不具合といっても`HttpClient`から発生するタスクの実行にしか使用されないため、
`ThreadPoolExecutor#shutdown()`が呼び出しされなくても、いずれはスレッドはすべて終了する。

問題になるとしたら非同期タスクの中から終了しないタスクが発生したりしたとき位だろうが、
`HttpClient`の操作から終了しないスレッドが発生したら疑いをかけてもいいかもしれない。

この問題はJDK 17で修正されている: [\[JDK\-8258582\] HttpClient: the HttpClient doesn't explicitly shutdown its default executor when stopping\. \- Java Bug System](https://bugs.openjdk.java.net/browse/JDK-8258582)

### 制御用スレッド

`HttpClient`の実装にあたる`jdk.internal.net.http.HttpClientImpl`のソースを確認すると
`SelectorManager`というクラスが存在する。

このクラスは`java.lang.Thread`を継承しており、スレッド名には`HttpClient-{ID}-SelectorManager`が割り当てられている。

このスレッドが何をしているのか確認すると、`HttpClient`からのHTTP接続やHTTPリクエストフローの制御を担う役割を実施しているようだった。

そしてこのスレッド、**HttpClientImplがガーベージコレクションで解放される**か、**`HttpClientImpl`のタスクカウント（参照カウント）が`0`になる**までスレッドが終了しない。

何故そんな仕組みになっているのかというと、HTTP 2.0のような通信はリクエストとレスポンスの関係が1対1ではないため、双方向の通信を同時に実行しつつ、何時終了するか分からない通信フローを制御し続けるために制御用スレッドとタスクカウントという方式を採ったようだった。

`HttpClientImpl`が使用されなくなっていればガーベージコレクションで解放されるし、
HTTP 2.0のような通信が終了していれば、タスクカウントは`0`になりスレッドが停止するからという設計のようだ。

このスレッドの存在は`HttpClient`などのjavadocに書かれていないし、外部から強制的に終了させるメソッドも公開されていないため、リフレクションを駆使して無理やり停止させることくらいしかできない。

* [Close Java HTTP Client \- Stack Overflow](https://stackoverflow.com/questions/53919721/close-java-http-client)
* [Java \- HttpClient not releasing threads \- Stack Overflow](https://stackoverflow.com/questions/65333866/java-httpclient-not-releasing-threads)

## 対処

問題が発生したWEBアプリケーションはメモリ割り当て量も少なく、
数分でガーベージコレクション発生するガーベージコレクションで制御用スレッドも終了するため様子見ということにした。

`java.net.http.HttpRequest`に対してCookieを直接していするメソッドが存在しないので、
レスポンスHTTPヘッダーをパースしてCookie取り出し、
リクエストHTTPヘッダーに`Cookie:`を追加するという処理を自作する気にもならなかったというのが正直なところ。

しかし、API呼び出しが多い環境やメモリ割り当てが大きくてガーベージコレクションがあまり発生しない環境では
`java.net.http.HttpClient`を使用しているとスレッド数が増え続ける事象が発生するかもしれない。

使用方法によってはApache HttpClientを使用するべきかもしれないと考えさせられた。