0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

スタックとヒープの違いをJavaコードで理解する:メモリ障害の原因と調べ方

0
Posted at

Javaのプログラムがメモリ関連のエラーで止まったとき、StackOverflowErrorならメソッド呼び出しの深さを確認します。
ヒープ不足によるOutOfMemoryErrorなら、ヒープに何がどれだけ残っているかを調べます。
どちらもメモリに関係するエラーですが、調査の入口はかなり違います。

スタックとヒープがコードの実行中にどう使われるかを追うと、この違いが何となく見えてきます。

この記事では、短いJavaコードを実行順にたどります。メソッド呼び出しに合わせてスタックが変化する様子と、newしたオブジェクトがヒープに残る条件を対応させます。

後半は、メモリ障害を「呼び出しの深さ」「保持する量」「参照の寿命」の三つに分け、設計や調査で確認する場所へつなげます。

最初に、スタックとヒープの違いを大まかに見ておきます。

観点 スタック ヒープ
主な役割 メソッドを呼び出すたびに、その処理に必要な作業領域を積む newしたオブジェクトや配列を保持する
Javaでの単位 一つの処理の流れ(スレッド)ごとに用意される Javaの実行環境(JVM)内のスレッドで共有される
増えるきっかけ メソッドを呼び出す オブジェクトや配列を作る
減るきっかけ メソッドの処理が終わる 使われなくなったオブジェクトがJVMの自動回収(GC)によって片付けられる
障害調査で見るもの 呼び出しの深さ、同じメソッドの繰り返し呼び出し(再帰)、スレッド数 保持しているデータ量、入力サイズ、同時実行数、オブジェクトを指す値(参照)が残る期間

動画では、コードの進行に合わせてスタックとヒープの変化を図で確認できます。

メソッドを呼ぶとスタックに何が積まれるのか

Javaプログラムは、JVM(Java Virtual Machine)と呼ばれる仕組みの上で動きます。JVMは、Javaのプログラムを実行し、メモリの管理などを行う実行環境です。

プログラムの中では、複数の処理が同時に進むことがあります。この一つひとつの処理の流れを「スレッド」と呼びます。JVMは、スレッドごとに専用のJVMスタックを用意します。

メソッドが呼ばれると、その1回の処理に必要な作業領域が作られます。JVMの仕様では、この作業領域を「フレーム」または「スタックフレーム」と呼びます。フレームには、引数やメソッド内で使う変数、計算途中の値、処理が終わったあとに呼び出し元へ戻るための情報が入ります。

名前が似ている三つの用語を分けると、次のようになります。

用語 どの範囲を指すか 役割
JVMスタック スレッドごとに用意される領域全体 メソッド呼び出しに対応するフレームを積む
スタックフレーム 1回のメソッド呼び出しに対応する作業領域 引数、メソッド内の変数、計算途中の値、戻り先などを持つ
オペランドスタック 1つのスタックフレームの中にある領域 計算に使う値や計算結果を一時的に置く

用語メモ:オペランドスタック
オペランドは「計算の対象になる値」という意味です。たとえば1 + 2では、12がオペランドです。JVMは計算に使う値をオペランドスタックへ積み、取り出して計算し、その結果を再び積みます。ここでいうオペランドスタックは、スレッドが持つJVMスタック全体とは別のものです。

あるメソッドから別のメソッドを呼ぶと、新しいフレームが上に積まれます。呼び出したメソッドの処理が終わると、そのフレームが外れ、呼び出し元の処理へ戻ります。例外によってメソッドを抜けた場合も、そのメソッドのフレームは破棄されます。この構造はJava SE 26のJVM仕様で定義されています。

ヒープにはオブジェクトや配列が置かれる

Javaのヒープには、クラスから作ったオブジェクトと配列が置かれます。スタックがスレッドごとに用意されるのに対し、ヒープはJVM内のスレッドで共有されます。

ローカル変数、つまりメソッドの中で宣言した変数がオブジェクトを指している場合、その変数が持っているのは「参照」です。参照は、どのオブジェクトを使うかを示す値です。オブジェクトそのものはヒープにあります。

