ヌーラボでScalaを書くRubyistの谷本です。ヌーラボでは、Backlogの開発を担当しており、最近ではBacklogをJavaからScala / Play Frameworkに移行するプロジェクトのメンバーでした。
BacklogのPlay化プロジェクトでは、OutOfMemorryError(以下、OOM)の発生やCPU使用率とロードアベレージが上がったままという、Java Virtual Machine(以下、JVM)上で動くBacklogのパフォーマンスに関する問題に対処すべく、何度かHeap/Thread dumpを見る機会がありました。
私がPlay化プロジェクトで取り組んだパフォーマンス改善の知見や経験をもとに、本記事では「JVMで起こったパフォーマンスの問題の切り分け方」についてお届けします。
目次
はじめに
本番環境でしばらく動かしていると、コード自体は正しく実行できるけど、ある特徴を持ったデータと実装方法がマッチせずOutOfMemorryError(以下、OOM)が発生したり、CPU使用率とロードアベレージが上がったままになったりすることがあります。
BacklogのPlay化プロジェクトでは、そういったパフォーマンスの問題の原因調査のために何度かHeap/Thread dumpを見る機会があり、その都度、どうすれば切り分けれるか考えたり勉強したりしました。
数年前にJVMの仕事をはじめたときや、Backlogの開発に携わり始めたときを振り返ってみると、こうしたパフォーマンスの問題の切り分け方を知っていれば、もっと素早く解決できただろうと感じます。
そこで、この記事では、数年前の自分のように「GCやHeap領域の名前や役割はなんとなく知っているけど、Heap/Thread dumpで情報がたくさん出てきて、何に注目すれば問題の解決につながるのかよくわからない」と感じている人に向けて、どういったことに注目して調査したのかについてご紹介します。
OOM発生時とCPU使用率の上昇時のそれぞれで、いくつかの注目する点と簡単な再現コードを使った問題の切り分け例がありますので、お好きなところからお読みください。
OOMの原因を探る
OOMが起きる原因は、Oracleのドキュメントにあるように色々な原因があります。
おそらく、起動後しばらく安定して動いている状態であれば、クラスメタデータが読み込み終わっており、Metaspace由来などのOOMは起きづらく、OOMの原因はHeap領域由来のものが多いでしょう。 Backlogで起こるOOMも、大半がHeap由来のGC Overhead limit exceededでした。
そこで、今回はGC Overhead limit exceededに絞って説明します。
なお、OOM発生時にHeap dumpが出力される起動オプションを設定をしておきましょう。これがないとOOMが起こってもHeap dumpがなく切り分けが進みません。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/your/dumping/path
注目する点1. なぜ、OOMが発生したのか
まず、エラーのメッセージを手がかりに、どういうことが起こったからOOMが発生したのかを考えます。
GC Overhead limit exceededは、Full GCを何回もして掃除しているけど空き領域がほぼ確保できない状態が続いていることを表すエラーです。つまり、“処理を進めるためにはHeap領域が足りない状態だが、参照が残っていてHeap領域がFull GCでもほぼ空かない状態”だと言えるでしょう。
このため、参照が残っているオブジェクトがHeap領域を圧迫している理由を探せば解決できそうだと予想できます。
注目する点2. なにがHeap領域を圧迫しているか
Heap領域を圧迫しているオブジェクトを調べたいので、VisualVMやEclipse Memory Analyzer(MAT)などのツールを使いHeap dumpの中身をみて、Heap上でサイズが大きなものを探します。
ただし、Heap上でのサイズというのは少し厄介で、オブジェクトが参照している他のオブジェクトまで含めたサイズを見ないと、そのオブジェクトがGCされたときのサイズが予想しづらくなります。例えば、JavaのHashMapのインスタンス自体は48Bと小さなオブジェクトです。しかし、そのインスタンスが参照するオブジェクトが数MBあり、その参照されているオブジェクトが他から参照されていなければ、このHashMapのインスタンスがGCされると数MBの空きができます。
MATのHistgramには、参照先まで含めたサイズを表示するRetained Heapという項目があります。Retained Heapが大きい順や、並べたとき見覚えのあるクラス名順に見ていくと、参照先が大きかったり大量にあったりするような、Heap領域を圧迫していそうなオブジェクトが見つかります。
VisualVMもインスタンスタブの中にある「保持された大きさを計算」をクリックすると同様の情報が表示できます。ちなみに、両方ともツール自体にメモリを十分に与えていないと表示できないので気を付けましょう。
注目する点3. なぜ圧迫するオブジェクトができたか
Heap領域を圧迫していそうなオブジェクトが見つかったら、それがなぜできたかを考えます。なぜを知るためには、そのオブジェクトがどういう役割のオブジェクトなのか理解して仮説をたてます。
例えば、リスト構造を表すようなオブジェクトの場合はどういう役割で使われていて、どういうときに要素が追加されるのかとか、Threadを表すオブジェクトのローカル変数の場合は、他のローカル変数の中身やスレッド名から何の処理をやっているスレッドなのかというのを理解し、どういうときにそのローカル変数は大きなものになるのかなどです。
そして、もしその仮説が成り立つなら他のオブジェクトやマシンリソースにどういう影響があるを考え、その通りになっていたのか調べ、矛盾がないか検証をします。
実際にOOMを発生させてHeap dumpを見てみる
上記の注目する点を踏まえて、実際に本番環境で後続処理が遅いときに起こった現象を参考にしたコードを実行して、OOMを発生させてみます。
実行したコードはこちらです。 簡単に説明すると、すこし大きめのオブジェクトを生成(getLargeObject)して、それを時間がかかる処理(executeHeavyTask)に渡し、それぞれは別のスレッドプールを使って実行しているというコードです。
コンパイルしたものを、以下のように実行して、しばらく(自分のローカルのdocker上では3分ほど)経つとOOMが発生しました。
まず、エラーメッセージはGC overhead limit exceededだったので、何がHeap領域を圧迫しているのか探ってみます。
MATのHistgramでRetained Heap順にすると、オブジェクトの個数のわりにThreadPoolExecutorとLinkedBlockingQueueのインスタンスが大きそうなことがわかります。
そこで、右クリックメニューのList objects > with outgoing referencesでThreadPoolExecutorとLinkedBlockingQueueのリストを表示して詳細を調べます。
大きなThreadPoolExecutorの詳細を見ると、大きなLinkedBlockingQueueを持っていることがわかります。 ThreadPoolExecutorは問題ではなくこのLinkedBlockingQueueがHeap領域を圧迫していることがわかりました。
では、なぜ、Heap領域を圧迫するほど大きなLinkedBlockingQueueができたのかを考えてみます。 そもそも、この大きなLinkedBockingQueueはスレッドプールのタスクを保持するQueueです。
そして、QueueのNodeの中身をのぞいていくとLargeObjectを持っていることから、LargeObjectを使うタスクがたまっていることがわかります。
LargeObjectを使うメソッドはexecuteHeavyTaskしかいないので、executeHeavyTaskが遅いことやexecuteHeavyTaskを実行するスレッドが2つしかないことが原因で、処理が追い付かずQueueにタスクがたまり続けHeap領域を圧迫していると仮説を立てることができます。