java:参照型変数やメモリの理解に苦しむ若手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.変数をコピーした際の挙動の違い

プリミティブ型変数と参照型変数の挙動の違いを実感するのは、変数をコピーした時でしょう。
実務でも躓きやすいポイントなので、サンプルコード付きで詳しく説明していきます。

以下は、プリミティブ型の変数をコピーした後に、コピー元の変数を変更する例です。
コピー元の変数の変更は、コピー先の変数に影響しません。
これはイメージ通りだと思います。

しかし、同じような書き方で参照型変数をコピーした場合、コピー元の変数の変更がコピー先の変数にも影響してしまいます。
以下は、配列をコピーした例と、ArrayList型をコピーした例です。

なぜこのようなことが起こるのかと言うと、この書き方ではアドレス値をコピーしてしまっており、領域を新たに確保しているわけではないからです。
このようなコピーは、シャローコピー(浅いコピー)と呼ばれます。

文章だけではイメージするのが難しいので、図も交えながら、シャローコピーの挙動を説明します。

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

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

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

ディープコピーのイメージは以下の通りになります。

コードで言うと、以下のようなコードになります。

上記のコードは原始的なディープコピーの方法です。
ディープコピーを行うためのCloneableインターフェースがJavaでは用意されており、これを使用した方が効率的にディープコピーを行うことができることもあります。
詳しくは、java:オブジェクトの中身をコピーする方法(cloneメソッド実装)を参照して下さい。

なお、プリミティブ型変数と参照型変数の挙動の違いを実感する代表的な場面としては、他にはメソッドの引数に変数を引き渡す場面が挙げられます。
情報処理技術者試験では値渡しと参照渡しの違いについても出題範囲になっていますが、これは値そのものを渡しているか、アドレス値を渡しているかの違いです。
参照型変数を引数で渡す時にアドレス値を渡している感覚(参照渡しをしている感覚)を持っていないと、これもバグの原因になり得ますので、注意が必要です。

3.メモリの開放

参照型変数の値を保持するための領域は、newする度に確保され直します。
(配列の場合は、配列を宣言し直す度に確保され直されます)

例えば、以下のコードでは、2回目のnewにより領域が再確保されています。

これを図解すると以下のようになります。

ここで問題になるのが、newし直す前に確保していた領域です。
この領域はプログラムで確保していたものの、使われなくなった領域です。
newで確保した領域が他のプロセス(プログラム)から使われることはあってはならないので、確保した領域は他のプロセスから使えなくなるような制御がかかります。
再び他のプロセスから使えるようにするためにはメモリを開放する必要があるのですが、この開放を忘れたまま領域の再確保を繰り返すと、他のプロセスが使えるメモリの領域が徐々に減っていきます。
これが「メモリリーク」と呼ばれる現象であり、放置すると空きメモリの不足により、プロセスの挙動や、場合によってはシステム全体の挙動が不安定になります。

C言語では、メモリの開放はコーディングにより明示的に行う必要がありました。
しかし、Javaでは「ガベージコレクション」と呼ばれる仕組みにより、開放するべき領域がある程度増えたら自動的に開放が行われるようになりました。

意図しないタイミングでのガベージコレクションは予期せぬ性能劣化を招く可能性があるので、性能要件がシビアなシステムではガベージコレクションについても気を配る必要があります。
ガベージコレクションについて詳しく見ていくにはこの記事ではとても足りないのですが、調べる上でメモリのイメージがついていれば理解は早まると思います。

4.イミュータブルな参照型変数について

参照型変数の中には、イミュータブルな変数も存在します。
イミュータブル(immutable)とは「不変」という意味であり、ミュータブル(mutable)の対義語です。
オブジェクト指向言語においては、「イミュータブル」は、「オブジェクトの生成後に、そのオブジェクトの状態(メモリ領域に保持されている値)が変化しない」という意味を指します。

イミュータブルな参照型変数の場合、newして領域を確保した後にオブジェクトの状態を変更したい場合は、再度newして領域を確保し直す必要があります。

Javaにおいては、プリミティブ型のラッパークラスやString型が、イミュータブルな参照型として用意されています。
これらの変数については、newを書かなくとも、領域が都度確保され直す、という挙動となります。
その結果、値の代入や参照においては、あたかもプリミティブ型の変数かのような挙動となります。
(ただし、本当にプリミティブ型の変数というわけではないため、プリミティブ型変数と異なりnull値を持つことができ、メソッドも持っています)

