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を使用するべきかもしれないと考えさせられた。