技術とか戦略とか

SIerで証券レガシーシステムを8年いじってからSESに転職した業務系エンジニアによる技術ブログ。

java:参照型変数の中身のイメージ

Javaの参照型変数の中身は、何年かJavaの実装経験を積んだ人でもイメージすることが難しいです。
しかし、ここがイメージできていないと、思わぬ落とし穴にはまることもあります。
 
C言語を経験していればイメージしやすくなりますが、そのためだけにC言語を学ぶのもハードルが高く感じると思います。
そこで、今回の記事で、参照型変数の中身のイメージについて、重要な点のみをピックアップして説明していきたいと思います。
 
0.そもそもメモリとは
参照型変数について理解する前に、まずはメモリについて意識する必要があります。
 
メモリとは、プログラムの実行中に取り扱っているデータを一時的に保存する領域です。
数学の問題を解く時に、答えを出す前に途中式を紙に書くと思いますが、その紙がメモリのようなものだと考えれば良いです。
なお、一般的には、プログラムの実行結果は最終的にファイルやDBに恒久的に保存するか、画面に表示するかします。
数学の問題の例で言うと、恒久的な保存領域や画面表示が答えに相当すると考えれば良いです。
 
そして、メモリは1バイト(8ビット)ずつ細かく区分けされており、それぞれの区分けについて「アドレス」と呼ばれる値により一意に場所を特定します。
アドレスのバイト数は、64ビットのOSの場合は8バイトになります。
 
文章で書かれてもイメージが難しいと思いますが、後ほど図を使って説明します。
とりあえず、「メモリと呼ばれる一時的な保存領域がある」という意識を持っていただければ、と思います。
 
1.プリミティブ型と参照型
Javaの変数は、プリミティブ型と参照型の2種類に大きく分けることができます。
どちらの型なのかによって、メモリに格納する内容が変化します。
 
プリミティブ型に分類される型については、以下の8つの型が存在します。

細かいですが、boolean型についてはデータの保持に使うのは1ビットのみで、メモリに値を保存する時にはバイト単位になっているはずです。
Javaの実装上は1バイトになることが多いようなのですが、boolean型が何バイトになるのかは正式な取り決めはないので、万が一これを気にする必要がある場合は調査した方が良いです。
 
そして、プリミティブ型以外の型の変数は、全て参照型変数に分類されます。
newでオブジェクトを生成する変数は全て参照型変数です。
また、プリミティブ型の配列についても、参照型変数に該当します。
プリミティブ型のラッパークラス(Integer型、Character型、等)やString型についても参照型変数に該当しますが、特定の場面においてはプリミティブ型に見えるような動きをします(詳しくは後述します)。
 
プリミティブ型変数と参照型変数では、メモリに格納する値が異なります。
プリミティブ型変数では値そのものをメモリに格納するのに対し、参照型変数ではメモリ上にその変数用の保存領域を確保した上で、その保存領域の場所を指し示すアドレスを変数の領域に格納します。
図に表すと、以下のようになります。

この違いは、プログラムの挙動の違いとなって現れます。それを以降で説明していきます。
 
2.変数をコピーした際の挙動の違い
プリミティブ型変数と参照型変数の挙動の違いを実感するのは、変数をコピーした時でしょう。
実務でも躓きやすいポイントなので、サンプルコード付きで詳しく説明していきます。
 
以下は、プリミティブ型の変数をコピーした後に、コピー元の変数を変更する例です。
コピー元の変数の変更は、コピー先の変数に影響しません。
これはイメージ通りだと思います。
 
import java.util.*;

public class Main {
    public static void main(String args) throws Exception {
        
        // 変数の宣言
        int i1 = 1;
        
        // 変数のコピー
        int i2 = i1;
        
        // コピーした変数の表示
        System.out.println(i2);
        
        // コピー元の変数の変更
        i1 = 2;
        
        // コピーした変数の再表示
        System.out.println(i2);
    }
}
 
1
1
 
しかし、同じような書き方で参照型変数をコピーした場合、コピー元の変数の変更がコピー先の変数にも影響してしまいます。
以下は、配列をコピーした例と、ArrayList型をコピーした例です。
 
import java.util.*;

