「うわっ…私のアプリ、遅すぎ…?」 スレッドダンプでJavaアプリケーションのボトルネックを調査しよう


こんにちは、寺岡です。
今回は、アプリケーションのトラブルを調査する際に役立つスレッドダンプについて書いてみたいと思います。

スレッドダンプって何?

スレッドダンプとは、ある時点の全てのスレッドのスタックトレースを出力したものです。

スタックトレースとは現在のスレッドのメソッドの呼び出し階層を出力したもので、例外が発生した時なんかによく見かけるアレのことです。

javaプロセス内部では複数のスレッドが並列に処理を行っています。
Tomcatなどのサーブレットは複数のスレッドを使ってリクエストを処理しますし、シングルスレッドのシンプルなアプリケーションでもGCなどの処理は専用のスレッドが処理を行っています。

スレッドダンプを取得するとは、プロセス内の全スレッドに対して「ちょっとお前ら、今何やってるの?」と聞いて回るようものです。

スレッドダンプを取る前に……

とりあえずtopコマンドくらいは叩いておきましょう。

top - 00:53:32 up 38 days, 3:30, 4 users, load average: 3.04, 1.82, 1.61
Tasks: 406 total, 3 running, 403 sleeping, 0 stopped, 0 zombie
%Cpu(s): 86.4 us, 7.6 sy, 0.0 ni, 4.3 id, 0.0 wa, 0.0 hi, 1.6 si, 0.0 st
KiB Mem : 2064120 total, 352624 free, 1248168 used, 463328 buff/cache
KiB Swap: 2097148 total, 1442796 free, 654352 used. 583676 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
19387 sandbox 20 0 3039060 245548 13684 S 152.5 11.9 1:38.57 java
40566 teraoka 20 0 63676 40188 964 S 6.9 1.9 303:30.49 tmux
858 root 20 0 4372 476 452 S 3.0 0.0 768:09.91 rngd
31175 root 20 0 0 0 0 R 3.0 0.0 422:30.34 kworker/0:2
3860 root 20 0 22564 1380 924 S 2.0 0.1 183:22.41 tmux
73 root 20 0 0 0 0 S 1.0 0.0 19:25.96 rcu_sched
74 root 20 0 0 0 0 R 1.0 0.0 20:06.80 rcuos/0
1840 root 20 0 687276 3560 2000 S 1.0 0.2 268:23.17 forticlientsslv
13828 sandbox 20 0 130160 2028 1272 R 1.0 0.1 0:00.09 top
1 root 20 0 139280 3324 1532 S 0.0 0.2 0:40.86 systemd
2 root 20 0 0 0 0 S 0.0 0.0 0:00.58 kthreadd
3 root 20 0 0 0 0 S 0.0 0.0 1:33.16 ksoftirqd/0
5 root 0 -20 0 0 0 S 0.0 0.0 0:00.00 kworker/0:0H
7 root rt 0 0 0 0 S 0.0 0.0 1:32.83 migration/0

特に気をつけてみたい箇所に背景色をつけています。

右上のロードアベレージがCPUコア数を大幅に超過しており、プロセスのCPU利用率が高い状態が続いている場合、そのプロセスが何らかの計算処理でCPUを使いきっている可能性が高いです。
CPU利用率の最大はCPUコア数×100%となることに注意しましょう。

CPUに余裕があるのに処理が遅い場合、ディスクIO、ネットワーク転送やロック待ちなどに原因がある可能性があります。ディスクIOに問題がある場合はロードアベレージとiowaitが共に高くなる傾向がありますので気に留めておくと良いでしょう。

topコマンドで調査できる事はまだありますが、問題の処理が終わってしまう前に急いでスタックトレースの取得にかかります。
出力内容をファイルに書き出しておいて、後で落ち着いて確認するのも良いでしょう。

また、スレッドダンプを取得するにはまずjavaプロセスのプロセスIDを調べる必要があります。
ctrl+c でtopコマンドを抜ける前に、疑わしいjavaプロセスのプロセスIDをメモ(or クリップボードにコピー)しておくようにしましょう。
プロセスIDはpsコマンドや、javaプロセスのプロセスIDを調べるためのjpsコマンドで取得しても構いません。

スレッドダンプの取得

javaプロセスのスレッドダンプを取るにはjstackコマンドを利用します。

$ jstack {プロセスID}

