equals를 재정의한 클래스 모두에서 hashCode를 재정의해야 한다. 그렇지 않으면 hashCode 일반 규약을 어기게 되어 hashMap 또는 HashSet같은 컬렉션을 사용할 때 문제를 일으킬 것이다.

[Object 명세에서 발췌한 규약]

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 일관되게 항상 같은 값을 반환해야 한다.

  • equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
  • equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

hashCode를 재정의하지 않으면 발생하는 오류

equals를 재정의했지만, hashCode 재정의를 잘못했을 때 혹은 재정의를 하지 않았을 때, 두번째 조항이 문제가 된다.
equals는 물리적으로 다른 두 객체를 논리적으로는 같다고 할 수 있지만, hashCode 메서드는 이 둘을 전혀 다르다고 판단하기 때문에, 서로 다른 값을 반환한다.

hashCode 재정의를 하지 않았을 때의 오작동 예시를 들어보자.

class Main {
    public static void main(String[] args){
      Map<PhoneNumber, String> m  = new HashMap<>();
      m.put(new PhoneNumber(010,1234,5678), "jinyoung");
        
      // expect : jinyoung, but return null
      m.get(new PhoneNumber(010,1234,5678));

    }
}

위의 코드를 살펴보면, 첫번째 인스턴스는 hashMap에 put할 때 사용되었고, 두번째 인스턴스는 hashMap에서 get할 때 사용되었다.
phoneNumber클래스는 hashCode를 재정의하지 않았기 때문에 논리적 동치인 두 객체가 서로 다른 해시코드를 반환하는 것이다.

여기서 m.get()은 null을 반환하는데, hashMap은 hashCode가 성능을 위해 다른 엔트리끼리는 동치성 비교조차 하지 않도록 최적화 되어 있기 때문이다.

hashCode 메서드를 재정의하는 방법

일단, 좋지 않은 hashCode 구현 예시를 먼저 보자.

@Override
public int hashCode() {
    return 42;
}

위의 코드는 동치인 모든 객체에서 똑같은 해시코드를 반환하니 적법하지만, 끔찍하게도 모든 객체에서 똑같은 값만 내어주므로 모든 객체가 해시테이블의 버킷 하나에 담겨 마치 연결리스트처럼 동작한다. 그 결과 평균 수행시간이 O(1)인 해시테이블이 O(n)으로 느려져서, 객체가 많아지면 성능이 매우 떨어지게 된다.

좋은 해시 함수라면 서로 다른 인스턴스에 대해 다른 해시코드를 반환할 것

이것이 hashCode의 세번째 규약이 요구하는 속성이다.

[좋은 HashCode를 작성하는 요령]
( f = 핵심 필드
c = 해당 필드의 해시코드 )

  1. int변수 result를 선언한 후 첫번째 핵심 필드에 대한 hashcode로 초기화 한다.
  2. 기본타입 필드라면 Type.hashCode()를 실행한다
  3. 참조타입이라면 참조타입에 대한 hashcode 함수를 호출 한다.
  4. 값이 null이면 0을 더해 준다.
  5. 필드가 배열이라면 핵심 원소를 각각 필드처럼 다룬다.
  6. 배열의 모든 원소가 핵심필드이면 Arrays.hashCode를 이용한다.
  7. result = 31 * result + c 형태로 초기화 하여 result를 리턴한다.

위의 요령을 적용한 예시를 보자.

class Main {
    @Override
    public int hashCode() {
        int c = 31;
        //1. int변수 result를 선언한 후 첫번째 핵심 필드에 대한 hashcode로 초기화 한다.
        int result = Integer.hashCode(firstNumber);
    
        //2. 기본타입 필드라면 Type.hashCode()를 실행한다
        //Type은 기본타입의 Boxing 클래스이다.
        result = c * result + Integer.hashCode(secondNumber);
    
        //3. 참조타입이라면 참조타입에 대한 hashcode 함수를 호출 한다.
        //4. 값이 null이면 0을 더해 준다.
        result = c * result + address == null ? 0 : address.hashCode();
    
        //5. 필드가 배열이라면 핵심 원소를 각각 필드처럼 다룬다.
        for (String elem : arr) {
          result = c * result + elem == null ? 0 : elem.hashCode();
        }
    
        //6. 배열의 모든 원소가 핵심필드이면 Arrays.hashCode를 이용한다.
        result = c * result + Arrays.hashCode(arr);
    
        //7. result = 31 * result + c 형태로 초기화 하여 
        //result를 리턴한다.
        return result;
    }
}

간략한 예시를 보자.

@Override 
public int hashCode() {
    int result = Short.hashCode(areaCode);
    result = 31 * result + Short.hashCode(prefix);
    result = 31 * result + Short.hashCode(lineNum);
    return result;
}

위의 코드는 핵심 필드 3개를 사용해 계산된 hashCode()이다.
단순하고, 충분히 빠르며, 서로 다른 전화번호들은 다른 해시 버킷들로 제법 훌륭히 분배해준다.

단, 해싱 충돌이 더욱 적은 방법을 써야 한다면 guava의 Hashing을 참고하자.

object class는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공한다.
이 메서드를 이용하면 hashCode를 한줄로 작성할 수 있지만, 속도는 조금 더 느리다. 입력 인수를 담기 위한 배열이 만들어지고, 입력 중 기본타입이 있다면 박싱과 언박싱도 거쳐야 하기 때문이다.
예시를 보자.

@Override
public int hashCode() {
    return Objects.hash(lineNum, prefix, areaCode);
}

클래스가 불변이고 해시코드를 계산하는 비용이 큳면, 캐싱하는 방식을 고려해보자.

예시를 보자.

class Main {
    private int hashCode; 
    
    @Override
    public int hashCode() {
        int result = hashCode;
        if (result == 0) {
            result = Short.hashCode(areaCode);
            result = 31 * result + Short.hashCode(prefix);
            result = 31 * result + Short.hashCode(lineNum);
            hashCode = result;
        }
        return result;
    }   
}

성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 생략해서는 안 된다.

속도야 빨라지겠지만, 해시 품질이 나빠져 해시테이블의 성능을 심각하게 떨어뜨릴 수도 있다. 특히 어떤 필드는 특정 영역에 몰린 인스턴스들의 해시코드를 넓은 범위로 고르게 퍼트려주는 효과가 있을지도 모르는데, 이런 필드를 생략한다면 해당 영역의 수많은 인스턴스가 단 몇 개의 해시코드로 집중되어 해시테이블의 속도가 선형으로 느려질 것이다.

Hashcode가 반환하는 값의 생성규칙 을 API 사용자에게 자세히 공표하지 않는다.

그래야 클라이언트가 hashcode값에 의지해 코드를 짜지 않으며, 추후에 계산 방식을 바꿀 수도 있다.

댓글남기기