public class Main {
    public static void main(String args) throws Exception {
        
        // 変数の宣言
        int il1 = {1,2,3};
        
        // 変数のコピー(シャローコピー)
        int
il2 = il1;
        
        // コピーした変数の表示
        System.out.println(il2[1]);
        
        // コピー元の変数の変更
        il1[1] = 4;
        
        // コピーした変数の再表示
        System.out.println(il2[1]);
    }
}
 
2
4
 
public class Main {
    public static void main(String args) throws Exception {
        
        // 変数の宣言
        ArrayList<Integer> il1 = new ArrayList<>();
        il1.add(1);
        il1.add(2);
        il1.add(3);
        
        // 変数のコピー(シャローコピー)
        ArrayList<Integer> il2 = il1;
        
        // コピーした変数の表示
        System.out.println(il2.get(1));
        
        // コピー元の変数の変更
        il1.set(1,4);
        
        // コピーした変数の再表示
        System.out.println(il2.get(1));
    }
}
 
2
4
 
なぜこのようなことが起こるのかと言うと、この書き方ではアドレス値をコピーしてしまっており、領域を新たに確保しているわけではないからです。
このようなコピーは、シャローコピー(浅いコピー)と呼ばれます。
 
シャローコピーを図として表すと以下のようになります。

 
コピーした段階では、アドレス値をコピーしてしまっており、指し示す領域はコピー元と同一になってしまっています。

そのため、コピー後にコピー元の変数を変更すると、その影響がコピー先の変数にも表れてしまいます。

これを回避するためには、新たに領域を確保し、その領域に格納する値をコピーした後、新たな領域を指し示すアドレス値を変数に格納するような形でコピーする必要があります。
このようなコピーは、ディープコピー(深いコピー)と呼ばれます。
 
ディープコピーを図解すると以下のようなイメージになります。

コードで言うと、以下のようなコードになります。
 
import java.util.*;

public class Main {
    public static void main(String args) throws Exception {
        
        // 変数の宣言
        int il1 = {1,2,3};
        
        // 変数のコピー(ディープコピー)
        int
il2 = {0,0,0};
        for (int i = 0; i < il1.length; i++) {
            il2[i] = il1[i];
        }
        
        // コピーした変数の表示
        System.out.println(il2[1]);
        
        // コピー元の変数の変更
        il1[1] = 4;
        
        // コピーした変数の再表示
        System.out.println(il2[1]);
    }
}
 
2
2
 
public class Main {
    public static void main(String args) throws Exception {
        
        // 変数の宣言
        ArrayList<Integer> il1 = new ArrayList<>();
        il1.add(1);
        il1.add(2);
        il1.add(3);
        
        // 変数のコピー(ディープコピー)
        ArrayList<Integer> il2 = new ArrayList<>(il1);
        
        // コピーした変数の表示
        System.out.println(il2.get(1));
        
        // コピー元の変数の変更
        il1.set(1,4);
        
        // コピーした変数の再表示
        System.out.println(il2.get(1));
    }
}
 
2
2
 
