よしたろうブログ

設計・人文知・歴史・哲学・漫画とかの話が好きです。

「プリミティブ型」と「参照型」はメモリ管理の仕組みが異なる【Java】

こちらの記事は前記事の続きです。

前記事ではJava における引数などの評価戦略は「値渡しのみ」であり「参照渡し」は存在しないと紹介しました。これに加え本記事では、データのメモリ管理上で具体的にどの様なことが起きているのかを紹介します。

1. 処理実行には実行内容をメモリ上に展開する必要がある

ほとんどのPCは「ノイマン型」コンピュータです。

よく見るやつです。入力装置はマウスやキーボード、出力装置はディスプレイなどですね。
ノイマン型のコンピュータの特徴は、

  1. プログラム内蔵方式
    あらかじめ機械語が主記憶装置(メモリ)に内蔵されている。

  2. 逐次制御方式
    主記憶装置(メモリ)に格納されている情報を中央処理装置(CPU)が1つ1つ取り出して実行する。

  3. アドレス指定方式でデータを扱う
    固定の命令(機械語)は命令部とアドレス部から構成される。アドレス部は命令の対象となるデータがある主記憶装置(メモリ)のアドレスを示す情報を持っており、 アドレスを示す情報はCPU内部のレジスタ(高速な記憶装置)に格納される。 CPUはレジスタにある場所情報をもとにメモリから順次読み出す。

  4. 固定の命令(機械語)が使える
    演算処理や動作などに固定の命令(機械語)を用いることができる。 「実行対象のプログラムをデータとしてメモリ上に展開し、処理演算装置(CPU)はそれを順次読み込んで処理する」

となっています。

つまり、作成したプログラムや第三者が作成したライブラリ、あるいはPC上で動く各種アプリケーションから、サーバ上で動作するWebサービスに至るまでどのようなプログラムも実行時に必ずメモリ上にその内容が展開される仕組みとなっています。メモリとは、プログラムの実行中に取り扱っているデータを一時的に保存する領域です。
実行対象プログラムは、実行前にメモリ上にその内容が展開されます。今回は取り上げませんが、この作業を行なうのはOSの役割です。 また、OSとJVMがそれぞれで占有するメモリ空間などの概念もあります。JavaGCの仕組みを理解するのに重要ですが、こちらも今回は紹介しません。

2. メモリの管理方法による分類

メモリの管理方法によってメモリの空間が定義されています。他にもあるのですが、ここでは「スタック領域」「ヒープ領域」「スタティック領域」の3種類を紹介します。

領域 説明 変数との対応 生存期間
スタック領域 ローカル変数、パラメータ、戻り値、演算に使われる任意の値などを管理する領域。スタック領域は共有リソースではないため、スレッドセーフ。実際に処理されるデータを格納する領域。「スタック」=積み重ねという名前が表すように、処理対象のデータはFILO(先入れ後出し)方式でデータを管理し、処理が完了したデータはスタックから破棄。 メソッドスコープあるいはforスコープなどの特定の処理スコープ内で定義する。 特定の処理スコープ内だけで有効な変数。変数定義した処理スコープの処理がすべて完了すると破棄。
ヒープ領域 new演算子で生成されたオブジェクトと配列を管理。必要な時に、必要なサイズを指定して領域が確保できる自由度の高いメモリ領域。ただし、確保したメモリは必ず解放する必要がある。Java ではGCにて実行中のプログラムの動作から不要になったと判断した領域を自動的に解放。メモリの解放を明示的に行わなけばならない言語ではメモリリークに注意する必要がある。 クラススコープ内で変数定義を行なう。 対象クラスのインスタンスがnew演算子にて生成されてから、破棄されるまでの間有効。
スタティック領域 static変数やグローバル変数を管理する。静的領域とも呼ばれ、プログラムの開始から終了までメモリ空間は保持し続けられる特徴をもつ。 クラススコープ内でstaticキーワード付きで変数宣言を行なう。 クラス利用をするJavaアプリケーションの開始から終了まで有効

「変数を宣言・定義を行う、スコープを選択する」 = 「どのメモリ管理領域でデータ管理するかを決定する」

ということになります。メソッド内で宣言した変数やfor文内で宣言した変数が、そのスコープを脱出すると利用できなくなる理由は、このスタック領域の管理仕様によるものです。

また、Java の様なオブジェクト指向プログラミング(OOP)言語の多くでは生成されたインスタンスは全てヒープ領域に配置されます。OOPは有限なヒープ領域を大量に使用することが前提になります。メモリの占有領域の解放管理は非常に重要な概念です。現代では非常にリッチな環境なので意識する機会は少なくなっていますが。

3. 「プリミティブ型」と「参照型」はメモリ管理の仕組みが異なる

プリミティブ型では値がスタック領域に、参照型ではスタック領域にヒープにあるインスタンスそのものではなく参照(ポインタ)が格納されます。

