Javaアプリケーションのパフォーマンス問題を解説|原因・対策・トラブルシューティングガイド

はじめに

Javaアプリケーションで構築されたシステムにおいて、レスポンス遅延やスループット低下、CPU高負荷といったパフォーマンス問題に直面することは少なくありません。しかし、原因が多岐にわたるため、問題の特定に苦労するケースも多くあります。
そこで本記事では、現場で役立つJava性能トラブルシューティングの実践方法を解説します。Javaアプリのパフォーマンス改善に取り組むエンジニアの方は、ぜひ参考にしてください。

Javaアプリケーションのパフォーマンス問題と原因

典型的なJavaアプリケーションにおけるパフォーマンス問題とその原因を整理します。

メモリ枯渇

メモリの枯渇により、以下のような影響が発生する可能性があります。

  • OutOfMemoryError の発生
  • スレッド処理の異常終了
  • スループットの低下
  • レスポンス時間の悪化

このような事象はサービス全体に深刻な影響を及ぼす可能性があるため、早急な原因調査と対処が必要です。
Javaアプリケーションにおいて、メモリ枯渇を考慮すべき領域は以下の通りです。

それでは、Javaアプリケーションで発生するメモリ枯渇の典型的な原因をご説明します。

ヒープ領域の枯渇

ヒープ領域とは、Javaアプリケーションが生成するオブジェクトを配置する領域で、JVMによって動的にメモリの確保と解放が繰り返されます。
ヒープ領域が枯渇した場合、ガベージコレクション(GC)によりメモリを解放しようとしますが、それでも追いつかない場合は OutOfMemoryError が発生します。

典型的な原因は以下です。

  • メモリリーク
  • 無限再帰によるオブジェクト生成

※ガベージコレクションによるメモリ解放の対象はヒープ領域となります。ヒープ領域のメモリが枯渇すると、Full GCを含むガベージコレクションが頻発して更なるCPU使用率上昇、レスポンス遅延、スループット低下につながるので要注意です。ガベージコレクションについては以下で説明しているので、興味がある方はぜひ参照してみてください。
Java初心者必見!メモリ管理とガベージコレクションを基礎から理解する

メタスペースの枯渇

メタスペースとは、クラス定義情報などのメタデータが配置される領域です。メタスペースが枯渇すると OutOfMemoryError が発生します。メタスペースはガベージコレクションでは解放されないケースが多いです。

典型的な原因は以下です。

  • クラスの動的ロードが解放されず、クラス定義が蓄積する
  • 短時間に大量のスレッドが生成される

コードキャッシュの枯渇

コードキャッシュ(Code Cache)とは、JIT(Just-In-Time)コンパイルにより生成されたネイティブコードを保持する領域です。コードキャッシュが枯渇すると、新規のJITコンパイルが停止し、対象メソッドはインタプリタ実行に戻るため、アプリケーションのパフォーマンスが急激に悪化します。

典型的な原因は以下です。

  • JITコンパイル対象のメソッドが多すぎる
  • 動的生成コードが多い(Proxy、Lambda、JSPなど)

Cヒープの枯渇

Cヒープ(C Heap)とは、C/C++で書かれたネイティブコードが malloc() や new によって動的に確保するメモリ領域です。JVM 自体は C/C++ で実装されており、Javaヒープ以外にも内部構造体・バッファ・JITコンパイル結果などを保持するためにCヒープを使用します。Cヒープが枯渇すると、OutOfMemoryError が発生したり、場合によっては OOM Killer により JVM が強制終了されることがあります。

典型的な原因は以下です。

  • C/C++コードの不適切な実装によるメモリリーク
  • DirectByteBufferなどのネイティブバッファの過剰使用
  • スレッドの過剰生成によるスタック領域の逼迫

スレッド枯渇

スレッドとは、一連の処理の最小単位のことです。Javaアプリケーションでは、複数のスレッドにて処理が並列実行されています。このスレッドが枯渇して割り当てできなくなると、新しいリクエストを処理できずにスループットやレスポンスが劣化します。設計によってはタイムアウトが発生してエンドユーザにエラーが返却されることがあります。
スレッドが枯渇する典型的な原因は以下となります。

バーストトラフィック

瞬間的なリクエスト集中により、スレッドが一時的に使い切られることがあります。その結果、一時的なリクエスト滞留やレスポンス遅延が発生します。スレッド数の上限値設定をチューニングしたり、スケールアウトによる負荷分散設計の改善も検討してみてください。

スレッドが解放されない

