恐怖の JVM 大量メモリ消費!メモリリークの謎を追え!!

こんにちは、ミドルウェア開発チームの青木(@a_o_k_i_n_g)です。将来の夢は川口浩探検隊に入ることです。

先日、弊社のアプリケーションサーバーで大量にメモリを消費するという現象に遭遇しました。アクセス頻度の低いサーバーがメモリを大量消費するという謎深いものでした。

発生当初の状況はこんな感じです。

  • アプリケーションサーバーでは Jetty が稼働
  • 現象が発生した JVM は 5GB 程度のメモリを消費しており、明らかに通常ではない量のメモリを消費している
  • 複数台のサーバーで発生していたが、全てで発生したわけではない。
    また、発生したサーバーはいずれもアクセス頻度が少ないサーバーだった。

ヒープ、パーマネント、スタック

ひとまず、JVM でトラブルが発生した時は何はともあれヒープダンプとスレッドダンプを見るに限ります。各種情報の取得をインフラ部隊へ依頼し、得られたヒープを解析すると、ある事実が判明しました。

ヒープの使用容量は 200MB にも達していなかったのです。これが原因では無いことは明白でした。

パーマネントも 250MB 程度で正常です。スレッドダンプも、スレッド数は 30 程度で、 JVM の持つスレッドと Jetty のスレッドプールに格納されているスレッドのみでした。異常はありません。

ではいったいどこでメモリを消費しているのでしょうか?

ここで改めておさらいしておくと、JVM が使うメモリは下記4つに大別できます。

  1. ヒープ
  2. パーマネント
  3. スタック
  4. C ヒープ

スタックは 1 スレッドにつきたかだか 1MB しか使用しない(64bit環境の場合)ので、スタックが原因でもありません。

ヒープでもパーマネントでもスタックでも無いので、消去法で C ヒープに何か潜んでいることがわかりました。

C ヒープに何か潜んでいるとわかった時点で、再度ヒープを調べました。ヒープに存在している領域が少なくとも、そのオブジェクトが C ヒープに大きなメモリを確保している可能性があるからです。 Java では java.nio.ByteBuffer.allocateDirect などでヒープ外にメモリを確保できるので、どこかに ByteBuffer を参照しているオブジェクトがあるのかもしれません。

Memory AnalyzerEclipse OQLを使い、200MB のヒープを調べつくしました。 その中で ZipFileIndex というクラスがヒープ内に複数存在していることがわかり、このオブジェクト群が犯人ではないかと疑いました。ZIP 関連の処理は C ヒープを使うということが知られており、ZipFileIndex というファイルは如何にも怪しく見えたからです。

結論から言えば、これらは jar ファイルを保持していたのみでシロでした。
その他ヒープをどれだけ解析しても、犯人はどこにもいませんでした。

C ヒープの世界へ

jstatjmap の力が及ばない領域、C ヒープ。この領域をどうやって調べれば良いでしょうか? 我々は、メモリダンプを取得してメモリ内に存在する文字列から犯人を推定する、という方針で進めることにしました。

メモリダンプには gcore を使います。gcore は任意のプロセスのメモリ内容を出力してくれるツールです。 メモリダンプをいただき、早速バイナリエディタで中身を・・・というわけにはいきません。 今回の場合はメモリダンプが 7GB 程度もあり、hexdump -C したところで先頭の僅かな部分しか見れません。

そこで登場するのが strings コマンドです。 strings コマンドはバイナリ内に存在する文字列を抽出してくれるツールです。

$ strings -a memory.gcore > strings.txt

以上のようにして memory.gcore 内に存在する文字列を抽出します。 次に、得られた文字列をソートし、出現回数順に並べてみます。

こうなりました。

