【新卒2年目の奮闘記】Javaのスレッドリークの正体は「監視スレッド」だった?GCルートから読み解くメモリリークとの違い

こんにちは!弥生でエンジニアをしている25卒の関です。1か月前まで新卒でした。

今回は、業務の中でSpring Bootのバージョンアップ(脆弱性対応)を行った際、スレッドリークについて調査・対応する機会があったので、備忘録も兼ねてお伝えできればと思います。

メモリリークとスレッドリークの関係

「スレッドリーク」を理解するためには、まずその従兄弟のような存在である「メモリリーク」について知るのが近道です。この2つを比較しながら見ていきましょう。

まず最初にメモリリークとは何かですが、「分かった気になれるIT用語辞典」(リンク)では以下の様に記載されています。

確保したメモリ領域を解放する処理がプログラムに入っていないのが原因で、メモリを確保するけど解放しないのが続くことによって、メモリの空き領域が減っていくこと

つまり、「使ったら使いっぱなしのまま放置しているとリソースが足りなくなる現象」の事を指します。 そのままリソースが足りなくなると、パフォーマンスが徐々に下がり、最悪の場合Out Of Memory(IT用語辞典)により、クラッシュや強制再起動が行われます。 そのため、確保したメモリを解放する必要があるのですが、「C/C++言語」と「Java」で大きな違いがあります。

CやC++のような言語では、プログラマが手動でメモリを確保し、使い終わったらメモリを解放するコードを明記しなければなりません。 これを書き忘れると、メモリリークに繋がります。

// サンプルコード
// C言語
#include <stdlib.h>

int main(void){
    // malloc関数を使ってメモリを確保する
    int *ptr = malloc(100);

    // free関数を使って手動でメモリを返す
    free(ptr);

    return 0;
}

一方、今回私が使用したJavaには「ガベージコレクション(GC)」が備わっており、もうどこからも参照されていない不要なデータを自動で解放してくれます。 今回は説明は省略しますが、参照や利用度合いによってメモリ割り当て領域も調整してくれたりもします。

// サンプルコード
// Java
public void memoryTest() {
    // newキーワードを使ってメモリを確保(オブジェクトを生成)する
    Object obj = new Object();

    // 参照を外す(nullを代入する、またはメソッドを終了してスコープを抜ける)
    obj = null; 
}

「じゃあJavaならメモリリークは起きないのでは?」

と思うかもしれませんが、そう甘くはありません。

例えば、不要になったオブジェクトの参照をいつまでも持ち続けたりすることなどが原因の一つです。 GCが「これはまだ使っているデータなんだな」と勘違いしてゴミ回収ができず、結果的にメモリリーク(Java heap spaceの枯渇)が発生してしまいます。

スレッドリークとの違い

さて、メモリリークの恐ろしさが分かったところで、今回の本題である「スレッドリーク」についてです。

スレッドリークはその名の通り、メモリリークのスレッド版です。 プログラムが生成したスレッドが終了せずに残り続け、システムリソースを消費し続ける現象のことです。

JavaにはGCがあるから、使い終わったメモリは勝手に片付けてくれるのでは?と思うかもしれません。しかし、スレッドリークの場合は別です。

ではなぜGCは手を付けられないのかという点ですが、Oracleには以下の様に記載されています。

https://docs.oracle.com/en/java/javase/21/gctuning/other-considerations.html

オブジェクトが到達不能となり、ガベージコレクションの対象となるのは、GCルートからそのオブジェクトへのパスが存在しない時です。GCルートには、アクティブなスレッドからの参照やJVM内部の参照が含まれます。これらは、オブジェクトをメモリ内に保持するために使用される参照です。

つまり、外部APIなどへ通信を行い「返事を永遠に待ち続けているスレッド」は、JVMから見れば『現在進行形で仕事中の重要なスレッド』です。GCは仕事中のスレッドを勝手に消すことはできません。 具体的には、外部システムと通信を行うメソッドの中で、通信クライアントである RestTemplate をリクエストが来るたびに毎回 new していたのが原因でした。 (※補足:RestTemplateは現在非推奨の傾向にあり、最新のSpring Bootでは RestClient や WebClient が推奨されています。)