Javaで用意されているイミュータブルな参照型変数のコピーする例は以下の通りとなります。
これはシャローコピーと同じ書き方ですが

実際の挙動としては以下のように明示的にnewしているのと同じ挙動となるため

結果としてはディープコピーの挙動となります。
(つまり、シャローコピーすることはできず、意図せずともプリミティブ型のような動きとなります)


いかがでしたでしょうか。

Javaの入門レベルを脱し、Javaのコーディングで意図しないバグの発生を防いだり、性能改善を図ったりしたいのであれば、メモリの話に踏み込まざるを得ません。
しかし、JavaではC言語と異なりメモリについて意識しなくてもコーディングできるようになっているので、生産性の高さと引き換えに、メモリについて想像するのが難しくなっています。
また、基本情報処理技術者試験で来年度(2023年度)からC言語を含む言語選択問題が出題対象から外れる(疑似言語に統一となる)ため、C言語に触れる機会が減ることが予想されます。

このような背景があるため、後継のJava技術者にメモリについて教える機会は今後ますます増えることが予想されます。
その際、C言語未学習者でも要点をかいつまんで理解してもらい、かつ躓きやすいポイントを押さえた記事が必要であると感じたため、今回の記事を執筆しました。

これからも、教育上必要と感じることがあれば、記事にしていきたいと思います!

余談ですが、Javaの挙動を確認するため、今回の記事の執筆時にpaiza.ioのサービスを使ってみました。
これは、オンライン上でコードを書き実行できるというものであり、Javaにも対応しています。
Chromeのデベロッパーツールのコンソール上でのJavaScript実行のようなことがJavaでもできるので、ちょっとした挙動確認でとても便利でした。

SpringFrameworkのAOPとは

AOPとは、「アスペクト指向プログラミング(Aspect Oriented Programming)」の略称です。
「アスペクト」を日本語に直訳すると「相」ですが、プログラミングの世界では「オブジェクト指向ではうまく分離できない横断的な機能」のことを指します。代表例としてはログ出力機能(各クラスの処理の開始・終了等のタイミングで割り込むような形で使われる)が挙げられます。
AOPをサポートするフレームワークでは、横断的な機能を別のプログラムとして切り出し、そのプログラムが特定のタイミングで呼び出されるように別途定義できるような機能が組み込まれています。

SpringFrameworkにも、AOPが機能の一つとして組み込まれています。
AOPを利用することで、横断的な機能を切り出して可読性を高めたり重複を排除したりすることができます。


以下は、SpringFrameworkでのAOPの書き方の一例です。
test関数の開始時と終了時に標準出力を割り込ませるサンプルコードです。

なお、便宜上、サンプルコードではRestControllerを併用していますが、AOPはRestControllerを併用する必要はありません。

【サンプルコード】

・SmpSpringBootApplication.java

・MyFirstAspect.java

【実行結果(コンソール)】

・SmpSpringBootApplication起動後、http://localhost:8080/testにアクセス


いかがでしたでしょうか。

SpringFrameworkのAOPについて簡単なサンプルコードが欲しかったので、簡単な解説を付けた上で記事に起こしました。
本日ではSpringFrameworkの情報は書籍やWeb上に十分に揃っているのですが、情報を付け加えたくなったらまた記事を起こしたいと思います。

例外ケースは処理の始めに除外する

プログラムで何かしらの処理を記述する場合、本当に実装したい処理(本処理)に入る前に、例外ケースを除外するテクニックがあります。
このように記述することで、本処理では例外ケースを考えずに済むため処理内容を考えやすくなりますし、例外ケースの場合に時間がかかる本処理を実行しなくて済むようになるので性能面でもメリットがあります。

例として、競技プログラミングの問題を取り上げて説明してみます。

今回取り上げる問題は以下です。
 100円玉がA枚、
 10円玉がB枚あります。
 X円を作る方法が何通りあるか出力しなさい。

ループ処理を実装して総当たりを行えばこの問題を解くことはできますが、その場合は性能面が犠牲になるため、望ましい解答ではありません。
今回は、ループ処理を使わずにこの問題を解いてみます。
(言語はJavaです。なお、本来は、標準入力を入力としますが、今回はソース中の定数を入力としています。)


【解答例】

・HundredYenTenYen.java

【実行例】

・100円玉だけで100以降の位を払え、10の位を払った時に10円玉10枚組が減らない場合

 備考:以下の3パターンがある。
  100円玉5枚+10円玉5枚
  100円玉4枚+10円玉15枚
  100円玉3枚+10円玉25枚