この図は役割を理解するための概念図です。実際の物理メモリ上で、必ずこの形や位置に配置されるという意味ではありません。JVMは実行時にさまざまな最適化を行うため、概念上の配置と実際の内部表現が異なることがあります。

Javaコードを実行順に追う

次のコードでは、mainからcheckoutを呼び、さらにcalculateFeeを呼びます。Java 8以降で使える構文に絞った例で、OpenJDK 17でも1100を出力することを確認しています。

public class StackHeapExample {
    static final class Order {
        final int price;

        Order(int price) {
            this.price = price;
        }
    }

    static int checkout(Order order) {
        int fee = calculateFee(order.price);
        return order.price + fee;
    }

    static int calculateFee(int price) {
        return price / 10;
    }

    public static void main(String[] args) {
        Order order = new Order(1000);
        int total = checkout(order);
        System.out.println(total);
    }
}

このコードが動く間の変化を、理解用のモデルとして追ってみます。

段階 スタックの状態 ヒープの状態
main開始 mainのフレームが作られる まだOrderオブジェクトはない
new Order(1000) mainの変数orderが参照を持つ Orderオブジェクトが作られる
checkout(order) checkoutのフレームが積まれ、引数orderも同じオブジェクトを参照する 1つのOrderオブジェクトを2つのフレームから参照している
calculateFee(order.price) calculateFeeのフレームが積まれ、引数priceを使って計算する Orderオブジェクトはそのまま残る
calculateFee終了 calculateFeeのフレームが外れる Orderオブジェクトはそのまま残る
checkout終了 checkoutのフレームが外れる mainorderが参照しているため残る
main終了 mainのフレームも外れる ほかに参照がなければ、GCが回収できる状態になる

checkoutが終了しても、mainorderが参照を持っている間、Orderオブジェクトは残ります。メソッドの終了と、そのメソッドで使ったオブジェクトの寿命が一致するとは限りません。

どこからも参照をたどれなくなったオブジェクトは、ガベージコレクションの回収対象になります。ガベージコレクションは、使われなくなったオブジェクトのメモリをJVMが自動的に回収する仕組みです。一般に「GC」と略されます。

回収対象になった瞬間にメモリから消えるわけではありません。実際にいつ回収するかはJVMが決めます。

よく見かける説明を、もう少し正確に捉える

「プリミティブ型はスタック、オブジェクトはヒープ」

プリミティブ型は、intbooleanなど、Javaが基本として用意している型です。

先ほどのコードでは、メソッド内の変数pricefeeをフレーム内の値として考えられます。一方、Order.priceはヒープ上のOrderオブジェクトを構成するフィールドです。同じintでも、メソッド内の変数か、オブジェクトが持つフィールドかで扱いが変わります。

また、JVMは実行時にコードを最適化します。スタックとヒープの図は、値の役割や寿命を整理するためのものです。物理メモリ上の配置を断定する図としては使えません。

「スタックは速く、ヒープは遅い」

スタックのフレームは、メソッドの呼び出しと終了に合わせて規則的に増減します。ただし、その違いだけでアプリケーション全体の性能は決まりません。オブジェクトの作り方、GC、扱うデータ量、データを読み出す効率、JVMによる最適化なども実行時間に影響します。

性能を改善するときは、プロファイラーやベンチマークで実際の処理時間とメモリ使用量を測ります。プロファイラーは、実行中のプログラムがCPUやメモリをどのように使っているかを調べるツールです。ベンチマークは、条件をそろえて処理時間などを測るテストです。

言語が変わると、メモリ管理の方法も変わる

ここまでの図とコードはJavaを基準にしています。スタックとヒープという言葉が出てきても、言語や実行環境によって、メモリの扱い方と開発者が行う作業は変わります。