jstackコマンドにパスが通っていない場合はjstackコマンドを探してフルパスで実行してください。
普通はJavaのインストールディレクトリのbinの中(例えば/usr/java/jdkXXXX/bin/jstack)にあります。
また、jstackコマンドの実行は、対象のjavaプロセスを実行しているユーザで行うようにしましょう。

スレッドダンプはjavaプロセスにSIGQUITシグナルを送信することでも取得できます。
この場合は、スレッドダンプはjavaプロセスの標準出力やエラー出力に出力されますので、ログファイルなどから確認することになります。
プロセスに間違ったシグナルを送ってしまう危険なども考慮すると、可能であればjstackコマンドを利用したほうが良いでしょう。

スレッドダンプの取得には、それほど大きなオーバーヘッドを必要としません。
「何かおかしいかも?」と感じた場合は、問題のスレッドがボトルネックとなる処理を抜けてしまう前に取得しておくと良いでしょう。
以下のように、適度に間隔を開けて数回取得してファイルに保存しておくと調査の際に有用です。

$ #以下のコマンドを数秒おきに実行してスレッドダンプを取得する
$ jstack {プロセスID} > threaddump.$(date +%F-%H%m%S)

スレッドダンプと似たようなものに、現在のメモリ状況を出力するヒープダンプがあります。
こちらもメモリリークなどの調査に役立つ情報ですが、ヒープダンプを取得する際、利用中のメモリ量に応じて停止時間が発生するため十分注意するようにしましょう。

スレッドダンプを調べる前に……

マルチスレッドアプリケーションでは沢山のスレッドが協調して処理を行っているため、可能であれば問題のスレッドのスレッドIDを特定しておきたいところです。
アプリケーションのログファイルなどを見て問題のスレッドに目星をつけておくと調査がぐっと楽になります。

スレッドダンプを読む

ログなどから問題のスレッドIDが分っている場合は、対象のスレッドのスタックトレースを、時系列を追って調べていくと良いでしょう。

スレッドダンプを見る場合は、複数回取得して内容を比較するようにしましょう。
一回スレッドダンプを取得しただけでは、対象のスレッドが偶然ボトルネックになっていないメソッドで処理を行っていた際のスタックを取得してしまう可能性があります。
一定の間隔取得したスレッドダンプを調べることで、処理に時間がかかっているメソッドを特定することができます。

問題のスレッドが分っていないときには、全スレッドのスタックトレースから疑わしいスレッドを絞り込んでいくことになります。
この時も、各スタックトレースの上部に記されているスレッドのStateや、実行しているミドルウェアのスレッド名のパターンなどからある程度対象スレッドを絞り込むと良いでしょう。

続いて、ありがちなトラブル発生時の実際のスタックトレースを例示してみます。

デッドロック


以下はJavaのsynchronized文を入れ子にして、デッドロックを発生させた際のスレッドダンプです。

*snip*
Found one Java-level deadlock:
=============================
"Thread-7":
waiting to lock monitor 0x00007fc1b80cdbe8 (object 0x00000000f5f03a60, a java.lang.Object),
which is held by "Thread-6"
"Thread-6":
waiting to lock monitor 0x00007fc1b80cc5e8 (object 0x00000000f5f03a70, a java.lang.Object),
which is held by "Thread-7"

Java stack information for the threads listed above:
===================================================
"Thread-7":
at com.techscore.sandbox.Servlets$DeadLock.lambda$doGet$1(Servlets.java:68)
- waiting to lock <0x00000000f5f03a60> (a java.lang.Object)
- locked <0x00000000f5f03a70> (a java.lang.Object)
at com.techscore.sandbox.Servlets$DeadLock$$Lambda$3/236118405.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
"Thread-6":
at com.techscore.sandbox.Servlets$DeadLock.lambda$doGet$0(Servlets.java:60)
- waiting to lock <0x00000000f5f03a70> (a java.lang.Object)
- locked <0x00000000f5f03a60> (a java.lang.Object)
at com.techscore.sandbox.Servlets$DeadLock$$Lambda$2/1872773875.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

Found 1 deadlock.

Javaレベルでのデッドロックは、スレッドダンプ取得時に自動的に検出してくれるので、スレッドダンプの最後を見ればすぐに分ります。
この場合、外部からロックを解除することは難しいため、アプリケーションを再起動することになるでしょう。

ネットワークIO待ち


次は、長時間のSQLクエリの発行や、API通信などにより、スレッドがネットワークのレスポンスをまっている場合のスタックトレースです。

