동일성은 두 객체가 메모리 상에서 완전히 같은 주소를 가리키는 경우를 말한다. 즉, 변수가 같은 인스턴스를 참조하고 있는지를 확인하는 것이다. 자바에서는 이를 == 연산자를 사용해서 비교한다.
두 객체가 메모리 상에서는 다른 위치에 존재하더라도, 논리적으로 같은 값 또는 같은 상태를 가지고 있는 경우를 의미한다. 자바에서는 equals() 메서드를 사용하여 비교한다.
public boolean equals(Object obj) {
return (this == obj);
}
이와 같이 Object의 equals()는 내부적으로 == 연산자를 사용하여 동일성을 비교한다. 즉, 두 객체의 메모리 주소값이 같은지를 확인한다.
위에서는 동등성을 비교할 때 equals()를 사용한다고 했는데, 왜 여기서는 동일성을 비교하나 싶을 수 있다.
Object 클래스는 모든 클래스의 조상 클래스로, 자식 클래스가 어떤 논리적인 동등성을 가지는지 알 수 없다.
따라서 우리가 만든 클래스에서 논리적인 동등성을 비교하고 싶다면 equals() 메서드를 반드시 오버라이드해야 한다.
@IntrinsicCandidate
public native int hashCode();
💡 참고로
hashCode()의 구현은 자바 코드가 아닌, JVM 자체에 C나 C++ 같은 네이티브 언어로 작성되어 있다.
hashCode()는 객체의 메모리 주소를 기반으로 해서 해시 코드를 생성한다. 자바 언어 자체는 개발자가 메모리 주소에 직접 접근하는 것을 허용하지 않는다. 이 작업은 JVM의 영역이므로 메모리 주소를 직접 다룰 수 있는 네이티브 코드로 구현해야 한다.- 또한 메모리 주소를 가져와서 정수로 변환하는 작업은 빠르게 처리되어야 하는데, JVM에 내장된 네이티브 코드로 실행하면 자바 코드를 거치는 것보다 훨씬 효율적이다.
hashCode()는 객체의 메모리 주소를 기반으로 한 정수 값, 즉 해시 코드를 반환한다. 이는 HashSet이나 HashMap과 같은 해시 기반 컬렉션에서 객체를 효율적으로 검색하고 관리하기 위해 사용된다.
우리가 보통 문자열이 같은지 다른지를 비교할 때 String의 equals()를 사용할 것이다. 그렇다면 String의 equals()는 Object와 다를까?
String 클래스는 Object의 equals()와 hashCode()를 오버라이드하여 동등성을 비교하도록 구현했다.
📌equals()
public boolean equals(Object anObject) {
// 1. 동일성 검사 : 메모리 상의 같은 객체라면 true
if (this == anObject) {
return true;
}
// 2. 동등성 검사
return (anObject instanceof String aString) // 비교 대상이 String이 맞는지 확인
&& (!COMPACT_STRINGS || this.coder == aString.coder) // (내부 최적화) 문자열 인코딩 방식이 맞는지 확인
&& StringLatin1.equals(value, aString.value); // 실제 문자 배열(value)의 내용을 하나씩 비교
}
equals()는 두 String 객체의 문자열 내용 자체를 비교한다.
String 객체는 내부적으로 value라는 byte 배열에 실제 문자 데이터를 저장하기 때문에, StringLatin1.equals(value, aString.value)에서 배열의 각 요소를 처음부터 끝까지 하나하나 비교한 후 결과를 반환한다.
📌hashCode()
public int hashCode() {
int h = hash; // 1. 캐시된 해시 코드 먼저 확인
// 2. 해시 코드가 계산된 적 없다면 = 캐시가 비어있다면
if (h == 0 && !hashIsZero) {
// 3. 문자열의 실제 내용(value)을 기반으로 해시 코드 계산
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}
hashCode()는 캐싱과 불변성이라는 특징을 가진다.
먼저 String 객체는 한 번 생성되면 그 내용이 절대 변하지 않는다. 내용이 변하지 않으므로, 해시 코드도 한 번만 계산하면 절대 변할 일이 없다.
이러한 불변성 덕분에, hashCode()는 처음 호출될 때만 실제 계산을 수행한다. 계산된 값은 객체 내부의 hash라는 필드에 저장되며, 그 다음부터 hashCode()가 호출되면 hash 필드에 저장된 값을 반환한다.
==를 사용해도 동작하는 이유는..new 키워드를 사용하여 생성하지 않은 문자열 리터럴은 문자열 상수 풀(String Constant Pool)에 저장된다. 따라서 동일한 문자열 리터럴을 참조하면 == 연산자가 true를 반환할 수 있다. 하지만 new 키워드를 사용하여 문자열을 생성하면 새로운 객체가 생성되므로 == 연산자가 false를 반환할 수 있다.
따라서 문자열 비교 시 항상 equals() 메서드를 사용한 동등성 비교를 하는 것이 좋다.
위에서 확인한 String에서도 equals()만 오버라이드하는 것이 아닌, hashCode()도 같이 오버라이드 해주었다. 꼭 hashCode()도 오버라이드 해주어야 할까?
Java 공식 문서에 나와 있는 내용을 바탕으로 객체 A, B가 있다고 가정해보자.
A.equals(B)가 true면 A.hashCode()와 B.hashCode()의 값은 반드시 같아야 한다.A.hashCode()와 B.hashCode()의 값이 같더라도 A.equals(B)가 false일 수 있다. 이를 해시 충돌이라고 한다.💡 해시 충돌(Hash Collision)이란?
서로 다른 키들이 같은 해시 값을 가질 때 발생하는 것으로, 한정된 버킷의 크기 때문에 여러 키들이 같은 해시 값을 가질 수 있다.
equals()를 오버라이드 했다면, 위의 사항들을 지키기 위해 반드시 hashCode()도 오버라이드해야 한다. 그렇지 않으면 해시 기반 컬렉션이 제대로 동작하지 않을 수 있다.
해시 값을 사용하는 HashMap이나 HashSet, Hashtable과 같은 Collection들은 객체가 논리적으로 같은지 비교할 때 위와 같은 과정을 거친다.
먼저 hashCode()의 리턴 값, 즉 해시 코드 값이 같은지 확인하고, 같다면 equals()로 같은 객체인지 확인한다. 따라서 두 객체가 equals()로 같다고 판단되면, 같은 해시 버킷에 있어야 하고 해시 코드가 같아야 한다는 것이다.
* 더 자세한 내용은 HashMap 포스트에 정리해두었다.