上記のコードは原始的なディープコピーの方法です。
ディープコピーを行うためのCloneableインターフェースがJavaでは用意されており、これを使用した方が効率的にディープコピーを行えることもあります。
詳しくは「java:オブジェクトの中身をコピーする方法(cloneメソッド実装)(https://cyzennt.co.jp/blog/2020/01/24/java%ef%bc%9a%e3%82%aa%e3%83%96%e3%82%b8%e3%82%a7%e3%82%af%e3%83%88%e3%81%ae%e4%b8%ad%e8%ba%ab%e3%82%92%e3%82%b3%e3%83%94%e3%83%bc%e3%81%99%e3%82%8b%e6%96%b9%e6%b3%95%ef%bc%88clone%e3%83%a1%e3%82%bd/)」を参照して下さい。
 
なお、プリミティブ型変数と参照型変数の挙動の違いを実感する代表的な場面としては、他にはメソッドの引数に変数を引き渡す場面が挙げられます。
情報処理技術者試験では値渡しと参照渡しの違いについても出題範囲になっていますが、これは値そのものを渡しているか、アドレス値を渡しているかの違いです。
参照型変数を引数で渡す時にアドレス値を渡している感覚(参照渡しをしている感覚)を持っていないと、これもバグの原因になり得ますので、注意が必要です。
 
3.メモリの開放
参照型変数の値を保持するための領域は、newする度に確保され直します。
(配列の場合は、配列を宣言し直す度に確保され直されます)
 
例えば、以下のコードでは、2回目のnewにより領域が再確保されています。
 
import java.util.*;
import java.util.ArrayList;

public class Main {
    public static void main(String args) throws Exception {
        
        // 変数の宣言
        ArrayList<Integer> il1 = new ArrayList<>();
        il1.add(1);
        il1.add(2);
        il1.add(3);
        
        // 変数の表示
        System.out.println(il1.get(1));
        
        // メモリ領域の再割り当て
        il1 = new ArrayList<>();
        il1.add(4);
        il1.add(5);
        il1.add(6);
        
        // 変数の再表示
        System.out.println(il1.get(1));
    }
}
 
2
5
 
これを図解すると以下のようになります。

ここで問題になるのが、newし直す前に確保していた領域です。
この領域はプログラムで確保していたものの、使われなくなった領域です。
newで確保した領域が他のプロセス(プログラム)から使われることはあってはならないので、確保した領域は他のプロセスから使えなくなるような制御がかかります。
再び他のプロセスから使えるようにするためにはメモリを開放する必要があるのですが、この開放を忘れたまま領域の再確保を繰り返すと、他のプロセスが使えるメモリの領域が徐々に減っていきます。
これが「メモリリーク」と呼ばれる現象であり、放置すると空きメモリの不足により、プロセスの挙動や、場合によってはシステム全体の挙動が不安定になります。
 
C言語では、メモリの開放はコーディングにより明示的に行う必要がありました。
しかし、Javaでは「ガベージコレクション」と呼ばれる仕組みにより、開放するべき領域がある程度増えたら自動的に開放が行われるようになりました。
 
意図しないタイミングでのガベージコレクションは予期せぬ性能劣化を招く可能性があるので、性能要件がシビアなシステムではガベージコレクションについても気を配る必要があります。
ガベージコレクションについて詳しく見ていくにはこの記事ではとても足りないのですが、調べる上でメモリのイメージがついていれば理解は早まると思います。
 
4.イミュータブルな参照型変数について
参照型変数の中には、イミュータブルな変数も存在します。
イミュータブル(immutable)とは「不変」という意味であり、ミュータブル(mutable)の対義語です。
オブジェクト指向言語においては、「イミュータブル」は、「オブジェクトの生成後に、そのオブジェクトの状態(メモリ領域に保持されている値)が変化しない」という意味を指します。
 
イミュータブルな参照型変数の場合、newして領域を確保した後にオブジェクトの状態を変更したい場合は、再度newして領域を確保し直す必要があります。
 
Javaにおいては、プリミティブ型のラッパークラスやString型が、イミュータブルな参照型として用意されています。
これらの変数については、newを書かなくとも、領域が都度確保され直す、という挙動となります。
その結果、値の代入や参照においては、あたかもプリミティブ型の変数かのような挙動となります。
(ただし、本当にプリミティブ型の変数というわけではないため、プリミティブ型変数と異なりnull値を持つことができ、メソッドも持っています)
 
Javaで用意されているイミュータブルな参照型変数のコピーする例は以下の通りとなります。
これはシャローコピーと同じ書き方ですが
 
import java.util.*;

public class Main {
    public static void main(String args) throws Exception {
        
        // 変数の宣言
        Integer i1 = 1;
        
        // 変数のコピー
        Integer i2 = i1;
        
        // コピーした変数の表示
        System.out.println(i2);
        
        // コピー元の変数の変更
        i1 = 2;
        
        // コピーした変数の再表示
        System.out.println(i2);
    }
}
 
実際の挙動としては以下のように明示的にnewしているのと同じ挙動となるため
 
import java.util.*;

public class Main {
    public static void main(String args) throws Exception {
        
        // 変数の宣言
        Integer i1 = new Integer(1);
        
        // 変数のコピー
        Integer i2 = new Integer(i1);
        
        // コピーした変数の表示
        System.out.println(i2);
        
        // コピー元の変数の変更
        i1 = 2;
        
        // コピーした変数の再表示
        System.out.println(i2);
    }
}
 
結果としてはディープコピーの挙動となります。
(つまり、シャローコピーすることはできず、意図せずともプリミティブ型のような動きとなります)
 
1
1