言語・実行環境 捉え方
Java / JVM メソッド呼び出しをJVMスタックで管理し、オブジェクトと配列をヒープへ割り当てる。不要になったオブジェクトはGCが回収する
JavaScript 一般的な説明では、関数の呼び出しをコールスタックで管理する。オブジェクトの不要なメモリは実行環境のGCが回収する
C / C++ 動的に確保したメモリを、開発者が明示的に解放する場面がある。解放後のメモリへ触れないように管理する必要がある

Javaの概念図を、そのまま別の言語の物理メモリ配置へ当てはめることはできません。対象の言語と実行環境に合わせて確認します。

スタックに関係する障害

深すぎる再帰とStackOverflowError

再帰は、メソッドの中から同じメソッドを呼び出す書き方です。次のコードは、つながっているNodeを再帰的に数える簡略例です。

static final class Node {
    Node next;
}

static int count(Node node) {
    if (node == null) {
        return 0;
    }
    return 1 + count(node.next);
}

Nodeを1つ進むたびに、countの新しいフレームが積まれます。データが想定以上に深い場合や、Node同士が輪のようにつながってnullへ到達しない場合、フレームが増え続けます。必要なスタックの大きさがJVMの許容範囲を超えると、StackOverflowErrorが発生します。

調査では、最初にスタックトレースを確認します。スタックトレースは、エラーが起きるまでにどのメソッドが呼ばれたかを示す記録です。同じメソッドが何度も並んでいる場合は、次の順で確認します。

  1. 再帰を終了する条件が、すべての入力で成立するか
  2. 入力データが輪のようにつながっていないか
  3. 入力の最大深度を制限しているか
  4. ループを使った処理へ置き換えた方が管理しやすいか

スタックサイズを増やして対応できる場合もあります。ただし、終了しない再帰や無制限の入力はそのまま残ります。入力の最大深度を予測でき、必要な量を見積もれる場合に設定を調整します。

JVMスタックはスレッドごとに用意されるため、スレッド数もプログラム全体のメモリ使用量に影響します。大量のスレッドを作る設計では、1スレッド当たりの使用量と同時に動くスレッド数を合わせて確認します。

ヒープに関係する障害

参照が残り続けるメモリリーク

JavaにはGCがありますが、プログラムから参照できるオブジェクトは使用中と判断されます。すでに業務上は不要なデータでも、参照が残っていれば回収されません。この状態が積み重なり、使用できるメモリを圧迫する問題をメモリリークと呼びます。

次のキャッシュには、データを削除する処理も件数の上限もありません。

import java.util.ArrayList;
import java.util.List;

public final class UnboundedCache {
    private static final List<byte[]> CACHE = new ArrayList<>();

    static void remember(byte[] data) {
        CACHE.add(data);
    }
}

staticフィールドは、特定のオブジェクトではなくクラスに属する変数です。この例では、クラスが使われている間、CACHEからリストをたどり、さらに各byte[]をたどれます。そのため、GCは配列を回収できません。

実際のキャッシュでは、少なくとも次の項目を決めます。

  • 最大件数または最大容量
  • データを保持する時間
  • 削除する条件と方法
  • 上限へ達したときの動作
  • どの処理がキャッシュを管理し、いつ破棄するか

OutOfMemoryErrorはリーク以外でも起きる

この節では、Javaヒープの不足によるOutOfMemoryErrorを扱います。JVMが新しいオブジェクトに必要なメモリを確保できず、GCを実行しても必要な空きが作れないときに発生します。

原因は、時間をかけて不要な参照が積み重なるメモリリークだけではありません。

  • 大きなファイルを一度に読み込む
  • 大量の検索結果をすべてメモリに保持する
  • 同時リクエストごとに大きな一時領域を作る
  • キューやキャッシュに上限を設けていない
  • 想定するピーク量に対してヒープの上限が小さい

用語メモ:バッファ
データの読み書きや受け渡しのために、一時的にデータを置く領域です。ファイルや通信データを扱う処理でよく使われます。

メモリ使用量の推移を見ると、原因を絞りやすくなります。

