
GCがあるのに、なぜTomcatは落ちるのか ── Dockerで4通りに壊して確かめた
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 が出ています。 ...