以下のような理由により、スレッドが解放されず、新規リクエストを処理できない状態になることが想定されます。その結果、CPU使用率が上昇するケースもあります。

  • 無限ループ
  • 長時間処理スレッドの存在
  • デッドロック(スレッド間の相互待ち)
  • コネクションプール枯渇(DB接続リーク)

これらの問題はいずれも、スレッドが「待ち状態」や「処理継続中」のまま停止せずに残ることが原因です。

Javaアプリケーションのパフォーマンス問題における解析の流れ

Javaアプリケーションのパフォーマンス問題を解析する際には、いきなりコード修正や設定変更に手を入れるのではなく、まず「どの層で問題が発生しているか」を明確にすることが重要です。
アプリケーション層なのか、ミドルウェア層やインフラ層に起因するのかを切り分けることで、原因特定が迅速に行えます。

以下のステップに沿って、原因を段階的に絞り込みましょう。
ステップ1:事象の明確化
ステップ2:OS関連のメトリクスの確認
ステップ3:Java関連のメトリクスの確認
ステップ4:ログ解析
ステップ5:ダンプ情報の解析

ステップ1:事象の明確化

まずは、発生している事象をできるだけ具体的に整理します。

  • どのタイミングで発生するか(特定の時間帯、一定時間稼働後など)
  • 負荷状況との関連(高負荷時のみ発生するのか、時間経過により発生するのか)
  • どの処理・画面・APIで再現するのか
  • レスポンスタイムやスループットがどの程度悪化しているか

これらを明確にすることで、問題を再現性のある形で扱えるようになります。

ステップ2:OS関連のメトリクスの確認

サーバOS全体のリソースとして、CPU、メモリ、I/O関連のメトリクスを確認して、ボトルネックとなっているリソースを特定してください。top、ps、sar などのコマンドによる確認が有効です。
OS関連の情報取得および解析方法については、以下の記事でまとめているのでぜひ参照ください。
LinuxのCPU使用率が高い原因と対処法|商用システムにおける実践的トラブルシューティング

ステップ3:Java関連のメトリクスの確認

続いて、JVMレイヤにおける各種メトリクスを確認します。スレッド数、DBコネクション数などのメトリクスを収集し、ボトルネックを特定します。

スレッド数

jcmd コマンドを実行することで、生成されているスレッド数を取得することができます。

[root@quiz ~]# jcmd 311127 Thread.print | grep "^\"" | wc -l
133

“runnable” で grep することで、現在利用中のスレッド数を確認することができます。runnableは実際にCPUで処理をしているか、I/O待ちになっている状態を示しています。

[root@quiz ~]# jcmd 311127 Thread.print | grep "^\"" | grep runnable | wc -l
23

あるいは、JMXを有効にしていれば下記の通り取得可能です。

$>bean java.lang:type=Threading
#bean is set to java.lang:type=Threading
$>
$>get ThreadCount
#mbean = java.lang:type=Threading:
ThreadCount = 35;
$>
$>get PeakThreadCount
#mbean = java.lang:type=Threading:
PeakThreadCount = 159;

以下の情報が取得することができます。

  • ThreadCount:生成中のスレッド数
    ※JMXでは実行中(runnable)のスレッド数は取得できません。jcmd コマンドなどを使って情報取得してください。
  • PeakThreadCount:JVM起動後の最大スレッド生成数

JMXにて情報取得する方法は以下に記載していますので、ぜひ参照ください。
TomcatでJMXを有効化する方法まとめ|SSL設定・認証・監視ツール連携まで解説

DBコネクション数

DBコネクション数についてはJVMから直接確認することはできません。ただし、コネクションプールを使用している場合はJMXにて確認可能です。ここでは Tomcat JDBC において確認する方法を一例としてご紹介します。

$> domain org.apache.tomcat.jdbc.pool
$> beans
#org.apache.tomcat.jdbc.pool.jmx.ConnectionPool[
  name="jdbc/PostgresDS",
  type=ConnectionPool
]
$> get numActive
#mbean = org.apache.tomcat.jdbc.pool.jmx.ConnectionPool[
  name="jdbc/PostgresDS",
  type=ConnectionPool
]
numActive = 7;
$> get numIdle
#mbean = org.apache.tomcat.jdbc.pool.jmx.ConnectionPool[
  name="jdbc/PostgresDS",
  type=ConnectionPool
]
numIdle = 8;

以下の情報が取得することができます。

  • numActive:使用中のDBコネクション数
  • numIdle:アイドル中のDBコネクション数

あるいは、データベース側からDBコネクション数を確認するのも有効です。一例として、PostgreSQLにおいて情報取得する方法をご紹介します。ps_stat_activity の Active、idle 項目を確認してください。