// スレッドリークを引き起こすコード例
public String checkStatus() {
    // リクエストが来るたびに、メソッド内で毎回新しく作成している
    RestTemplateBuilder builder = new RestTemplateBuilder();
    RestTemplate restTemplate = builder.build(); 
    
    // 外部APIへ通信
    return restTemplate.getForObject("http://dummy-api.com/status", String.class);
}

一見すると、メソッドの処理が終われば変数 restTemplate の役目は終わり、GC(お掃除係)が勝手にメモリから消してくれそうに見えます。

しかし、Spring Bootの通信クライアントは、生成される際、通信経路を管理するための「裏方の監視スレッド」を内部的にこっそりと自動生成します。

先ほどお話しした通り、動いているスレッドは『絶対に消してはいけないGCルート』として扱われます。

そのため、メソッドを抜けて変数の参照が切れても、この裏方として生まれた監視スレッドは動き続けているため、永遠に残り続けます。 その結果newをするたびに、絶対に消されない監視スレッドが裏側でどんどん誕生し、JVM内に蓄積し続けていたのです。

これがシステムリソースを食いつぶし、OOMを引き起こしていたリークの全貌でした。

「何食わぬ顔」で動くJavaと、コンテナによる強制再起動

今回の事象で一番厄介だったのは、「スレッドリークが発生しても、Javaプロセス自体はすぐに死なない」という点でした。

一般的に、JVMのHeap領域が不足して発生するOOM(OutOfMemoryError)では、Javaプロセスはエラーを吐きながらも動き続けようとします。 しかし、今回はコンテナ環境(ECS)で動かしていたため、以下のような流れでシステム停止・再起動が起きていました。

  1. スレッドリークによりメモリ使用量が徐々に増加。
  2. コンテナに割り当てたメモリ上限を超過し、プロセスを強制終了。
  3. 常駐数を維持するため、新しいコンテナが自動で立ち上がる。

一見すると「再起動で直っている」ように見えますが、根本原因を直さない限り、この「リーク→強制終了→再起動」のループが止まらないのがこの問題の恐ろしいところです。

どうやって解決したか

この問題の解決にはいくつかの選択肢がありましたが、通信クライアントを起動時に1つだけ作り、それを使い回すシングルトンの設計に修正しました。

なぜ、毎回破棄(try-with-resourcesなどでクローズ)やスレッドプールではなく、1つを使い回す方法を選んだのかについては、ざっくり以下の理由があります。

  1. 裏方スレッドの過剰生成防止
    • RestTemplateはCloseableではないためtry-with-resourcesによる自動解放ができず、起動した監視スレッドが「GCルート」として残り続けてしまうのを防止
  2. パフォーマンス向上(RestTemplateの重い初期化をスキップ)
    • スレッド生成(HttpClient)は負荷が高いため、既存のスレッドを再利用することで負荷の軽減
  3. アクセス規模と既存コードの統一
    • 改修したAPIのアクセス数はそれほど多くないため、1つのSingletonインスタンスで十分と判断
    • 既存のコードに倣い、SpringのBeanに管理を委ねる設計

最終的には以下のようなコードになりました。

// 修正後のコード例
@RestController
public class DummyApiController {

    // クラスのフィールドとして1つだけ保持し、使い回す
    private final RestTemplate restTemplate;

    public DummyApiController(RestTemplateBuilder builder) {
        // 起動時に1回だけ作られる
        this.restTemplate = builder.build();
    }

    public String checkStatus() {
        return restTemplate.getForObject("http://dummy-api.com/status", String.class);
    }
}

このように修正したことで、裏方の監視スレッドが無限に増殖することはなくなり、スレッドリークの懸念は見事に解消されました。

おわりに

今回、ただ単に「スレッドリークとは何か」を知識として知るだけでなく、システムが裏側でどのようにスレッドを管理し、使い回しているのかを肌で感じることができました。

特にRestTemplateで生成したスレッドについて結果的にGC対象外になるというのも、調査の過程でJavaのメモリ割り当てやGCの仕様を学習するいい機会でした。

これからJavaやSpring Bootを触り始める方にとって、この記事がメモリやスレッドの裏側の動きをイメージする手助けになれば嬉しいです!

弥生では一緒に働く仲間を募集しています。
www.yayoi-kk.co.jp

弥生エンジニアの公式X x.com

弥生のエンジニアに関する note 記事もご覧ください。
note.yayoi-kk.co.jp