JVMが管理するメモリ領域にも、スタック領域とヒープ領域があります。上記の様に、スタック領域にはローカル変数のデータを置き、出てきた順に、ローカル変数のデータを積み上げていきます。変数の有効範囲を抜けると,このデータはすぐに解放されます。

public class PrimitiveType {
    public static void main(String[] args) {

        int i = 5;
        int i2 = i;
        System.out.println("Before ::" + i);
        change(i); // 実引数 i
        System.out.println("After ::" + i);
    }

    private static void change(int a) { // 仮引数 int a
        a++;
        System.out.println("in change ::" + a);
    }
}

Before ::5
in change ::6
After ::5

このプログラムの変数をスタックに積み上げると次のようになります。

image.png

ローカル変数はメソッドやメソッド内のfor、if等のスコープで定義される変数のことで、スタック変数とも呼ばれます。上記で紹介した通り、ローカル変数は"スタック領域"と呼ばれるメモリ領域で管理され、変数の有効範囲を抜けるとこのデータはすぐに破棄されます。

一方のヒープ領域は,インスタンスの実体を格納する領域です。以下に流れを記述します。

  1. 参照型の変数を作ると、まずスタックにその場所が用意される

  2. new演算子でそこに新しいインスタンスを作ると、インスタンスの実体がデータとしてヒープ領域に作られる

  3. そして、ヒープ上に存在するインスタンスの位置が、参照型の変数のデータとしてスタック領域に書き込まれる。スタック領域にはオブジェクトを参照するための情報が入っています。

これはオブジェクトを参照するための情報なので、オブジェクト型の変数は「参照型」と呼ばれます。参照とはポインタのことです。new 演算子インスタンス生成する変数・プリミティブ型の配列は参照型変数です。new 演算子は「ヒープ領域に指定された内容を実体化する」という演算を行なう特殊な演算子です。

これに対し実際のデータ自体がスタック領域に書き込まれるのがプリミティブ型です。このようなメモリ管理をする型を「プリミティブ型」といいます。 プリミティブ型と参照型の変数の挙動は上記で紹介した通りです。

import java.util.Arrays;

public class ReferenceType {
    public static void main(String[] args) {
        int[] arr = new int[] {1, 2, 3};

        System.out.println("Before ::" + Arrays.toString(arr));
        change(arr); // 実引数 arr 
        System.out.println("After ::" + Arrays.toString(arr));
    }

    private static void change(int[] a) { // 仮引数 int[] a
        a[0]++;
        a[1]++;
        a[2]++;
    }
}

Before ::[1, 2, 3]
After ::[2, 3, 4]

下図のように、特定のメソッドを処理中に new 演算子インスタンス化の命令が指定されると、ヒープ領域にそのインスタンス(実体)が展開されます。インスタンスはヒープに確保されますが、参照型変数はスタックにプリミティブ型変数と同じように存在します。

  • プリミティブ型変数はスタックに値(実データ)が格納
  • 参照型変数はヒープに確保されたインスタンスの参照を格納

image.png

参照型変数は参照(ポインタ)を元にヒープ領域に展開されたインスタンスへアクセスします。

import java.util.Arrays;

public class ReferenceType {
    public static void main(String[] args) {
        int[] arr = new int[] {1, 2, 3};

        System.out.println("Before ::" + Arrays.toString(arr));  //①
        change(arr);
        System.out.println("After ::" + Arrays.toString(arr));   //②

    }

    private static void change(int[] a) {
        // 仮引数の参照値を変更
        int[] a = new int[] {2, 1, 4};

        a[0]++;
        a[1]++;
        a[2]++;
    }
}

Before ::[1, 2, 3]
After ::[1, 2, 3]

このプログラムでは、スタック領域の変数とヒープ上で生成されるインスタンスの関係は以下の様になります。

image.png

①では 変数 arr を出力し、②の時点でも同じ様に変数 arr を出力します。Java には参照渡しがないのでこの様な挙動になります。メモリ上でどの様にデータが管理されているかをみれば一目瞭然ですね。

具体的な流れは、上記での説明を引用します。

changeメソッドの

a = new int[] {2, 1, 4};

の様に、新しいオブジェクトを生成して、そのインスタンスの参照値を再代入するなどの、仮引数が指し示す参照値自体を変更するような操作を行うと、新しい参照値は呼び出し元の実引数には反映されず、参照渡しと異なる動作となります。

本来の参照渡しは呼び出し先で参照先を変更するような操作(新しいオブジェクトを生成して、そのインスタンスの参照値を再代入)を行うと呼び出し元にもその変更が反映されますが、参照値の値渡しは参照を表す値を渡しているだけになり、仮引数の参照値を変更しても呼び出し元の実引数の参照値は呼び出し前と変わりません。

参照渡しであれば、

After ::[3, 2, 5]

という出力になるはずです。
実際の出力は上記の通りです。これは仮引数が change メソッド内で違う参照値を代入されることで発生する現象です。実引数となる arr 変数の参照値とはこのタイミングで異なるのです。

この次は、シャロウコピー・ディープコピー・ ディフェンシブコピーなどを取り扱いたいと思います。

参考資料