メモリの増え方 最初に疑うもの
安定した負荷でも、GC後に残る使用量が徐々に増える 不要になったオブジェクトへの参照が残っている可能性
特定の処理で急激に増え、その処理が終わると下がる 入力サイズ、一括処理、同時実行数、一時的なバッファ
Javaヒープは安定しているが、OS(WindowsやLinuxなど)から見た使用量が増える スレッドのスタック、ヒープ外のバッファ、JVM内部、C/C++で作られたライブラリ

OutOfMemoryErrorは、Javaヒープ以外のメモリ不足でも発生します。エラーメッセージを確認し、どの領域を確保できなかったのかを見てから調査範囲を決めます。

C/C++では解放したメモリへのアクセスも調べる

CやC++では、動的に確保したメモリを開発者が解放する場面があります。次のコードは、deleteで解放した領域を再び読み取っています。

#include <iostream>

int main() {
    int* value = new int{42};
    delete value;
    std::cout << *value << '\n';
}

このような処理を「解放後アクセス」と呼びます。C++の仕様では結果が保証されないため、クラッシュ、データ破損、意図しない情報の読み書きなどにつながる可能性があります。

ClangのAddressSanitizerを使うと、実行中の不正なメモリアクセスを検出できます。AddressSanitizerは、コンパイラが検査用の処理をコードへ追加し、実行時に解放後アクセスや領域外アクセスなどを検出する仕組みです。

clang++ -O1 -g -fsanitize=address -fno-omit-frame-pointer use_after_free.cpp -o use_after_free
./use_after_free

この例と同等のコードをAddressSanitizer付きで実行すると、heap-use-after-freeとして検出されます。

設計段階で「深さ・量・寿命」を決める

メモリ障害を防ぐ設計では、入力の件数や処理時間に応じて増えるものを探します。

観点 増えるものの例 設計で決めること
深さ 再帰、メソッドの呼び出し 最大深度、終了条件、循環の検出、ループ処理への切り替え
キャッシュ、キュー、検索結果、一括読み込み、バッファ 最大件数、最大容量、分割サイズ、同時実行数、満杯時の動作
寿命 オブジェクト、イベントリスナー、コールバック 誰が保持するか、いつ参照や登録を外すか
外部資源 ファイル、通信接続、DB接続 取得と終了処理の対応、タイムアウト、例外発生時の終了処理

用語メモ:イベントリスナーとコールバック
イベントリスナーは、クリックやデータ受信などの出来事が起きたときに処理を動かすための登録です。コールバックは、あとで呼び出してもらうために別の処理へ渡す関数やメソッドです。どちらも登録先が参照を持ち続けることがあるため、不要になった時点で解除する設計が必要です。

ファイルやDB接続は、Javaのオブジェクトだけでなく、OS側のファイルや通信接続も使用します。これらを外部資源と呼びます。

Javaでは、try-with-resourcesを使うと、処理を抜けるときに終了処理を自動で呼び出せます。

try (InputStream input = Files.newInputStream(path)) {
    // inputを使う処理
}

この例のInputStreamAutoCloseableという仕組みに対応しているため、ブロックを抜けるとclose()が呼ばれます。GCが動く時期を待たず、使い終わった位置でファイルを閉じられます。

症状から調査方法を選ぶ

ここからは、Javaのメモリ障害を実際に調べるときに使う用語とコマンドを扱います。

症状 最初に確認するもの 次に行う調査
スタックトレースに同じメソッドが続く 入力の深さ、終了条件、データの循環 問題の入力を再現し、最大深度や循環の検出を追加する
GC後もヒープ使用量が増え続ける クラスごとのオブジェクト数、参照元 クラス別に数える一覧(クラスヒストグラム)や、ヒープの保存データ(ヒープダンプ)を調べる
大きな処理だけで急増する 入力件数、分割サイズ、同時実行数 条件を固定し、ピーク時の使用量を測る
Javaヒープは安定しているが、OSから見た使用量が増える スレッド数、ヒープ外のバッファ、JVM内部のメモリ JVM内部のメモリ内訳を調べるNMTや、OSの監視機能を使う
C/C++でクラッシュやデータ破損が起きる 解放後アクセス、二重解放、領域外アクセス AddressSanitizerを有効にしてテストする

