信頼度ランク
| S | 公式ソース確認済み |
| A | 成功実績多数・失敗例少数 |
| B | 賛否両論 |
| C | 動作未確認・セキュリティリスク高 |
| Z | 個人所感 |
Java初心者がつまずく「オブジェクト・インスタンス・参照・メモリ」の正体
「文法は読めるが内部で何が起きているかわからない」状態から脱却するための、JVMメモリモデルを軸とした体系的解説。Integer キャッシュ・String pool・== と equals の「なぜ」を暗記ではなく構造で理解する。
一言結論
変数に入っているのはオブジェクトではなく参照(アドレス)。この一点を理解するだけで、== vs equals・Integer キャッシュ・String pool・static・Spring DI がすべて繋がる。
Java を学び始めて最初の数週間は、不思議なくらいスムーズに進む。変数に値を入れて、if で分岐して、for で繰り返す。「なんだ、思ったよりいけるじゃないか」と感じる。
だが、ある日突然わからなくなる。
String a = new String("test");
String b = new String("test");
System.out.println(a == b); // false ←なんで?
System.out.println(a.equals(b)); // true ←じゃあこっちは?
Integer x = 127;
Integer y = 127;
System.out.println(x == y); // true ←え?
Integer p = 128;
Integer q = 128;
System.out.println(p == q); // false ←は??
この「なんで?」が解消できないまま資格試験に臨み、問題を暗記で乗り切ろうとする。でも暗記は必ず崩壊する。Spring を使い始めた瞬間、DI の概念でもう一度詰まる。
この記事の目的は一つだ。「メモリ上で何が起きているか」を理解すれば、上記のすべてが自然に繋がることを示す。
1. なぜ Java 初心者は突然わからなくなるのか
Java Bronze レベルまでは、正直なところ「メモリモデルを知らなくても」書ける。プリミティブ型(int、boolean など)は値をそのまま扱えるし、String も + で結合するだけなら何も考えなくていい。
崩壊が始まるのは Silver 以降だ。
- 参照の比較(
==とequalsの違い) - static の意味(なぜインスタンスを作らなくていいのか)
- ポリモーフィズム(親の型の変数に子のインスタンスを代入できるのはなぜか)
- DI コンテナ(Spring が「管理」するとはどういう意味か)
これらはすべて、「変数とオブジェクトの関係」つまりメモリモデルを理解していないと、永遠に感覚でしか掴めない。
初心者がやりがちな誤解:「Java はメモリを自動管理してくれるから、メモリのことを考えなくていい」
半分正しい。GC がメモリの解放を自動化してくれる。しかし「メモリ上でどう構造化されているか」を理解しないと、Java の設計思想が全部霧の中になる。
2. 「変数の中にオブジェクトが入っている」は半分間違い
多くの入門書はこう説明する。「変数はオブジェクトを格納する箱です」。
これが最初の、そして最大の誤解の種になる。
実際に何が起きているか
String s = new String("abc");
この 1 行で起きていることを、正確に分解する。
Step 1: new String("abc") が実行される → ヒープ領域に String オブジェクトが生成される。
Step 2: そのオブジェクトのメモリアドレス(例: 0x4A2F)が返される。
Step 3: 変数 s(スタック領域にある)に、そのアドレスが格納される。
【スタック領域】 【ヒープ領域】
┌─────────────┐ ┌──────────────────────┐
│ s: 0x4A2F │ ──────► │ String オブジェクト │
└─────────────┘ │ value: "abc" │
│ hash: 96354 │
└──────────────────────┘
s が持っているのは "abc" という文字列そのものではない。オブジェクトが存在するヒープ上のアドレスだ。これを「参照(reference)」と呼ぶ。
C 言語経験者にはおなじみの「ポインタ」に近い概念だが、Java はそのアドレスを直接操作できないよう隠蔽している。隠蔽してくれているのはありがたいが、**「存在は隠されているが、構造は変わらない」**という点が重要だ。
スタックとヒープの役割
| スタック | ヒープ | |
|---|---|---|
| 何を置くか | ローカル変数・メソッド呼び出し情報 | オブジェクト本体 |
| 管理方法 | LIFO(自動的にメソッド終了時に破棄) | GC が管理 |
| 速度 | 速い | 比較的遅い |
| サイズ | 小さい(数 MB 程度) | 大きい(ヒープ全体) |
メソッドが呼び出されると、そのメソッドのスタックフレームが積まれる。ローカル変数はすべてそのフレームに入る。メソッドが終わるとフレームが消える。だからローカル変数にデフォルト値がないのも理由がある(初期化前に読もうとするのは危険なので、コンパイラが止める)。
一方、new で作られたオブジェクトはヒープに入る。ヒープはメソッドの終了とは無関係に存在し続ける。誰も参照しなくなったとき、はじめて GC の回収対象になる。
よくある勘違い:
String s = "abc";と書いたとき、sの中に文字列が入っていると思いがち。実際にはsはスタック上の小さな箱で、ヒープ上の本体へのアドレスが入っているだけ。
3. オブジェクトとインスタンスの違い
混同されやすい用語を整理しておく。
class Dog {
String name;
void bark() { System.out.println("Bow!"); }
}
Dog d = new Dog();
- クラス(
Dog): 設計図。メモリ上に「こういう構造のものを作れる」という定義が存在するだけで、それ自体は実体を持たない。 - インスタンス:
new Dog()によってヒープに生成された実体。dが指しているヒープ上のDogオブジェクトがインスタンス。 - オブジェクト: インスタンスとほぼ同義で使われることが多い。厳密には「すべてのインスタンスはオブジェクトだが、文脈によって使い分けられる」程度の差。Java Silver 試験の範囲では同義と思って問題ない。
【クラス定義(メソッドエリア)】
┌──────────────────────┐
│ class Dog │
│ - name: String │
│ - bark(): void │
└──────────────────────┘
│
│ new Dog() × 3 回
▼
【ヒープ領域】
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Dog │ │ Dog │ │ Dog │
│ name: │ │ name: │ │ name: │
│ "Pochi" │ │ "Hachi" │ │ "Kuro" │
└──────────┘ └──────────┘ └──────────┘
1 つのクラス定義から、何個でもインスタンスを生成できる。インスタンスはそれぞれ独立した状態を持つ。
4. == と equals がなぜ違うのか
ここが最も「なんとなく」で乗り切られやすく、そして最も危険な箇所だ。
String a = new String("test");
String b = new String("test");
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true
なぜか。メモリ図を見れば一目瞭然だ。
【スタック】 【ヒープ】
┌──────────┐ ┌──────────────────┐
│ a: 0x3C10│ ──────►│ String: "test" │ ← アドレス 0x3C10
└──────────┘ └──────────────────┘
┌──────────┐ ┌──────────────────┐
│ b: 0x5F88│ ──────►│ String: "test" │ ← アドレス 0x5F88
└──────────┘ └──────────────────┘
new を 2 回呼べば、内容が同じでも別のオブジェクトが 2 つ作られる。a と b はそれぞれ別のアドレスを持っている。
a == bは「aとbが同じアドレスを指しているか」を比較する →0x3C10 != 0x5F88なのでfalsea.equals(b)は「aとbが指すオブジェクトの内容が同じか」を比較する →"test" == "test"なのでtrue
== は参照の比較、equals() は内容の比較。これはルールではなく、メモリ構造から来る必然だ。
実務の落とし穴:
Stringの比較を==で書いてもコンパイルエラーにならない。テストは通ることもある(後述の String pool 効果で)。しかし本番でnew String(...)やDBから取得した値と比較すると突然falseになる。String の比較は必ずequals()を使う。
5. Integer キャッシュの正体
これはよく「謎挙動」として語られるが、仕組みを知れば当然の最適化だとわかる。
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false
なぜこうなるのか
Integer a = 127; はオートボクシングによって Integer.valueOf(127) に展開される。
Integer.valueOf() の実装は以下のようになっている(概念的に):
public static Integer valueOf(int i) {
if (i >= -128 && i <= 127) {
return IntegerCache.cache[i + 128]; // キャッシュから返す
}
return new Integer(i); // 範囲外は毎回 new
}
JVM 起動時に -128 〜 127 の範囲の Integer オブジェクトがあらかじめ作られてキャッシュされている。この範囲への valueOf() 呼び出しは、毎回同じオブジェクトを返す。
【ヒープ(Integer キャッシュ領域)】
┌──────┬──────┬──────┬─────┬──────┬──────┐
│ -128 │ -127 │ ... │ 126 │ 127 │ │
└──────┴──────┴──────┴─────┴──────┴──────┘
Integer a = 127; → a は cache[255] を指す (0x9A01)
Integer b = 127; → b も cache[255] を指す (0x9A01)
→ a == b は true(同じアドレス)
Integer c = 128; → new Integer(128) → 0xB3C0
Integer d = 128; → new Integer(128) → 0xD5F1
→ c == d は false(別のアドレス)
なぜ -128〜127 なのか
byte 型の範囲(-128 〜 127)と一致しているのは偶然ではない。この範囲の整数はプログラムで圧倒的に頻出する(ループカウンタ、配列インデックス、フラグ値など)。頻出するオブジェクトを毎回 new するのは無駄なので、JVM が最初からキャッシュしておく設計になっている。
重要:
Integerに限らず、Byte、Short、Long(-128〜127)、Character(0〜127)でも同様のキャッシュが存在する。BooleanはTRUEとFALSEの 2 つしかないので全部キャッシュされている。暗記ではなく理解を: 「-128〜127 は
==で比較できる(範囲外はできない)」と暗記するのではなく、「Integerの==比較は参照比較なのでそもそも使うべきでない」と理解する。キャッシュは実装の詳細であり、いつか変わるかもしれない。
6. String pool とは何か
String a = "hello";
String b = "hello";
System.out.println(a == b); // true ← なぜ?
new を使っていないのに、なぜ == が true になるのか。
String pool の仕組み
JVM はヒープ内に String pool(文字列定数プール)という特殊な領域を持っている。文字列リテラル("hello" のようにダブルクォートで書いた文字列)を処理するとき、JVM は次のように動作する。
- pool に
"hello"がすでに存在するか確認する - 存在すれば → そのオブジェクトへの参照を返す
- 存在しなければ → pool に新しい
Stringオブジェクトを作り、その参照を返す
【スタック】 【ヒープ(String pool)】
┌──────────┐ ┌──────────────────┐
│ a: 0x2200│ ──────►│ String: "hello" │ ← pool 内の唯一の実体
└──────────┘ └──────────────────┘
▲
┌──────────┐ │
│ b: 0x2200│ ─────────────────┘ ← 同じアドレス
└──────────┘
a と b が同じオブジェクトを指しているので、a == b は true になる。
new String() との違い
String a = "hello"; // pool を使う
String b = new String("hello"); // pool を使わず、ヒープに新規作成
System.out.println(a == b); // false(別のアドレス)
System.out.println(a.equals(b)); // true(内容は同じ)
【ヒープ(String pool)】
┌──────────────────┐
│ String: "hello" │ ← a が指す(pool 内)
└──────────────────┘
【ヒープ(通常領域)】
┌──────────────────┐
│ String: "hello" │ ← b が指す(pool 外、別オブジェクト)
└──────────────────┘
new String("hello") は pool を経由せず、常に新しいオブジェクトをヒープに生成する。そのため a == b は false になる。
なお、intern() メソッドを使えば pool に登録して同一参照を得ることもできるが、実務でそれが必要になることはほとんどない。
設計意図: String はプログラム中で最も使われるオブジェクトの一つ。同じ内容の文字列を何千個も作っていたら、メモリを大量に消費する。pool による再利用は正当なメモリ最適化だ。また
Stringが**イミュータブル(不変)**に設計されているのも、pool での共有を安全にするためだ。可変であれば、一方が内容を変えたとき pool 内の共有オブジェクトが壊れてしまう。
7. static が「共有」である理由
class Counter {
int count = 0; // インスタンス変数
static int total = 0; // static 変数
}
Counter c1 = new Counter();
Counter c2 = new Counter();
c1.count = 5;
Counter.total = 10;
【ヒープ】
┌──────────────┐ ┌──────────────┐
│ Counter c1 │ │ Counter c2 │
│ count: 5 │ │ count: 0 │
└──────────────┘ └──────────────┘
【メソッドエリア(クラス情報)】
┌──────────────────────┐
│ class Counter │
│ static total: 10 │ ← インスタンスに属さない
└──────────────────────┘
インスタンス変数(count)はヒープ上の各インスタンスごとに存在する。c1.count を変えても c2.count は変わらない。
一方 static 変数(total)は、クラス情報が格納されるメソッドエリア(JVM 内のクラスメタデータ領域)に存在する。インスタンスが何個作られても、static 変数はクラスに 1 つだけ。どのインスタンスから触っても、同じ値にアクセスする。
これが「static は共有」の正体だ。ルールとして覚えるのではなく、メモリ上の置き場所が違うと理解すれば、「なぜ static メソッドから this が使えないのか」も自明になる(this はインスタンスへの参照だが、static の文脈ではどのインスタンスを指せばいいか決まらない)。
8. Spring DI が急に理解できる瞬間
Spring Framework を使い始めると「DI(依存性注入)」という概念が出てくる。最初はなんとなく「フレームワークがよしなにしてくれる」程度の理解で使い始めるが、参照の概念があれば一気に明快になる。
UserService s1 = context.getBean(UserService.class);
UserService s2 = context.getBean(UserService.class);
System.out.println(s1 == s2); // true(デフォルトは Singleton スコープ)
Spring のデフォルト動作(Singleton スコープ)では、getBean() を何度呼んでも同じインスタンスへの参照が返される。
【スタック】 【ヒープ】
┌──────────┐ ┌──────────────────────┐
│ s1: 0xAA1│ ──────►│ UserService インスタンス│
└──────────┘ │(Spring が管理) │
└──────────────────────┘
┌──────────┐ ▲
│ s2: 0xAA1│ ────────────────┘ ← 同じアドレス
└──────────┘
Spring は起動時に UserService を 1 つだけ new してヒープに置き、その参照を DI コンテナが管理する。@Autowired や getBean() は「新しいオブジェクトを作る」のではなく、「管理済みの参照を渡す」操作だ。
この理解があれば:
- なぜ
@Serviceクラスにインスタンス変数でリクエスト固有データを持たせてはいけないのか(全リクエストで同じインスタンスを共有するから、スレッドセーフでなくなる) @Scope("prototype")が何をしているのか(getBean()のたびに新しいインスタンスをnewするよう変える)- なぜ
@Autowiredでフィールドインジェクションよりコンストラクタインジェクションが推奨されるのか
といった疑問が次々と解消していく。
9. 結論:Java はかなり「物理的」な言語だ
Java はよく「C と違ってメモリを気にしなくていい」と言われる。GC があるから解放を考えなくていい、というのは正しい。
しかし**「どこに何が置かれているか」という構造は、Java も C も変わらない**。ただ Java はその構造をうまく隠蔽しているだけだ。
今回の内容を整理する。
| 概念 | 正体 |
|---|---|
| 変数(参照型) | スタック上にある、ヒープへのアドレス |
new | ヒープ上にオブジェクトを生成し、アドレスを返す |
==(参照型) | 2 つの変数が同じアドレスを指しているか |
equals() | オブジェクトの内容を比較するメソッド |
| String pool | ヒープ内のリテラル文字列キャッシュ領域 |
| Integer キャッシュ | -128〜127 の Integer を事前生成・再利用する最適化 |
static | クラスメタデータ領域に存在する、インスタンス非依存の値 |
| Spring DI | 管理済みインスタンスへの参照を配布する仕組み |
これらは別々の「覚えること」ではなく、一つのメモリモデルから導かれる当然の帰結だ。
「Javaはメモリを隠蔽しているので雰囲気で書けてしまう」という性質は、入門期には優しいが、中級以降では理解の先送りとして蓄積する。ある日突然、何もかもが繋がらなくなる。
今がその「先送りを回収する」タイミングだ。
次に学ぶべき内容
メモリモデルへの理解が深まると、以下のテーマが格段に面白くなる。
- GC(ガベージコレクション)の仕組み: 参照がなくなったオブジェクトをどうやって検知・回収するか。世代別 GC の設計思想。
- JVM の内部構造: スタック・ヒープ・メソッドエリア・PC レジスタ・ネイティブスタック。
- Java Memory Model(JMM): マルチスレッド時のメモリ可視性・
volatile・synchronizedの正体。 - JOL(Java Object Layout): 実際のオブジェクトがヒープ上でどのようなバイト列として並んでいるか確認できるツール。
- String の内部実装: Java 9 以降では
byte[]ベースの Compact Strings になっている。 - 参照の種類: 強参照・弱参照・ソフト参照・ファントム参照と GC の関係。
「暗記 Java」を卒業した先には、設計を理由ごと理解できる Java エンジニアがいる。