$ sort strings.txt | uniq -c | sort -nr | less
168269 com/mysql/jdbc/configs/maxPerformance.properties
158788 connectionAttributes=none
158634 # setup
155904 alwaysSendSetIsolation=false
155101 # query
155071 7i,\
155070 ('Rggm
155068 Ud=k
155068 e<|8
155030 dJxzd'
155029 v^e>
155029 D1*;
154939 enableQueryTimeouts=false
154854 # Bypass connection attribute handling during connection
153117 # Can cause high-GC pressure if timeouts are used on every
152992 elideSetAutoCommits=true
152787 # and transaction isolation
152780 useLocalSessionState=true
151611 # Connection methods to set current database, autocommit
146208 cacheCallableStmts=true
146167 cachePrepStmts=true
146062 cacheServerConfiguration=true
145959 # Reduces amount of calls to database to set
145849 # session state. "Safe" as long as application uses
145699 # restarting the application using this configuration bundle.
145661 # would be "dangerous" to run-of-the-mill J2EE applications
145661 # and the server configuration, this bundle isn't appropriate
145549 # for use with servers that get config'd dynamically without
145516 # still staying JDBC-compliant and not doing anything that
145502 # Note that because we're caching things like callable statements
108967 es performance, while
37258 # A configuration that maximizes performance, while
27736 Code
...

まず先頭が com/mysql/jdbc/configs/maxPerformance.properties です。これは MySQL のパフォーマンスを最大限に引き出すために指定していた、JDBC のプロパティ useConfigs=maxPerformance によって読み込まれるプロパティファイルです。cybozu.com ではパフォーマンスのため、useConfigs=maxPerformance を指定しています。

参考: Properties Files for the useConfigs Option

その後、意味不明な文字列も出てきてはいますが(おそらくこれは普通のバイナリデータがたまたま文字列化できてるに過ぎないものでしょう)、それ以外の意味のある文字列、これらはまさに maxPerformance.properties の中身です!

この時点で、JDBC が犯人ではないかという疑いが濃厚になりました。
ただし確定とは言えません。これは単に文字列を出力しただけであって、本当は文字列データが現れてないところに巨大なバイナリデータがあるかもしれないからです。

コネクションリークではありません。トラブル発生時の時点で lsof で JVM がオープンしているファイルやネットワークの情報を見たのですが、コネクションリークは発生していませんでした。

上記のソート結果からわかることがもうひとつあります。 maxPerformance.properties の中身を読んで保持しているなら、どの行も行数が同じになるはずです。 ですが実際には行数はバラバラです。このことから、おそらく maxPerformance.properties の中身は読まれたものの、破棄されて別のメモリに上書きされて消えているのでは、との推測が立ちました。 つまり現在も参照されているような活きたメモリではなく死んだメモリであると言えそうです。

一方、依然として下記の謎は我々を苦しめました。

  • 複数台のサーバーで発生していたが、全てで発生したわけではない。
    また、発生したサーバーはいずれもアクセス頻度が少ないサーバーだった。

何がトリガーで本現象が発生するのか、なぜ同じ環境でもっとヘビーに使われているサーバーで発生しないのか、 その条件を見極めるため再現実験を行うことにしました。

多数の再現実験

cybozu.com では、サーバーに対して毎秒、DB が生きているかどうかの監視 API を叩いています。 この監視は、ロードバランサの内側からと外側から叩かれており、内側からだけ監視、外側からだけ監視、 両方から監視のパターンに分けて再現実験することにしました。また、JDBC のダウングレード版も試しました。

得られた結果のほとんどからは JDBC ドライバが犯人であるという仮説を支持するものになりました。 また、監視系統を半分にすればメモリ使用量の増加が抑えられることがわかり、どうやらコネクション数に関係しそうなことがわかってきました。

この実験中、useConfigs=maxPerformance を使用しない場合、再現しない(メモリ使用量が抑えられる)こともわかりました。

犯人に近づいてきています。

JDBC のプロパティ

メモリ消費量増大という現象を抑えるための一番簡単な対策は useConfigs=maxPerformance プロパティを外すことですが、それは cybozu.com のパフォーマンス劣化につながるのでその議論はほとんど話題にもあがりません。 調査隊全員が今のパフォーマンスを維持したままの問題解決を目指していました。

さて、問題の useConfigs=maxPerformance は、複数のプロパティの集合体です。 使われているプロパティは下記の通り。

cachePrepStmts=true
cacheCallableStmts=true
cacheServerConfiguration=true
useLocalSessionState=true
elideSetAutoCommits=true
alwaysSendSetIsolation=false
enableQueryTimeouts=false  

これらのうちのどれかがメモリリークを引き起こしてるのでしょう。各プロパティを個別に指定して再現実験を行い、現象が発生した環境のプロパティが犯人であると突き止めようとしたのです。 しかし、これらのプロパティを個別に試しても、メモリ消費量が増大する環境はありませんでした。

ということは複数プロパティの組み合わせで発生するのかもしれません。 二分法で試そうか、という話をしている時、ふと気になったことがあったのです。

そもそも、すべて同じパラメーターにしたら再現するだろうか?

useConfigs=maxPerformance を指定せず、個別のプロパティすべてを maxPerformance を指定した時と 同等のプロパティを指定して再現するか確かめることにしました。 結果は、再現せずメモリ消費量は抑えられたままでした。

犯人は useConfigs=maxPerformance を処理している付近に違いないとほぼ断定できました。 ここからは JDBC のソースコードの出番です。

JDBC ソースコード

useConfigs で指定したプロパティを読んでいる箇所は com.mysql.jdbc.NonRegisteringDriver の parseURL 付近です。 このあたりをくまなく読むと、怪しげな箇所がありました。

try {
    InputStream configAsStream = super.getClass().getResourceAsStream(
            "configs/" + configName + ".properties");

    if (configAsStream == null) {
        throw SQLError.createSQLException("Can't find configuration template named '" + configName
            + "'", "01S00", null);
    }

    configProps.load(configAsStream);
} catch (IOException ioEx) {

InputStream configAsStream に注目してください。これは maxPerformance.properties などを読むストリームですが、閉じていません。

おそらくこのクローズ漏れが犯人でしょう。ただ、気になることがありました。ここはコネクションを取得するたびに通るコードで、アクセスが無い環境ではあまり通らず、ヘビーに使われている環境ほどよく通るコードです。ではなぜヘビーに使われている環境では再現しないのでしょう?

とはいえここ以外に犯人がいるとも思えません。そこでこのほんの数行のコードの挙動をより深く追ってみます。 その結果、getClass().getResourceAsStream が返す InputStream は、BufferedInputStream にくるまれた FileInputStream が返されていることがわかり、FileInputStream は finalize メソッドで close 処理をしていました

"finalize を使ってはいけない" というプラクティスはもはや常識のものと思われましたが、ここには残っていました。

でもようやくわかりました。犯人はこのクローズ漏れに間違いありません。
犯人確保、です。

解明

先ほどのコードの実装上、finalize が呼ばれない限り close 処理が行われません。finalize が呼ばれる場合というのはすなわち GC が発生する場合です。GC が発生しない場合ずっとメモリ上に蓄積したままになります。

これで、ヘビーにアクセスされている環境では発生せず、ほとんどアクセスが無いサーバーで発生する理由がわかりました。ヘビーにアクセスされている環境では GC が発生するので定期的にリソースが close されてメモリも綺麗になりますが、アクセスが少ないサーバーでは GC が発生しないため、メモリ上にどんどん蓄積していくという現象だったのです。C ヒープの消費量が増大しても GC は発生しないのです。

次に、ヒープが正常だった理由にも納得です。FileInputStream はファイルの中身をヒープに確保するようなことはせず、native コードで読み出します。なのでヒープ上に現れないのですね。

useConfigs を使わなければ発生しないというのも当然です。ここは useConfigs で指定されたプロパティを読む部分なのですから。

メモリ上の死んだ部分という推測も合ってました。FileInputStream が読むときにいったん載るものの、読みだしてしまえばもう使われませんからね。

念の為、検算もしました。現象が再現する環境に対して外部から GC を定期的に発生させる状況を作れば、メモリ使用量は増えないはずです。ちなみに JDK7 からは jcmd によって外部から GC を発生させることができるようになりました。

参考: How do you Force Garbage Collection from the Shell?

これを使って環境を作りメモリ使用量を見たところ、無事メモリ使用量は抑えられたままでした。期待する検算結果そのものです。

かくして、我々を悩ませたメモリ大量消費の謎は、JDBC 内のクローズ漏れが原因ということで一見落着しました。

後処理として、MySQL にバグレポートを出し、直るまでのワークアラウンドとして useConfigs=maxPerformance を指定せず内部プロパティを展開して指定するよう改修しました。

まとめ

現象の解明まで紆余曲折ありました。JDBC が犯人ではない説、ロードバランサが犯人説、JVM が量子力学的振る舞いをしている説、幽霊説、etc・・・。なによりアクセス頻度が低いサーバーで現象が発生するという謎に悩まされましたが、結局はバグとしてはありがちなクローズ漏れでした。幽霊の正体見たり枯れ尾花、です。

我々は、アプリケーションサーバーをお客さんへの影響無しに再起動する機構を持っています。そのため、やろうと思えば「現象が発生したら再起動してメモリを綺麗にする」方式でも対応できました。でもそんなことはしたくありません。そんなことをすればインフラ部隊に工数が発生してしまいますし、なにより美しくありません。面白くもありません。今回の現象解明には少々時間がかかってしまいましたが、それでもこれで良かったと思っています。根本治療出来たのですから。

そしてもうひとつ、声を大にして言いたいことがあります。
finalize に 頼 っ て は い け な い

それでは、良い JVM ライフを!