아이템11은 모든 객체의 공통 메서드 중 hashCode()
의 재정의에 대해 설명하고 있다.
핵심 주제는 'equals를 재정의한 클래스 모두 hashCode도 재정의해야한다.' 이다.
아래는 hashCode 일반 규약 세 가지이다.
1. equals 비교에 사용되는 정보가 변경되지 않았다면, hashCode 메서드는 항상 같은 값을 반환해야 한다.
(애플리케이션을 다시 실행했다면 달라질 수 있음.)
2. 두 객체에 대한 equals가 같다면, hashCode의 값도 같아야한다.
즉, 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.
3. 두 객체에 대한 equals가 다르더라도, hashCode의 값은 같을 수 있지만 해시 테이블 성능을 고려해 다른 값을 리턴하는 것이 좋다.
그렇다면 왜 equals를 재정의한 클래스는 모두 hashCode를 재정의해야하는지 알아보자.
아래는 equals()
만 재정의한 PhoneNumber.class
이다.
package item11;
public final class PhoneNumber {
private final short first, middle, end;
public PhoneNumber(int first, int middle, int end) {
this.first = rangeCheck(first, 999, "지역코드");
this.middle = rangeCheck(middle, 9999, "중간");
this.end = rangeCheck(end, 9999, "끝");
}
@Override
public boolean equals(Object o) { // 객체의 속성 값이 모두 같으면 같은 객체라고 재정의.
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PhoneNumber that = (PhoneNumber) o;
if (first != that.first) return false;
if (middle != that.middle) return false;
return end == that.end;
}
// short 로 형변환 하는 코드는 생략하였다.
}
예제를 보면,
public class Main {
public static void main(String[] args) {
Map<PhoneNumber, String> m = new HashMap<>();
PhoneNumber p1 = new PhoneNumber(011, 1111, 1111);
PhoneNumber p2 = new PhoneNumber(02, 222, 2222);
System.out.println(p1.equals(p2)); // false
System.out.println("p1의 해시코드 : " + p1.hashCode()); // 1435804085
System.out.println("p2의 해시코드 : " + p2.hashCode()); // 1784662007
m.put(p1, "아이유");
m.put(p2, "제니");
// 이 번호가 아이유 번호라고? 다시 확인해봐야지.
String thisNumber = m.get(new PhoneNumber(011, 1111, 1111));
System.out.println(thisNumber); // ?
}
}
마지막 줄 ?
는 어떻게 출력될까?
핸드폰 번호가 '아이유'와 동일하다. 다른 객체긴 하지만 우리가 equals()
로 객체의 속성 값이 모두 같으면 동등하다고 작성했다.
그렇다면 thisNumber
는 "아이유"를 출력할 것 같다!
.
null // 없는 번호입니다.
🙄 ?
이게 바로 hashcode의 두 번째 규약을 지키지 못한 코드이다.
(* 두 번째 규약 : 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.)
PhoneNumber.class
는 hashCode()
를 재정의 하지 않았기 때문에 논리적으로 같은 두 객체가 서로 다른 해시코드를 반환했고, 그 결과 get()
는 엉뚱한 해시 버킷에서 객체를 찾으려 한 것이다.
(* 설사 버킷이 같더라도, HashMap은 해시코드가 다른 엔트리끼리는 값 비교를 시도조차 않는다.)
결국 우리는 PhoneNumber.class
에 hashCode()
를 재정의 해야만 한다. 어떻게 해야할까?
미리 말하지만 아래와 같은 코드는 절대 해선 안되는 코드이다.
@Override
public int hashCode() { return 42; }
// `hashCode()`를 재정의해서 모두 같은 해시코드를 갖게하기
위의 코드는,
1. 같은 해시코드를 반환.
2. 모든 객체가 같은 버킷에 담김.
3. 해당 객체들은 버킷 내에서 연결리스트처럼 동작.
결국 평균 수행 시간을 O(1) → O(n) 으로 느려지게 만든다.
좋은 해시 함수라면 서로 다른 객체에 다른 해시코드를 반환해야한다.
이것이 hashCode의 세 번째 규약이다.
(* 세 번째 규약 : 해시코드는 같을 수 있지만, 성능을 고려해 다른 값을 리턴하는 것이 좋다.)
다음은 이펙티브 자바에서 말하는 '좋은 hashCode를 작성하는 요령'을 예시 코드로 작성해보았다.
public class Wizard { // 마법사
private final int no; // 식별 번호
private final Feature feature; // 특징
private final String[] friends; // 친구들
@Override
public int hashCode() {
// int 타입 변수 result에 필드마다 해시함수를 거쳐서 해시코드를 담아줄 것
// 기본 타입 필드 : Type.hashCode(필드)
int result = Integer.hashCode(no);
// 참조 타입 필드 : 해당 클래스에서 재정의한 hashCode()를 사용
result = 31 * result + feature.hashCode(); // 필드.hashCode()
// 배열 타입 필드 : Arrays.hashCode(필드)
result = 31 * result + Arrays.hashCode(friends);
return result;
}
// 생성자 및 equals() 는 생략했다.
// ...
}
int 타입 result 객체에 필드마다 해시코드를 구해서 계속 더해주는데, 더해줄때마다 31
을 곱해주면된다.
31인 이유? :
31은 소수이면서 홀수이기 때문에 선택된 값이다. 만일 그 값이 짝수였고 곱셈 결과가 오버플로되었다면 정보는 사라졌을 것이다. 2로 곱하는 것은 비트를 왼쪽으로 shift하는 것과 같기 때문이다. 소수를 사용하는 이점은 그다지 분명하지 않지만 전통적으로 널리 사용된다. 31의 좋은 점은 곱셈을 시프트와 뺄셈의 조합으로 바꾸면 더 좋은 성능을 낼 수 있다는 것이다(31 * i는 (i << 5) - i 와 같다). 최신 VM은 이런 최적화를 자동으로 실행한다.
- 현대 컴파일러와 JVM의 JIT 컴파일러는 곱셈과 관련된 일반적인 패턴을 감지하고, 필요한 경우 이를 비트 시프트 연산으로 최적화할 수 있다고한다.
아래 코드는 우리가 오버라이딩한 hashCode()
를 사용하는 예제이다.
public class Main {
public static void main(String[] args) {
Map<Wizard, String> map = new HashMap<>();
// 학번, 특징, 친구들
Wizard w1 = new Wizard(1, "번개 흉터", new String[]{"론, 헤르미온느"});
Wizard w2 = new Wizard(2, "금색 머리", new String[]{"크레브, 고일"});
map.put(w1, "해리포터");
map.put(w2, "말포이");
String s = map.get(new Wizard(1, "번개 흉터", new String[]{"론, 헤르미온느"}));
System.out.println(s); // 해리포터
}
}
구현하는 방법은 어렵지 않지만, 직접 구현할 필요 없이,
이러한 기능은 IDE에서 제공해주기도 하고, lombok, AutoValue, guava도 이런 기능을 제공한다.
아래는 롬복을 사용한 코드다.
@EqualsAndHashCode // lombok
public class Wizard {
private final int no;
private final Feature feature;
private final String[] friends;
public Wizard(int no, String ft, String[] friends) {
this.no = no;
feature = new Feature(ft);
this.friends = friends;
}
}
equals()
에 쓰이지 않는 필드는 반드시 제거해야 한다.
해시코드를 계산할 때 equals()
에서 사용하지 않는 필드까지 포함하게 되면 다른 해시코드를 반환하게 된다. 이는 성능 저하 뿐만아니라 hashCode
의 규약을 깨뜨린다.
성능을 높인답시고 해시코드를 계산할 때 핵심필드를 생략해서는 안된다. 속도는 빨라지겠지만, 해시 품질이 나빠져 해시테이블의 성능을 심각하게 떨어뜨릴 수도 있다.
hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자. 그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있다.
요약하면, 구체적인 생성 규칙을 공개하지 않음으로써, 개발자는 내부 구현을 자유롭게 변경할 수 있고, 사용자는 해당 메서드의 반환 값에 너무 의존하지 않게 되어, 전반적으로 소프트웨어의 품질과 유지 보수성이 향상된다.
요즘 VM들은 최적화를 자동으로 해준다.
: JVM 말고도 다른 VM들도 뜻한다. 해당 부분은 31 * i
가 i << 5) - 1
로 최적화 된다는 내용에 포함된 내용인데, 이것은 JIT 컴파일러가 이런 패턴을 인식하고, 코드가 런타임에 실행될 때 해당 연산을 더 효율적인 비트 연산으로 자동 변환할 수 있다고 한다.
비결정적(undeterminisitc) 요소
: 다른 것들과 비교할 때 결정적인 요소가 아님을 뜻한다. 현재 아이템 내용에서는 equals
비교할 때 '핵심 필드가 아닌 요소' 로 해석할 수 있고, '해시코드 생성에서 고려되지 않아야 하는 요소'로 해석할 수 있다.
URL처럼 계층적인 이름을 대량으로 사용하면 심각한 오류 유발 가능성
: 자바2 전의 String은 문자열이 길면 균일하게 나눠 최대 16문자만 뽑아내 해시코드를 계산했는데, URL처럼 계층적인 이름은 나머지 부분의 문자열이 크게 다르지 않기 때문에 해시충돌이 일어날 확률이 높다.
https://example.com /user/ data
https://example.com /admin/ data
* 균일하게 나누다가 /user/ 와 /admin/ 부분이 누락되면 해싱충돌이 일어날 수 있다.