jcmdでクラスヒストグラムとヒープダンプを取得する

jcmdは、JDKに付属するJavaプロセスの診断用コマンドです。JDKは、Javaプログラムの作成や実行に使うコンパイラ、実行環境、診断ツールなどをまとめた開発キットです。jcmdから実行中のJavaプログラムへ診断命令を送り、メモリやスレッドなどの情報を取得できます。

クラスヒストグラムは、クラスごとのオブジェクト数と使用量を並べた一覧です。どの種類のオブジェクトが増えているかを大まかに確認できます。

ヒープダンプは、ある時点でヒープに存在するオブジェクトと参照関係をファイルへ保存したものです。専用の解析ツールで開くと、どのオブジェクトが何から参照されているかを調べられます。

次の<pid>には、OSがJavaプロセスへ割り当てた識別番号を指定します。PIDは「プロセスID」の略です。

jcmd <pid> GC.class_histogram
jcmd <pid> GC.heap_dump filename=heapdump.dmp

利用できる診断命令やオプションはJVMによって異なります。実際の環境では、次のコマンドで利用可能な命令を確認します。

jcmd <pid> help
jcmd <pid> help GC.heap_dump

ヒープダンプの取得中は、対象のJavaプロセスに負荷がかかることがあります。保存先の空き容量と、本番処理への影響を確認してから実行します。

また、ヒープダンプにはオブジェクトが持つ値も含まれます。業務データ、認証情報、個人情報などが保存される可能性があるため、保存場所、転送方法、閲覧権限、削除期限を管理します。

Javaヒープ以外のメモリが増えている場合

Javaヒープが安定していても、OSから見たプロセス全体のメモリ使用量が増えることがあります。たとえば、スレッドのスタック、JVMが内部で使う領域、ファイルや通信の読み書き用にヒープ外へ確保するダイレクトバッファ、C/C++で作られたライブラリなどです。Javaヒープ以外で使うメモリは、まとめてネイティブメモリと呼ばれることがあります。

OpenJDKやOracle JDKで使われているHotSpot JVMには、JVMが内部で確保したメモリを分類するNative Memory Tracking(NMT)という機能があります。

java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory baseline
jcmd <pid> VM.native_memory summary.diff

最初のコマンドでNMTを有効にしてJavaプログラムを起動します。起動後にbaselineで比較の基準を記録し、summary.diffで基準からどの項目が増えたかを確認します。

NMTが追跡するのは、HotSpot JVMが内部で確保したメモリです。C/C++で作られた外部ライブラリなど、JVMの外側で行われたすべてのメモリ確保を追跡できるわけではありません。その場合は、OSやC/C++向けの調査ツールも使います。

OracleのJava SE 26 Troubleshooting Guideでは、NMTを有効にすると5〜10%程度の性能低下が生じる可能性があると説明されています。本番環境では、この負荷を考慮して使用します。

まとめ

スタックとヒープを障害の調査へつなげるときは、次の三つに分けて確認します。

  • 深さ:メソッド呼び出し、再帰、スレッド
  • :キャッシュ、キュー、一括処理、同時実行数
  • 寿命:参照を持つ処理、参照を外す時点、外部資源を閉じる時点

StackOverflowErrorが発生したら、呼び出しの深さとデータの循環を確認します。ヒープ不足によるOutOfMemoryErrorや、時間とともにメモリ使用量が増える問題では、保持している量、入力サイズ、同時実行数、参照元を確認します。

Javaヒープだけで説明できない場合は、スレッドのスタック、ダイレクトバッファ、JVM内部、外部ライブラリまで調査範囲を広げます。

設計時に上限、データを保持する処理、保持を終える時点を決めておくと、障害が起きたときにも調査の順番を組み立てやすくなります。

参考資料

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?