String a = "hello";
String b = "hello";
System.out.println(a == b); // true
String c = new String("hello");
String d = new String("hello");
System.out.println(c == d); // false
같은 "hello"를 담고 있는데, 비교 결과가 다르다. == 는 어떤 기준으로 비교하는 걸까.
Java 에서 == 는 두 변수가 같은 메모리 주소를 가리키는지를 본다. 객체의 내용이 같은지는 전혀 따지지 않는다.
String a = "hello";
String b = "hello";
a → 0x1a2b3c4d "hello"
b → 0x1a2b3c4d "hello" ← 같은 주소 → a == b : true
---
String c = new String("hello");
String d = new String("hello");
c → 0x5e6f7a8b "hello"
d → 0x9c0d1e2f "hello" ← 다른 주소 → c == d : false
new String("hello") 를 두 번 쓰면 JVM 은 Heap 에 별개의 객체를 두 개 만든다. 내용은 같아도 메모리 주소가 다르니 == 는 false 를 돌려준다.
equals() 는 두 객체의 내용이 같은지를 본다. String 은 equals() 를 재정의해서 문자 하나하나를 비교한다.
System.out.println(c.equals(d)); // true
이 차이를 흔히 동일성(identity) 과 동등성(equality) 으로 표현한다. == 은 같은 메모리 주소를 가리키는지, equals() 는 내용이 같은지를 본다.
그런데 위 String a == b 비교는 왜 true 였을까. 분명히 두 변수를 따로 선언했는데.
"hello" 처럼 따옴표로 직접 쓴 문자열을 리터럴이라고 한다. JVM 은 리터럴을 처리할 때 Heap 안에 따로 관리하는 영역인 String Pool 을 먼저 확인한다. 같은 문자열이 이미 Pool 에 있으면 새 객체를 만들지 않고 기존 것을 재사용한다.
String a = "hello" 와 String b = "hello" 는 둘 다 같은 Pool 객체를 가리키므로 == 가 true 가 된다.
String 이 불변(immutable)이기 때문에 이 재사용이 안전하다. 한 변수에서 내용을 바꾸면 새 String 이 만들어질 뿐, 다른 변수가 가리키는 Pool 객체는 건드리지 않는다.
new String("hello") 는 명시적으로 새 객체를 요청하는 것이라 Pool 을 무시하고 Heap 에 따로 만든다. 그래서 c == d 는 false 다.
String 은 Pool 때문에 예상과 다른 동작을 했다. 숫자 타입에도 비슷한 함정이 있다.
int, long 같은 원시 타입(primitive)은 객체가 아니다. 값 자체가 스택에 직접 저장되기 때문에 메모리 주소라는 개념이 없다. == 로 비교하면 값을 그대로 비교한다.
int a = 128;
int b = 128;
System.out.println(a == b); // true — 값 자체를 비교
Integer, Long 같은 래퍼 타입(wrapper)은 다르다. 이들은 객체라서 Heap 에 저장되고, == 는 메모리 주소를 비교한다.
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false
127 까지는 true, 128 부터는 false 다. JVM 이 자주 쓰이는 작은 정수값을 미리 캐시해두기 때문이다. -128 ~ 127 범위는 캐시된 객체를 재사용하므로 == 가 true 가 된다. 범위를 넘어서면 새 객체를 만들어서 메모리 주소가 달라진다.
Long 도 같은 범위를 캐시한다. Double 과 Float 은 캐시가 없다. 소수점 값은 가능한 범위가 연속적이라 "자주 쓰이는 값"을 정해 캐시할 수 없기 때문이다.
equals() 를 재정의하지 않은 클래스에서 equals() 를 호출하면 Object 의 기본 구현이 사용된다.
// Object.equals() 기본 구현
public boolean equals(Object obj) {
return (this == obj);
}
기본 구현은 == 과 동일하게 참조를 비교한다. 아래처럼 같은 값을 가진 객체를 두 개 만들어도 equals() 가 false 를 돌려준다.
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1.equals(p2)); // false — equals() 미재정의, 참조 비교
System.out.println(p1 == p2); // false — new 로 만든 서로 다른 객체
p1 과 p2 는 값은 같지만 new 로 생성한 별개의 객체라 메모리 주소가 다르다. 커스텀 클래스에서 값 기반 비교를 하려면 반드시 equals() 를 직접 재정의해야 한다.
객체 비교에는 equals() 를 쓰는 것이 기본이다. == 는 두 변수가 정확히 같은 객체를 가리키는지 확인할 때만 의도적으로 사용한다.
String 리터럴이나 Integer 캐시 범위처럼 == 가 true 로 보이는 경우가 있지만, 이는 JVM 의 최적화 동작이지 값 비교의 결과가 아니다. String 에 new 를 쓰거나 Integer 가 127 을 넘어가는 순간 결과가 달라진다. 값이 같은지 비교하려는 상황이라면 항상 equals() 를 써야 한다.