equals를 재정의한 클래스는 hashCode도 재정의해야한다.
그렇지 않으면 인스턴스를 HashMap
이나 HashSet
같은 컬렉션의 원소로 사용할 때 문제가 발생한다.
equals
비교에 사용되는 정보가 변경되지 않았다면, hashCode
도 변하면 안 된다.(애플리케이션을 다시 실행한다면 이 값이 달라져도 상관 없음)equals
가 두 객체가 같다고 판단했다면, 두 객체의 hashCode
는 똑같은 값을 반환한다.→ 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.equals
가 두 객체를 다르다고 판단했더라도, hashCode
는 꼭 다를 필요는 없다.하지만, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) { //hash값으로 인덱스 계산하여 실제 값 가져옴
if (first.hash == hash && // 찾은 hash값이 맞는지 비교하고 맞다면 equals를 통해 값을 준다.
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) { // 같은 해쉬값이고 equals의 결과가 다른경우 다음노드를 조회
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
내부적으로 HashMap의 get메서드를 보면
들어온 Key 객체를 hashCode를 통해 hash값으로 바꾸고
이 hash값을 인덱스로 쓰는 실제 값을 찾아서 찾은 값과 인자의 해시 값, key 값이 모두 equals를 통해 일치하여야 해당 노드를 반환한다.
Map<PhoneNumber, String> map = new HashMap<>();
map.put(new PhoneNumber(010,1234,5678), new Person("리치"));
map.get(new PhoneNumber(010,1234,5678)) //이 명령어를 실행하면 null반환!!
equals를 재정의하여 논리적으로 같다고 했음에도 hashCode를 재정의하지 않았다면 논리적으로 동일한 두 객체여도 HashMap, HashSet에서의 get 조회가 불가능하다. 왜냐하면 get은 내부적으로 equals, hashCode가 모두 사용되며 두 객체의 equals, hashCode 모두 일치해야 조회가 가능하다. 하지만 현재는 재정의되있지 않아 둘의 객체의 HashCode가 달라 조회가 안된다.애초에 두 PhoneNumber 객체는 각각 HashMap에 저장되어있을 것이다.
그럼 hashCode 를 적당하게 아무렇게나 재정의해도 되는 것일까? 절대 안된다!!
//사용금지
@Override
public int hashCode() {
return 42;
}
위코드는 모든 객체에서 똑같은 해시 코드를 내어주므로 모든 객체가 해시테이블의 버킷 하나에 마치 연결리스트linked list처럼 동작한다. 그 결과 평균 수행 시간이 O(1)인 해시 테이블이 O(n)으로 느려져서, 객체가 많아지면 도저히 쓸 수 없게 된다.
따라서 좋은 해시함수라면 서로 다른 인스턴스에 대해 다른 해시코드를 반환해야 한다. 논리적으로 동일한 관계인 객체들만 해시코드가 같아야 한다.
equals
비교에 사용되는 필드에 대해서만 해시코드를 계산한다. equals비교에 사용되지않는 필드까지 해시코드계산에 사용하면 hashCode 규약 두번째를 어기게 될 수 있다.
Objects 클래스의 hash 메서드가 있지만 성능이 느리다.
클래스가 불변이고 해시코드 계산비용이 크다면 매번 새로 해시코드 계산보다는 캐싱방식을 고려해라.
성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 절대 생략해서는 안 된다.
hashCode
메서드스레드 안전성까지 고려해야 한다.
private int hashCode; //기본값 0
@Override
public int hashCode() {
int result = hashCode; // 초기값 0을 가진다.
if(result == 0) {
int result = Integer.hashCode(areaCode);
result = 31 * result + Integer.hashCode(areaCode); //31곱함
result = 31 * result + Integer.hashCode(areaCode); //31곱함
hashCode = result;
}
return result;
}
동시에 여러 쓰레드가 hashCode를 호출하면 여러 쓰레드가 동시에 계산하여 우리의 처음 의도와는 다르게 여러번 계산하는 상황이 발생할 수 있다.그래서 지연 초기화를 하려면 동기화를 신경써주는 것이 좋다.
연산을 빠르게 처리할 수 있다. 31 * N = 32N - N으로서 32는 2^5이니 어떤 수 N에 대해 32를 곱한 값은 시프트연산으로 쉽게 구현할 수 있다. 따라서 N에 31을 곱한 값은 (N << 5) - N이다. 31을 곱하면 이렇게 쉬프트 연산을 통해 빠른 계산이 가능하기 때문에 31을 사용한다.
현재의 String 클래스의 hashCode 메서드에도 성능 향상을 위해 31을 사용한다.
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
이전의 String 클래스는 아래와 같이 모든 문자에 대한 해시 함수를 계산하는 게 아니였다.
skipt을 통해 일정 간격의 문자를 건너가면서 해시 함수를 계산했다.
아래 코드를 보면 문자열의 길이가 16을 넘으면 최소 하나의 문자가 건너뛰어지며 해시 함수가 계산된다.
public int hashCode() {
int hash = 0;
int skip = Math.max(1, length() / 8);
for (int i = 0; i < length(): i+= skip)
hash = s[i] + (37 * hash);
return hash;
}
이 방식은 심각한 문제를 얘기한다. 웹상의 URL은 앞부분이 동일한 경우가 많은데 서로 다른 URL에 대해서 동일한 해시 값이 나오는 문제가 생겼기 때문이다.
equals를 재정의할 때는 hashCode도 반드시 재정의해야 한다. 그렇지 않으면 프로그램이 제대로 동작하지 않을 것이다.
이펙티브 자바
자바봄 블로그
https://javabom.tistory.com/3?category=833277
Naver d2 - Java HashMap 동작원리
https://d2.naver.com/helloworld/831311