iOSアプリを作っていると、メモリのことを嫌でも意識します。画像を何枚か抱えたまま画面を行き来していたら、ある日突然アプリが消える。クラッシュログを見ても理由がはっきりせず、循環参照を探して回る。そういう時間を過ごした人は多いはずです。
ところが同じ人がJavaでWebアプリを書くと、メモリのことをほとんど考えなくなります。Tomcatに載せて、何ヶ月も動かして、それで困らない。ガベージコレクション(以下GC)がメモリを自動で片付けてくれるからだ、と説明されればそのまま納得してしまいます。
この記事は、その納得が半分しか正しくないという話です。JavaでもメモリリークはしますしTomcatは落ちます。ただしiOSとは落ち方がまるで違い、しかも「落ちた」という言葉の意味さえ違う。最後にDockerでTomcatを立てて、実際に4通りに壊して確かめます。
何を「ゴミ」と見なすかが、そもそも違う
まず前提から。iOSとJavaでは、そもそも何をゴミと判定するかの基準が違います。
iOSのARCは、その物を指している矢印が何本あるかを数えます。矢印がゼロ本になった瞬間に解放する、という単純明快な仕組みです。ところがAとBが互いを指し合うと、どちらも1本のまま永久にゼロになりません。誰も使っていないのに、いつまでも居座り続ける。これが循環参照です。しかもiOSはアプリのメモリをディスクに逃がさないので、上限を超えるとOSがアプリごと終了させます。開発中に強く意識させられるのは、こういう作りだからです。
JavaのGCは数えません。見ているのは「出発点から参照を辿って行き着けるか」だけです。出発点になるのは、static フィールドや、いま動いているスレッドが握っている変数など。そこから辿り着けないものは、互いに指し合っていようが構わず、まとめてゴミと見なされます。
だからJavaでは「解放し忘れ」型のリークは原理的に起きません。解放を書く場所そのものが無いし、循環参照も怖くない。ここまでは、GCのありがたさとして語られるとおりです。
GCが捨てるのは「使わないもの」ではない
問題は、この判定基準がもうひとつのことを意味している点です。GCは「もう使わない」を判定していません。判定しているのは辿り着けるかどうかだけです。
この2つは普段ほとんど一致するので混同しがちですが、ずれた瞬間にリークになります。「もう使わない」のに「辿り着ける」ものは、GCから見れば生きているデータです。何度回収しに来ても、大事に守って帰っていく。
代表例が static なコレクションです。
private static final Map<String, byte[]> CACHE = new HashMap<>();
同じサムネイルを何度も作り直すのは無駄だから、と書いてしまうこの1行。static フィールドはクラスが読み込まれている限り生き続ける出発点そのものなので、上限も期限も付けなければ、入れたものは全部、アプリが動いている間ずっと守られ続けます。
同じ形のバグは他にもあります。Tomcatのワーカースレッドは使い回されるので、ThreadLocal に入れたものは明示的に消さない限り、リクエストが終わってもスレッドに貼り付いたまま残ります。HttpSession に巨大なオブジェクトを詰めれば、セッションが切れるまで消えません。どれも「参照が残っている」という一点で同じ型です。
それでも普段は問題にならない理由
では、なぜ日常的には困らないのか。理由は2つあって、どちらも「GCが優秀だから」とは少し違います。
ひとつは、Webアプリが扱うオブジェクトの大半がリクエストと一緒に生まれてレスポンスと一緒に死ぬからです。世代別GCはそういう短命なものを非常に安く回収できるように作られていて、生き残りが少ないほど仕事が楽になります。普通に書いていれば、勝手に片付いていく。
もうひとつは、サーバーが案外よく再起動されることです。デプロイのたび、設定変更のたびにプロセスが入れ替わる。じわじわ増えるタイプのリークは、顕在化する前にリセットされている。つまり気にせずにいられるのは、リークが育つ前に片付いているからでもあります。
裏を返すと、長期間動き続けるサーバーほど、そして再デプロイを繰り返すサーバーほど、この手のバグが表に出ます。
本当に落ちるのか、Dockerで確かめる
ここからは実測です。公式イメージのTomcat 10.1(JDK 17)を立てて、わざとリークするサーブレットを置きます。
docker run -d --name leak-heap -p 18080:8080 \
-e CATALINA_OPTS="-Xmx64m -Xms64m -Xlog:gc" \
-v "$PWD/webapp:/usr/local/tomcat/webapps/leak" \
tomcat:10.1-jdk17-temurin
ヒープを64MBに絞っているのは、実サーバーで何時間もかかることを数十秒に縮めるためです。仕組みは変わりません。
置いたサーブレットは、先ほどの上限なしキャッシュそのものです。
@WebServlet("/thumb")
public class ThumbnailServlet extends HttpServlet {
private static final Map<String, byte[]> CACHE = new HashMap<>();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
String id = req.getParameter("id");
byte[] thumb = CACHE.get(id);
if (thumb == null) {
thumb = renderThumbnail(id); // 1枚 1MB のつもり
CACHE.put(id, thumb);
}
Runtime rt = Runtime.getRuntime();
long usedMb = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
res.getWriter().printf("cached=%d used=%dMB max=%dMB%n",
CACHE.size(), usedMb, rt.maxMemory() / (1024 * 1024));
}
}
異なるIDで順に叩いていくと、27リクエスト目で応答が返らなくなりました。ログには java.lang.OutOfMemoryError: Java heap space が出ています。
面白いのはその直前のGCログです。
[26.025s][info][gc] GC(41) Pause Full (G1 Compaction Pause) 59M->59M(64M) 9.990ms
[26.030s][info][gc] GC(42) Pause Full (G1 Compaction Pause) 59M->59M(64M) 4.904ms
[26.054s][info][gc] GC(46) Pause Full (G1 Compaction Pause) 59M->59M(64M) 4.416ms
[26.058s][info][gc] GC(47) Pause Full (G1 Compaction Pause) 59M->59M(64M) 4.059ms
[26.083s][info][gc] GC(51) Pause Full (G1 Compaction Pause) 59M->59M(64M) 4.959ms
矢印の左が回収前、右が回収後です。59M->59M ですから、フルGCを何度かけても1バイトも減っていない。GCが壊れているわけではなく、正常に動いた上で「捨てるものが1つも無い」と結論しています。全部 static フィールドから辿り着けるので、当然です。
先頭の時刻にも注目してください。5回のフルGCが0.06秒の間に詰め込まれています。空振りと分かっている掃除を、それでも全力で繰り返している状態です。
これがヒープリークの本質的な怖さで、GCは頑張れば頑張るほどCPUを食い、それでいて何も解決しません。長時間かけて劣化させるほうのケースでは、この段階で応答が極端に遅くなり、GC overhead limit exceeded という別の顔のOutOfMemoryErrorが出ることもあります。
そして落ちたあと、コンテナはどうなっていたか。
$ docker ps --format '{{.Status}}'
Up 38 seconds
生きています。ここが後で効いてきます。
閉じ忘れたファイルは、メモリと関係なくサーバーを止める
2つ目はメモリの話ではありません。ただ現場では、メモリの問題と見分けがつきません。
以前、ファイルディスクリプタの解放忘れが原因でTomcatが応答しなくなり、強制再起動がかかってトラブルになったことがあります。当時は原因を突き止められず、メモリリークだったのかどうかも分からないままでした。今回それを再現してみると、なぜ分からなかったのかがよく見えます。
仕込みは単純で、ファイルを開いて閉じないだけです。
InputStream in = new FileInputStream(TEMPLATE);
OPENED.add(in); // close() を書き忘れている
int firstByte = in.read();
OSが1プロセスに許すファイル数を256に絞ったコンテナで叩くと、201本目で Too many open files になりました。ここまでは想定どおりです。問題はその先で、Tomcat自身のログにこう出ます。
SEVERE [http-nio-8080-Acceptor] org.apache.tomcat.util.net.Acceptor.run Socket accept failed
java.io.IOException: Too many open files
at java.base/sun.nio.ch.Net.accept(Native Method)
at org.apache.tomcat.util.net.NioEndpoint.serverSocketAccept(NioEndpoint.java:629)
at org.apache.tomcat.util.net.Acceptor.run(Acceptor.java:145)
ネットワーク接続もファイルディスクリプタを1本使います。だから枯渇すると、新しい接続を受け付ける係そのものが動けなくなる。試しに20並列でリクエストを投げたら、15本は接続すら張れず、200が返ったのは1本だけでした。
このときのサーバーの状態を並べると、厄介さが分かります。プロセスは生きている。メモリは全く問題ない。ヒープダンプを取っても何も出てこない。それでいて、外から見ると応答しない。監視がヘルスチェックの失敗を検知して強制再起動をかける条件が、ここで綺麗に揃います。当時原因が分からなかったのも無理はなく、そもそもメモリの問題ではなかった可能性が高い、というのが今回の再現で得られた答えでした。
ひとつ補足すると、FileInputStream は回収されるときに裏でファイルを閉じてくれる仕組みを持っています。つまり閉じ忘れても、GCが走れば道連れに片付くことがある。今回の実験でわざと参照を保持したのは、この救済が効いてしまうと再現が運任せになるからです。裏返せば実際のバグは、その運の上に成り立っています。暇な日は何も起きず、忙しい日だけ落ちる。原因究明が難しくなる理由がここにもあります。
止め忘れたスレッドは、古いクラスを道連れにする
3つ目は、アプリが立てたスレッドを止め忘れるパターンです。起動時に定期ジョブ用のスレッドプールを作り、終了時に止めるのを忘れた、という形にしました。
@Override
public void contextInitialized(ServletContextEvent sce) {
pool = Executors.newScheduledThreadPool(2, r -> new Thread(r, "report-batch"));
pool.scheduleAtFixedRate(() -> { /* 定期処理 */ }, 1, 1, TimeUnit.SECONDS);
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
// pool.shutdownNow() を書き忘れている
}
これはTomcatが検出して警告を出してくれます。見覚えのある人も多いはずです。
WARNING [Catalina-utility-2] WebappClassLoaderBase.clearReferencesThreads
The web application [leak] appears to have started a thread named [report-batch]
but has failed to stop it. This is very likely to create a memory leak.
ここで先に、素朴な疑問を片付けておきます。アプリを入れ替えたりしない普通の運用でも、スレッドを止め忘れたらメモリリークになるのか。
答えは、本数が増え続けるかどうかで変わります。起動時にプールを1つ作って止め忘れただけなら、余分なスレッドが数本残るだけで、それ以上は増えません。実害はほとんどありません。まずいのは、リクエストのたびに新しいスレッドを作って終わらせないような、際限なく増える形です。こちらは入れ替えなど関係なく、単体で落とせます。しかも2通りの落ち方をします。
まず、リクエストのたびにスレッドを作り、そのスレッドが1MBのデータを握ったまま終わらないサーブレットを、ヒープ64MBで叩いてみます。
final byte[] payload = new byte[1024 * 1024];
new Thread(() -> {
try { NEVER.await(); } catch (InterruptedException ignore) {}
System.out.println(payload.length); // payload を掴んだまま、永久に終わらない
}, "leaked-worker").start();
req#20 -> threads=20 live=37 usedHeap=48MB
req#27 -> threads=26 ERROR java.lang.OutOfMemoryError: Java heap space
static なキャッシュはどこにもありません。それでもヒープが埋まります。理由は最初の図のとおりで、動いているスレッドはGCの出発点そのものだからです。そのスレッドが握っているものは、スレッドが生きている限りずっと辿り着ける。止め忘れたスレッドは、それ自体が上限なしのキャッシュとして働きます。ここまでは1つ目の落ち方と同じ場所に当たっただけで、原因が違うだけです。
次に、今度は何も握らせず、スレッドの数だけを増やしてみます。
req#150 -> threads=150 live=167 usedHeap=20MB
req#157 -> threads=156 ERROR java.lang.OutOfMemoryError:
unable to create native thread: possibly out of memory or process/resource limits reached
同じ OutOfMemoryError でも中身がまるで違います。注目すべきは usedHeap=20MB のほうで、このときヒープは256MBのうち20MBしか使っていません。ヒープはがら空きのまま落ちています。スレッドは1本ごとにOS側のスレッドとスタック用の領域を必要とし、そちらの上限に当たったからです。
だからこのエラーは -Xmx を増やしても直りません。それどころか、ヒープに割り当てた分だけスタックに回せるメモリが減るので、環境によっては増やすほど早く落ちます。OutOfMemoryErrorと聞いて反射的にヒープを増やす対処が、きれいに裏目に出る典型がこれです。エラーメッセージの続きまで読んで、どの天井に当たったのかを見分ける必要があります。
そして、アプリを入れ替える運用だと、ここにもう1つの落ち方が乗ります。動いているTomcatにwarを置き直す、いわゆるホットデプロイやリロードの場合です。
仕組みはこうです。Tomcatはアプリごとに専用のクラスローダーを持っていて、入れ替えのときは新しいクラスローダーを作り、古いほうを捨てます。古いクラス定義が解放されるのは、その古いクラスローダーにどこからも辿り着けなくなったときです。ここで冒頭の判定基準が効いてきます。
止め忘れたスレッドは、古いほうのアプリが作ったものです。そのスレッドは自分が実行している処理オブジェクトを握っていて、その処理オブジェクトは自分のクラスを指し、そのクラスは自分を読み込んだクラスローダーを指しています。そして動いているスレッドはGCの出発点そのものです。つまりスレッドが1本残るだけで、古いアプリのクラス定義がまるごと守られる。しかもクラス定義が置かれるのはヒープではなくMetaspaceという別の領域なので、ヒープをいくら監視していても気づけません。
だからこの節は、スレッドの話から急にクラスローダーの話に移ったわけではありません。スレッドリークがメモリリークに化ける経路そのものが、クラスローダーです。逆に言えば、デプロイのたびにJVMごと入れ替える運用(コンテナを差し替えるなど)なら、この落ち方は起きません。止め忘れたスレッドは残りますが、プロセスが死ねば道連れに消えるからです。この失敗が刺さるのは、長く動かし続けるTomcatにアプリだけを置き直していく運用です。
入れ替えを繰り返しながら測ると、こうなりました。
| 起動直後 | 10回入れ替え後 | 限界到達時 | |
|---|---|---|---|
| 残ったスレッド | 2本 | 22本 | 30本 |
| Metaspace | 22MB | 102MB | 127.5MB(上限128MB) |
入れ替え1回につきスレッドがちょうど2本、Metaspaceが約8MBずつ積み上がり、14回目で java.lang.OutOfMemoryError: Metaspace になりました。
この実験で予想外に面白かったのが、最初の失敗です。クラスが3つしか無い最小のアプリで試したところ、10回ほど入れ替えてもMetaspaceは0.5MBしか増えず、まるで再現しませんでした。そこでクラス数を実アプリ並みの2400個まで増やしたら、1回あたり8MBに跳ね上がって、あっさり限界に達した。同じバグなのに、増え方が100倍以上違う。
つまりこのリークは、アプリが抱えているクラスの量にそのまま比例します。小さなアプリでは事実上いつまでも顕在化しないのに、フレームワークやライブラリを大量に積んだ本物のアプリでは、十数回の再デプロイで到達しうる。「開発環境では何ともないのに本番だけ死ぬ」の正体が、こういうところにあります。
4つに共通していたこと
並べてみると、Javaのサーバーには壊れる天井が4つあることが分かります。
右の2つ、スレッド数とファイルディスクリプタは、どちらもヒープがまったく減っていない状態で落ちます。メモリの監視グラフをいくら眺めても異常が見えないのに応答しない、という形になるのはこの2つです。
そして今回いちばん収穫だったのは、4つとも docker ps の表示が最後まで Up だったことです。JVMは生きている。Tomcatも生きている。それでもアプリは応答しない。
OutOfMemoryErrorはJavaの例外の一種で、投げられたスレッドは死にますがJVMは止まりません。運が悪いと、ロックを握ったまま死んだり、データベース接続を返さないまま死んだりします。半分だけ壊れた状態で動き続けるほうが、素直に落ちてくれるより厄介です。ログには最初の1件しか出ないのに、そのあと延々と別の症状が出る、という調査の悪夢はここから来ます。
そう考えると、本番のJVMには -XX:+ExitOnOutOfMemoryError を付けて潔く終了させ、再起動はプロセス管理側に任せるほうが安全なことが多い。原因を追いたいなら -XX:+HeapDumpOnOutOfMemoryError を併せて付けて、死ぬ前にヒープの中身を吐かせておきます。
巨大な画像を読むとき、ヒープはディスクに逃げてくれない
ここでよくある誤解をひとつ。メモリが足りなくなったらディスクに逃がして、遅くなるだけで済むのではないか、という期待です。
JVMのヒープに関しては、そういう仕組みはありません。-Xmx が硬い天井で、超えた瞬間にOutOfMemoryErrorです。100MBの画像を10並列で丸ごと読み込めば、それだけで足りなくなります。
OSレベルのスワップは確かに起こり得ますが、これは救いになりません。GCはヒープ全体を舐めて回るので、その領域がディスクに追い出されているとページの読み戻しが大量に発生し、性能が数十倍から数百倍に劣化します。実質的には止まったのと同じで、だから本番サーバーではスワップを切っておくのが定石です。コンテナで動かしていれば、そもそもcgroupの上限を超えた時点でLinuxのOOM Killerがプロセスごと消しにきます。
では巨大なファイルをどう扱うか。答えは、ヒープに全部載せないことです。読みながら順に処理する形にすれば、いくら大きくても一度に抱える量は一定に保てます。ファイルアップロードなら、サーブレットのマルチパート設定で fileSizeThreshold を指定すれば、その大きさを超えた分をテンポラリファイルに書き出してくれます。これが「ディスクに逃がす」に一番近い仕組みですが、自動ではなく、アプリ側が明示的に選ぶ設計になっている点が大事です。画像処理なら、全画素を展開せずに必要な範囲だけ読むか、思い切って別プロセスの専用ツールに任せるほうが安全です。
HTTPクライアントを自作しなくても、これは起きる
スレッドリークと聞くと、「自分で new Thread() なんて書かないから関係ない」と感じるかもしれません。実際、リクエストを処理しているスレッドはTomcatが持つプール(既定で最大200本)のもので、アプリが作るものではありません。使い終わればプールに返るので、リクエストごとに漏れるということは原理的に起きません。
気をつけたいのはそこではなく、ライブラリのクライアントをリクエストごとに作る書き方です。new OkHttpClient() は内部にスレッドプールと接続プールを抱えます。Java 11以降の HttpClient.newHttpClient() も専用のスレッドを持ちます。Apache HttpClientの接続マネージャも同様です。これらはどれも「アプリで1つ作って使い回す」前提で設計されています。
ここで一番大きな損は、実はリークではありません。接続の再利用が効かなくなることです。接続プールは、一度張ったTCP接続を次のリクエストでも使い回すための仕組みです。リクエストごとにクライアントを作れば、プールは毎回空の状態から始まり、使い回す相手がいないまま捨てられます。結果として毎回TCPのハンドシェイクをやり直し、HTTPSならTLSのハンドシェイクまでやり直す。1回あたり数十ミリ秒の話ですが、これは確実に毎回払う税金です。
資源の面は、使っているライブラリによって話が変わります。OkHttpのように、放置されたクライアントの遊んでいるスレッドが数十秒、アイドル接続が数分で自動的に片付くよう作られているものもあれば、Apache HttpClientの CloseableHttpClient のように、明示的に閉じないと接続を抱えたままになるものもあります。前者なら、作る速さが片付く速さを下回っている限り何も起きません。アクセスが少ないうちは何年でも無事に動きます。逆に言えば、限界に触れるのはトラフィックが増えた日で、閉じ忘れたファイルのときと同じ「忙しい日だけ落ちる」という顔をします。
つまりこの書き方は「必ず落ちる爆弾」ではなく、「平常時は静かに性能を削り、限界を細くしておく」たぐいの問題です。だから長年動いていて何も起きていない、ということは普通に起こり得ます。それでも直す価値があるのは、落ちる落ちない以前に、接続を使い回せていないぶんだけ毎回遅いからです。
落とさないために
やることは、ここまでの裏返しです。キャッシュを持つなら必ず上限か期限を決めること。LinkedHashMap の removeEldestEntry を使う手もありますが、素直にCaffeineのようなライブラリに任せたほうが確実です。開いたものは try-with-resources で閉じること。アプリが立てたスレッドは contextDestroyed で確実に止めること。HTTPクライアントやコネクションプールは1つ作って使い回すこと。そして本番のJVMは、壊れたら半端に生き延びずに終了するよう設定しておくこと。
「GCがあるから大丈夫」ではなく「GCは辿り着けないものしか捨てない」。この一行さえ握っていれば、どこにリークが潜むかは自分で見当をつけられます。static なフィールド、生き続けるスレッド、長寿命のセッション。出発点から切れずに繋がったままのものを疑えばいい。
なお、思い込みを実測で崩す話としてはSQLiteのボトルネックを測った回 も同じ趣旨で書いています。