・100円玉だけで100以降の位を払え、10の位を払った時に10円玉10枚組が減る場合

 備考:以下の2パターンがある。
  100円玉4枚+10円玉6枚
  100円玉3枚+10円玉16枚

・100円玉だけでは100以降の位を払えない場合

 備考:以下の1パターンがある。
  100円玉6枚+10円玉24枚

・持っている10円玉の枚数で、作りたい円の10の位を実現できない場合

・作りたい円が10の倍数ではない場合

・作りたい円が100円玉・10円玉の合計金額を超えている場合


この解答例では、「10円玉10枚の組み合わせは、100円玉1枚を代替できる」という点に着目し、④~⑦の箇所でパターン数を割り出しています。

そして、ポイントは、①~③で例外ケースを除外している所にあります。
①~③の例外処理を事前に噛ませることで、④~⑦に到達した時点で
・入力された金額は100円玉と10円玉で払い切れる
・入力された金額は必ず10の倍数である
・10の位を払う時に10円玉が足りなくなることはない
ということが保証されるため、④~⑦の処理を考えやすくなっています。

また、今回の例では当てはまりませんが、もし④~⑦が時間のかかる処理である場合は、例外ケースの場合に処理時間の短縮をすることもできます。

このように、例外ケースを事前に除外するテクニックが役に立つことがあるので、覚えておいて損はないでしょう。


いかがでしたでしょうか。

プログラムのアルゴリズムを考える上ではいくつかコツやテクニックのようなものがあるので、これからもそういった情報を発信できればと思います。

C#:外部コマンド呼び出しの方法

javaプログラムからの外部コマンド呼び出しは別の記事に掲載したのですが、C#でも同様に外部コマンド呼び出しが可能です。
今回の記事では、「画面から外部コマンドを呼び出し、外部コマンドで出力されたファイルを取り込む」という機能をWindows Fromで実現したサンプルプログラムを紹介します。

【サンプルプログラム】

【呼び出されるバッチ】

・C:\tmp\HelloWorld.bat

【実行結果】


いかがでしたでしょうか。

プライベートでこのようなプログラムを作る機会があったので、記事として紹介してみました。
簡単な画面を作りたい時にC#のWindows Fromは便利で、今回紹介したような画面を実装すると社内ツールを作る時の表現の幅が広がったりします。

処理時間はデータ量に比例するとは限らない

処理時間(計算量)はデータ量に比例するとは限りません。
例えば、データ量が10倍になったからと言って、処理時間も10倍になるとは限りません。

処理時間が何倍になるかは、アルゴリズムにより決まります。
アルゴリズム次第では、データ量が10倍になった時に処理時間が100倍になることも有り得ます。

今回は、試しに、Java作成したソート処理の実行時間を測ってみます。
アルゴリズムはバブルソートです。


【サンプルコード】

・BubbleSort.java

【実行時間】

・データ量が10000レコードの場合

・データ量が100000レコードの場合


以上のように、データ量が10倍になった場合に、実行時間も100倍(実測ではそれ以上)になりました。

なぜ実行時間がデータ量に比例しないのかと言うと、このアルゴリズムでは二重ループが発生するからです。
二重ループの中の処理が行われる回数は、10000レコードの場合は「1万 * 1万」(正確には「1万 * 9999」)回ですが、100000レコードの場合は「10万 * 10万」(正確には「10万 * 99999」)回となり、100倍の差となります。
この差が、実行時間にも反映されます。

専門用語で言うと、このような概念は「O(オーダ)」と呼ばれます。
(詳しくは、以前の記事で紹介しています)

実務でも、大量のデータを処理する必要がある場合は、「O(オーダ)」の概念や、最適なアルゴリズムを意識する必要があります。
基礎的なアルゴリズムは、情報処理技術者試験で学ぶことができます。
また、競技プログラミングでは最適なアルゴリズムを考えさせる問題が頻出なので、詳しく学びたい方は手を出してみると面白いと思います。


いかがでしたでしょうか。

「処理時間はデータ量に比例するとは限らない」というのは、アルゴリズムや性能問題を学んだことがない人にとっては意外なことだと思います。
システムには性能問題(Webページの動作が遅い、夜間バッチが決められた時刻までに終わらずにサービス開始時刻になってもシステムを利用できない、等)がつきものです。
そうした性能問題は、データ量の増加に対して処理時間が指数関数的に増えることで引き起こされていることも少なくありません。

この記事を通して、アルゴリズムや性能問題への興味を持つ人が一人でも増えれば幸いです。