SJ blog
beginner
S

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

==とequals()はなぜ違うのか — Stringを==で比較してはいけない本当の理由

Javaで==とequals()がなぜ別物なのか。参照比較と値比較の違い、Stringプール、nullとの比較を根本原理から解説する初心者向け記事。

一言結論

==はメモリアドレス(参照)が同じかを比較し、equals()は値が同じかを比較する。Stringリテラルが==で動くように見えるのはStringプールの副作用であり、本質的な比較には必ずequals()を使う。

よくあるバグから始まる

String a = new String("hello");
String b = new String("hello");

if (a == b) {
    System.out.println("同じ");
} else {
    System.out.println("違う");  // ← こちらが出力される
}

ab"hello"なのに「違う」と出力される。初心者が最初につまずくJavaの落とし穴の一つだ。

なぜこうなるのか。「equals()を使えばいい」だけを覚えても、次に同じ間違いをする。仕組みから理解しよう。

==はメモリアドレスの比較だ

Javaの変数には2種類ある:プリミティブ型int, doubleなど)と参照型String, 配列, クラスのオブジェクトなど)だ。

プリミティブ型の変数は値そのものを直接格納している:

int x = 42;
┌─────┐
│  42 │  ← 変数xの中に42という値が入っている
└─────┘

参照型の変数は値そのものではなく、オブジェクトが格納されているメモリアドレス(参照)を格納している:

String a = new String("hello");

メモリのどこか(アドレス 0x100):
┌─────────┐
│ "hello" │  ← Stringオブジェクト本体
└─────────┘

変数a: 0x100  ← aには「0x100番地を見ろ」という情報が入っている

==は「変数の中身がイコールか」を比較する。

  • プリミティブ型なら: 値を比較 → 直感通りに動く
  • 参照型なら: メモリアドレスを比較 → 「同じ場所を指しているか」を比較

だから:

String a = new String("hello");  // アドレス 0x100 に "hello" を作る
String b = new String("hello");  // アドレス 0x200 に "hello" を作る

a == b  // 0x100 == 0x200 → false(違うアドレス)

2つの"hello"別々のメモリに確保されたオブジェクトだ。中身が同じでも別の場所にある。==はその住所を比較するのでfalseになる。

equals()は中身(値)の比較だ

equals()はオブジェクトの「意味的な等しさ」を比較するメソッドだ。Stringクラスはequals()を「文字列の中身が同じかどうか」で比較するように実装している:

String a = new String("hello");
String b = new String("hello");

a.equals(b)  // 文字列の中身を比較 → true

内部では文字列を1文字ずつ比較している。住所は違っても中身が同じならtrueを返す。

なぜリテラル比較では==が「動いてしまう」のか

ここが混乱の原因だ:

String a = "hello";
String b = "hello";

a == b  // なぜか true になる

newを使っていない場合、Stringプールという仕組みが働く。

JVMは文字列リテラル("hello"のようなダブルクォートで囲まれた文字列)を特別な場所(Stringプール)にキャッシュする:

Stringプール(特別なメモリ領域):
  "hello" → アドレス 0x100

String a = "hello";  → aに 0x100 が入る(プールから取得)
String b = "hello";  → bに 0x100 が入る(同じくプールから取得)

a == b  → 0x100 == 0x100 → true

同じ文字列リテラルは同じオブジェクトを指すので、==trueになる。

でもこれはStringプールという最適化の副作用であって、文字列の中身を比較しているわけではない。

String a = "hello";
String b = new String("hello");  // newを使うとプールを使わない

a == b        // false(プールの0x100 vs 新しく作った0x200)
a.equals(b)   // true(中身は同じ)

new String(...)はStringプールを使わず、毎回新しいオブジェクトを作る。

実践的な落とし穴

ユーザー入力や外部から受け取った文字列は必ずnewで作られたようなオブジェクトになる(プールにはない):

Scanner scanner = new Scanner(System.in);
String input = scanner.nextLine();  // ユーザーが "yes" と入力

if (input == "yes") {   // ❌ 動かない
    System.out.println("OK");
}

if (input.equals("yes")) {  // ✅ 正しい
    System.out.println("OK");
}

==でも一見動くことがあるからこそ厄介だ。テストのときはたまたまプールにある文字列と比較してtrueになり、本番でユーザー入力が来たときだけfalseになる、というバグになりやすい。

nullとの比較に注意する

equals()にはもう一つの落とし穴がある:

String s = null;
s.equals("hello");  // NullPointerException!

nullはオブジェクトではないので、メソッドを呼び出せない。

安全な書き方:

// パターン1: null チェックを先にする
if (s != null && s.equals("hello")) { ... }

// パターン2: 比較する文字列を先に書く(Yoda記法)
if ("hello".equals(s)) { ... }  // sがnullでも NullPointerException にならない

パターン2は「hello」というリテラルは絶対nullではないので、"hello".equals(null)falseを返すだけでクラッシュしない。

Objectクラスのデフォルトequals()

実は、equals()Objectクラスで定義されており、すべてのクラスが継承している。デフォルトのequals()==と同じ動作(参照比較)をする:

// Objectクラスのデフォルト実装(概念的な擬似コード)
public boolean equals(Object obj) {
    return (this == obj);  // デフォルトは参照比較
}

Stringはこれをオーバーライドして、文字列の中身で比較するように変えている。自作クラスの場合、equals()をオーバーライドしなければデフォルトの参照比較になる:

public class Point {
    int x, y;
    public Point(int x, int y) { this.x = x; this.y = y; }
}

Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);

p1 == p2        // false(別のオブジェクト)
p1.equals(p2)   // false(デフォルトのequalsは参照比較なので)

座標が同じなら「等しい」と判定したければ、equals()をオーバーライドする必要がある。

まとめ

==           = メモリアドレス(参照)が同じかを比較
equals()     = オブジェクトの中身(値)が同じかを比較
Stringプール  = リテラル文字列をキャッシュする仕組み。==が偶然trueになる副作用の原因
new String() = Stringプールを使わない。==は必ずfalseになる

ルール:
  プリミティブ型(int, double...) → == で比較してOK
  参照型(String, オブジェクト)   → equals()で比較する
  nullかもしれない変数            → "literal".equals(変数) が安全

==が動いているように見えるケースがあるからこそ、気づきにくいバグになる。「参照比較と値比較は別物」という理解が、このクラスのバグを根絶する。