postgres=# SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state;
 state  | count
--------+-------
        |     5
 active |     4
 idle   |     7
(3 rows)

ステップ4:ログ解析

Javaパフォーマンス問題発生時に主に確認べきなのは業務アプリケーションのログとGCログとなります。それぞれご説明します。

業務アプリケーションログ

パフォーマンス問題が生じている時間帯に、エラーや想定外の処理が発生していないかを確認しましょう。例えば、同一のスタックトレースが出力されていたらそこを詳細に解析します。
また、通常時と比べてレスポンスタイムが長くなっている処理がないか、確認をしてください。レスポンスタイムが長い処理を特定したら、各種ログを確認することで、下記のどこで時間がかかっているのかを見極めましょう。
– Javaアプリケーションの処理
– データベースクエリ
– 外部API処理

GCログ

GCログを確認し、GC処理に問題があるかどうかを確認してください。サンプルで、Full GCを意図的に発生させた際のログを記載します。本ログにおいて、確認すべき点をご説明します。

[2025-12-07T08:13:34.555+0900][info ][gc,start    ] GC(173) Pause Full (Diagnostic Command)
[2025-12-07T08:13:34.555+0900][info ][gc,task     ] GC(173) Using 4 workers of 4 for full compaction
[2025-12-07T08:13:34.556+0900][info ][gc,phases,start] GC(173) Phase 1: Mark live objects
[2025-12-07T08:13:34.601+0900][info ][gc,phases      ] GC(173) Phase 1: Mark live objects 45.053ms
~~Truncated~~
[2025-12-07T08:13:34.680+0900][trace][gc,age         ] GC(173) - age  14:     161880 bytes,    2384664 total
[2025-12-07T08:13:34.680+0900][trace][gc,age         ] GC(173) - age  15:      71984 bytes,    2456648 total
[2025-12-07T08:13:34.680+0900][info ][gc,heap        ] GC(173) Eden regions: 25->0(98)
[2025-12-07T08:13:34.680+0900][info ][gc,heap        ] GC(173) Survivor regions: 3->0(14)
[2025-12-07T08:13:34.680+0900][info ][gc,heap        ] GC(173) Old regions: 49->49
[2025-12-07T08:13:34.680+0900][info ][gc,heap        ] GC(173) Humongous regions: 0->0
[2025-12-07T08:13:34.680+0900][info ][gc,metaspace   ] GC(173) Metaspace: 86226K(87040K)->86226K(87040K) NonClass: 75307K(75776K)->75307K(75776K) Class: 10919K(11264K)->10919K(11264K)
[2025-12-07T08:13:34.680+0900][info ][gc             ] GC(173) Pause Full (Diagnostic Command) 75M->47M(164M) 124.598ms
[2025-12-07T08:13:34.680+0900][info ][gc,cpu         ] GC(173) User=0.33s Sys=0.02s Real=0.12s
[2025-12-07T08:13:34.680+0900][info ][safepoint      ] Safepoint "G1CollectFull", Time since last: 264865951 ns, Reaching safepoint: 46414 ns, Cleanup: 6428 ns, At safepoint: 124750596 ns, Total: 124803438 ns
[2025-12-07T08:13:34.555+0900][info ][gc,start ] GC(173) Pause Full (Diagnostic Command)

Pause Full という文言があったら、Full GCが発生していることを意味しています。Full GCの発生頻度が多くなっていないか確認しましょう。今回は意図的にFull GCを発生させているので Diagnostic Command という表示になっていますが、これが G1 Compaction、Allocation Failure、G1 Evacuation Failure といった文言で出力され始めたら要注意です。

[2025-12-07T08:13:34.680+0900][info ][gc ] GC(173) Pause Full (Diagnostic Command) 75M->47M(164M) 124.598ms

メモリが回収されているかを確認してください。ここでは75Mから47Mに減少しているので、問題なくメモリが解消されています。
また、Full GC の処理時間も確認してください。ここでは 124.598ms となっており問題ありませんが、処理時間が1秒以上になってくる場合は注意が必要です。

[2025-12-07T08:13:34.680+0900][info ][gc,heap ] GC(173) Eden regions: 25->0(98)
[2025-12-07T08:13:34.680+0900][info ][gc,heap ] GC(173) Survivor regions: 3->0(14)
[2025-12-07T08:13:34.680+0900][info ][gc,heap ] GC(173) Old regions: 49->49
[2025-12-07T08:13:34.680+0900][info ][gc,heap ] GC(173) Humongous regions: 0->0
[2025-12-07T08:13:34.680+0900][info ][gc,metaspace ] GC(173) Metaspace: 86226K(87040K)->86226K(87040K) NonClass: 75307K(75776K)->75307K(75776K) Class: 10919K(11264K)->10919K(11264K)

