[교재 EffectiveJava] 아이템11. equals를 재정의하려거든 hashCode도 재정의하라

반응형
728x90
반응형

equals()와 hashCode()

equals를 재정의한 클래스 모두에서 hashCode도 재정의해야한다. 그렇지 않으면 hashCode 일반 규약을 어기게 되어, 해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 것이다. 

 

▶ Object 명세에서 발췌한 규약

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다.
  • equals가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
  • equals가 두 객체를 다르다고 판단 했더라도 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다.

hashCode 재정의를 잘못했을 때 크게 문제가 되는 조항은 두번째다. 즉, 논리적으로 같은 객체는 같은 해시코드를 반환해야한다.

 

PhoneNumber.java
public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;

    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "area code");
        this.prefix   = rangeCheck(prefix,   999, "prefix");
        this.lineNum  = rangeCheck(lineNum, 9999, "line num");
    }

    private static short rangeCheck(int val, int max, String arg) {
        if (val < 0 || val > max)
            throw new IllegalArgumentException(arg + ": " + val);
        return (short) val;
    }

    @Override public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;
        PhoneNumber pn = (PhoneNumber)o;
        return pn.lineNum == lineNum && pn.prefix == prefix
                && pn.areaCode == areaCode;
    }
    
    ...
}

 

HashMapTest.java
public class HashMapTest {

    public static void main(String[] args) {
        PhoneNumber number1 = new PhoneNumber(123, 456, 7890);
        PhoneNumber number2 = new PhoneNumber(123, 456, 7890);

        Map<PhoneNumber, String> map = new HashMap<>();
        map.put(number1, "keesun");
        map.put(number2, "whiteship");

        /*
            넘긴 key에 대한 hashcode 값을 먼저 가져오고, hash에 해당하는 버킷에 들어있는 오브젝트를 꺼내온다.
         */
        String notFound = map.get(new PhoneNumber(123, 456, 7890));
    }
}

1) 첫번째 PhoneNumber 인스턴스

put() 메서드로 PhoneNumber를 넣을때 사용했다.

 

2) 두번째 PhoneNumber 인스턴스

get() 메서드로 PhoneNumber를 꺼내려고 할때 사용했다. 

 

PhoneNumber 클래스는 hashCode를 재정의하지 않았다. 논리적 동치인 두 객체가 서로 다른 해시코드를 반환하게 되면서 "equals가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다." 규약을 지키지 못한다. 그 결과 get() 메서드는 엉뚱한 해시 버킷에 가서 객체를 찾으려해서 null을 리턴한다. 

HashMap은 해시코드가 다른 엔트리끼리는 동치성 비교를 시도조차 하지 않도록 최적화되어 있기 때문이다. 

 

문제해결

이 문제는 PhoneNumber에 적절한 hashCode 메서드만 작성해주면 해결된다. 

 

 

hashCode() 메서드 재정의

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

 

이 코드는 동치인 모든 객체에서 똑같은 해시코드를 반환하니 적법하다. 하지만 모든 객체에게 똑같은 해시코드 값만 내어주므로 모든 객체가 해시 테이블의 버킷 하나에 담겨 마치 연결 리스트처럼 동작한다. 이렇게 되면 해시 테이블에 데이터를 찾는 속도가 매우 느려진다. 

좋은 해시 함수라면 서로 다른 인스턴스에 다른 해시코드를 반환한다. 이상적인 해시 함수는 주어진 서로 다른 인스턴스들을 32비트 정수 범위에 균일하게 분배해야한다.  또한 hashCode() 메서드가 동치인 인스턴스에 대해 똑같은 해시 코드를 반환해야한다. 

 

해시코드 계산에서 제외

다른 필드로부터 계산해낼 수 있는 파생 필드는 모두 해시 코드 계산에서 제외해도 된다. 또한 equals 비교에 사용되지 않는 필드는 반드시 제외해야한다.

 

위 PhoneNumber 클래스의 재정의한 hashCode() 메서드

단순하고, 충분히 빠르고, 서로 다른 전화번호들은 다른 해시 버킷들로 훌륭히 분배해주는 코드다.

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

 

 

Objects 클래스의 hash 메서드

Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공한다. 이 메서드를 활용하면 hashCode 함수를 단 한줄로 간단하게 작성이 가능하다. 하지만 속도는 좀더 느리다. 

입력 인수를 담기 위한 배열이 만들어지고, 입력 중 기본 타입이 있다면 박싱, 언박싱도 거쳐야하기 때문이다. 그러니 hash 메서드는 성능에 민감하지 않은 상황에서만 사용해야한다.

@Overrider 
public int hashCode() {
    return Objects.hash(linenum, prefex, areaCode);
}

 

Objects.java
public static int hashCode(Object a[]) {
    if (a == null)
        return 0;

    int result = 1;

    for (Object element : a)
        result = 31 * result + (element == null ? 0 : element.hashCode());

    return result;
}

 

 

해시코드 지연초기화(lazy initialization)

hashCode() 메서드를 최초 호출했을때 hashCode가 게산되고, 그 이후로는 if문을 타지 않게된다.

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;
}

 

쓰레드 안전한 코드
private volatile int hashCode; // 자동으로 0으로 초기화된다.

/**
 * hashCode() 최초 호출됬을때 계산
 * 쓰레드 안정성을 고려해야한다.
 * @return
 */
@Override
public int hashCode() {
    if (this.hashCode != 0) { // 값이 있으면 계산X
        return hashCode;
    }

    synchronized (this) { // lock
        // T1 쓰레드만 수행
        // 이후 T2 쓰레드 수행되도 result는 0 이 아니므로 if문 수행X -> hashCode 변수에 volatile 추가
        int result = hashCode;
        if (result == 0) {
            result = Short.hashCode(areaCode);
            result = 31 * result + Short.hashCode(prefix);
            result = 31 * result + Short.hashCode(lineNum);
            this.hashCode = result;
        }
        return result;
    }
}

 

volatile 키워드에 대한 포스팅 바로가기

https://devfunny.tistory.com/841

 

[JAVA] Volatile 변수

volatile 변수 volatile로 선언된 변수의 값을 바꿨을때 다른 스레드에서 항상 최신 값을 읽어갈 수 있도록 해준다. ▶ 변수의 값을 읽을때 CPU cache에 저장된 값이 아닌 Main 메모리에서 읽는다. 기존에

devfunny.tistory.com

 

 

주의할점

성능을 높이기위해 해시코드를 계산할때 핵심 필드를 생략해서는 안된다. 속도는 빨라져도 해시 품질이 나빠져 해시테이블의 성능을 심각하게 떨어뜨릴 수 있다. hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자. 그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수 있다. 

 

 

반응형

Designed by JB FACTORY