*snip*
"http-nio-8080-exec-1" #24 daemon prio=5 os_prio=0 tid=0x00007fc1bc003000 nid=0x4dc0 runnable [0x00007fc1d833f000]
java.lang.Thread.State: RUNNABLE
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
at java.net.SocketInputStream.read(SocketInputStream.java:170)
at java.net.SocketInputStream.read(SocketInputStream.java:141)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:246)
at java.io.BufferedInputStream.read1(BufferedInputStream.java:286)
at java.io.BufferedInputStream.read(BufferedInputStream.java:345)
- locked <0x00000000f5d13d88> (a java.io.BufferedInputStream)
at sun.net.www.http.HttpClient.parseHTTPHeader(HttpClient.java:704)
at sun.net.www.http.HttpClient.parseHTTP(HttpClient.java:647)
at sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1536)
- locked <0x00000000f5cce298> (a sun.net.www.protocol.http.HttpURLConnection)
at sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1441)
- locked <0x00000000f5cce298> (a sun.net.www.protocol.http.HttpURLConnection)
at com.techscore.sandbox.Servlets$NetworkIO.doGet(Servlets.java:45)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:622)
*snip*

スタックトレースの先頭を見ると、このスレッドは現在socketRead0メソッドの処理中のようです。
何度スレッドダンプを取得しても常に同じスタックトレースが出力されるようなら、ネットワークのレスポンスを待ち続けている可能性が高いでしょう。

この場合、「大量のデータを取得しようとしている」「通信先のネットワーク処理が長くレスポンスが遅延している」「通信先との接続切れを検知できずに待ち続けている」など様々な要因が想定されます。
状況に応じて必要な対応は変わってきますので、処理の内容や(可能であれば)通信先サーバの状況を調査して判断する必要があります。

通信の切断を検知できなかった場合、ネットワーク通信のタイムアウトが設定されていないのであれば、永遠にレスポンスを待ち続けてしまうことになります。
JavaのHTTPUrlConnectionなどはデフォルトのタイムアウトが0(無限)となっていますので、適切な値を設定するようにしましょう。

UrlConnection#setConnectTimeout
UrlConnection#setReadTimeout

ループ中の正規表現コンパイル


こちらは、ループ中で正規表現のコンパイル処理を行った際のスタックトレースです。

*snip*
"http-nio-8080-exec-1" #24 daemon prio=5 os_prio=0 tid=0x00007f17c0003000 nid=0x487d runnable [0x00007f17f5103000]
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern.atom(Pattern.java:2226)
at java.util.regex.Pattern.sequence(Pattern.java:2130)
at java.util.regex.Pattern.expr(Pattern.java:1996)
at java.util.regex.Pattern.compile(Pattern.java:1696)
at java.util.regex.Pattern.(Pattern.java:1351)
at java.util.regex.Pattern.compile(Pattern.java:1028)
at java.lang.String.replaceAll(String.java:2223)
at com.techscore.sandbox.Servlets$Regex.doGet(Servlets.java:87)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:622)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:729)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:291)
*snip*

デッドロックやネットワーク待ちと違い、この場合はCPU処理がボトルネックになります。
本記事で例示したtopコマンド結果のCPU利用率が高くなっているのは、この処理を実行中に取得したためです。

CPU処理で時間がかかっている場合、処理の進行の応じてスタックトレースが大きく変化していきますので、何度もスレッドダンプを取得してボトルネックを分析するようにしましょう。

スタックトレースを見ると分りますが、この処理ではString#replaceAll内で正規表現のコンパイルが行われてます。
Stringには引数にStringで正規表現パターンを指定するメソッドがいくつかありますが、これらは内部で正規表現のコンパイルが行われていることに注意しましょう。
大量に呼び出されない場合は大きなボトルネックにはなりませんが、ループ中に実行された場合は無視できない処理量になります。
コンパイル済みのPatternをstaticフィールドに用意してPattern#matcherを使うなど、可能な限りコンパイル処理の回数を減らすようにするべきです。

まとめ

今回サンプルで用意したスレッドダンプを取得した際に実行したサーブレットをgistにあげておきましたので興味のある方はご覧下さい。

スレッドダンプを調査するには、現象を再現させている間に複数回取得することが重要です。
根気強くjstackを叩き続け、お宝(問題再現中のスレッドダンプ)を掘り当てることができれば、アプリケーションの改善に大いに役に立ってくれるはずです。


コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です