各種メモリ領域の容量が正常な値か確認しましょう。特にメモリリークが発生して、Old領域が枯渇した場合、Full GC が頻発する可能性がありますので注意してください。

[2025-12-07T08:13:34.680+0900][info ][gc,cpu         ] GC(173) User=0.33s Sys=0.02s Real=0.12s

CPU使用率も確認できますので、合わせて確認してみてください。

※GC発生に関する情報は重要な情報となりますので、GCログとしてファイルに出力しておくことを推奨します。
一例としてtomcatにおける設定方法をご紹介します。setenv.shに以下の設定を加えて再起動することで、GCログが出力されます。

[root@quiz ~]# cat /opt/tomcat/bin/setenv.sh
export JAVA_HOME=/opt/java/jdk-21.0.2
export JRE_HOME=$JAVA_HOME
export PATH=$JAVA_HOME/bin:$PATH
## GC Log Setting
CATALINA_OPTS="$CATALINA_OPTS \
-Xlog:gc*,gc+heap=info,gc+age=trace,safepoint:file=$CATALINA_BASE/logs/gc.log:time,level,tags:filecount=5,filesize=10M"
[root@quiz ~]#

ステップ5:ダンプ情報の解析

ステップ3までの解析で原因が特定できない場合は、以下のダンプ情報を取得し、詳細に解析してみて下ください。

  • スレッドダンプ
    スレッドの状態や、スタック情報を取得することができます。これにより、スレッド競合やデッドロックの有無を確認することが可能です。
  • ヒープダンプ
    ヒープ内のオブジェクト、メモリ使用状況を取得することができます。これにより、不要オブジェクトの蓄積やメモリリークを特定することが可能です。
  • コアダンプ
    プロセスが異常終了した時点のメモリ内容・レジスタ・スレッド情報を取得できます。これにより、JVMクラッシュやネイティブライブラリの不正アクセスなど、OSレベル・JVM内部の深い原因を特定することが可能です。

各種ダンプの取得方法、解析方法については下記の記事にまとめているので、ぜひ参考にしてください。
Javaパフォーマンス問題を徹底解析|ヒープダンプ・スレッドダンプ・コアダンプの取得と解析方法

まとめ

本記事では、Javaアプリケーションで発生する代表的なパフォーマンス問題(メモリ枯渇、スレッド枯渇、コードキャッシュ/Cヒープの問題、コネクションプール枯渇 など)と、現場で使える解析手順を整理しました。ポイントを短くまとめると次の通りです。

  • まずは層の切り分け:アプリケーション層・ミドルウェア層・インフラ層のどこでボトルネックが起きているかを明確にすることが最重要。ここがぶれると無駄な対処を重ねて時間を浪費します。
  • 観測データを揃える:OSメトリクス(CPU/メモリ/I/O)、JVMメトリクス(ヒープ/メタスペース/スレッド数/JIT/code cache 等)、ログ(業務ログ・GCログ)、ダンプ(スレッド/ヒープ/コア)を時系列で揃えて因果関係を確認する。
  • GCログを出力しておく:Full GC の頻度・回収量・処理時間は重要指標。長時間のStop-the-Worldはサービス影響につながるため、異常を見つけ次第詳細調査を。
  • 短時間での切り分け手順を確立する:(1)事象の明確化→(2)OS確認→(3)JVM確認→(4)ログ解析→(5)ダンプ解析、という順序をテンプレート化しておくと再現性と効率が上がる。
  • よくある原因と対処イメージ
    • ヒープリーク → ヒープダンプ解析で長生きオブジェクトを特定、コード修正またはメモリ設定調整。
    • スレッド枯渇 → スレッドダンプ + コードレビュー(無限ループ・同期競合・外部待ちの確認)/スレッドプール設定の見直し。
    • コードキャッシュ枯渇 → 動的生成コードの削減、JIT関連オプションの調整。
    • Cヒープ(ネイティブ)問題 → ネイティブバッファや外部ライブラリの使用を確認、OSレベルの監視強化。
  • 運用での予防:メトリクス収集・アラート(GC長時間、スレッド数急増、DBコネクション枯渇など)を整備し、負荷テストでしきい値を事前に把握しておく。
  • 問題対応の心構え:急な設定変更や修正を繰り返すより、まず観測→仮説→検証(再現)→対策の順で進める。部分最適で済ませると再発しやすい。

コメント