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
制御用スレッド
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に書かれていないし、外部から強制的に終了させるメソッドも公開されていないため、リフレクションを駆使して無理やり停止させることくらいしかできない。
対処
問題が発生したWEBアプリケーションはメモリ割り当て量も少なく、
数分でガーベージコレクション発生するガーベージコレクションで制御用スレッドも終了するため様子見ということにした。
java.net.http.HttpRequestに対してCookieを直接していするメソッドが存在しないので、
レスポンスHTTPヘッダーをパースしてCookie取り出し、
リクエストHTTPヘッダーにCookie:を追加するという処理を自作する気にもならなかったというのが正直なところ。
しかし、API呼び出しが多い環境やメモリ割り当てが大きくてガーベージコレクションがあまり発生しない環境では
java.net.http.HttpClientを使用しているとスレッド数が増え続ける事象が発生するかもしれない。
使用方法によってはApache HttpClientを使用するべきかもしれないと考えさせられた。