1. equals를 재정의한 클래스는 hashCode도 재정의 해야한다.
- HashMap이나 HashSet같은 컬렉션의 원소로 사용할 때 문제가 발생한다.
- 해쉬코드 재정의 조건
- equals 비교에 사용되는 정보가 변경되지 않는다면, hashCode는 변하면 안된다.
- equals가 두 객체가 같다 판단한다면, 두 객체의 hashCode는 똑같은 값을 반환한다.
- equals가 두 객체가 다르다고 판단해도, hashCode는 꼭 다를 필요는 없다. 하지만 성능을 챙기려면 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) {
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의 조회 함수이다. 이 함수를 보면 2번째 조건을 이해할 수 있다.
HashMap은 들어온 키 객체를 hash값으로 바꾸고 이걸 주소값으로 쓰는 값을 찾아 hash값 키값 equals를 비교하여 다 통과하면 해당 노드를 반환한다..
PhoneNumber라는 객체가 equals만 재정의 되어있고 hashCode가 재정의 안 되어있다고 생각해보자.
Map<PhoneNumber,Person> map = new HashMap();
map.put(new PhoneNumber(010-3333), new Person("y"));
map.put(new PhoneNumber(010-3333), new Person("z"));
이 맵에서 우린 전화번호가 같으면 같은 사람을 뽑고 싶을 것이다.
하지만 실제 조회해 보면 같은 전화번호인데 서로 다른 두명이 조회될 거다.
해쉬코드를 재정의 안해서다. 위 16번째 줄을 보면 table에서 객체를 찾을 때 hash값이 다르기 때문에 애초에 같은 PhoneNumber의 다른 해쉬값을 주소로 해서 Person객체를 각각 저장했을 것이다.
해시코드가 다르면 동치성을 비교조차 하지 않는다.
* 두번째 조건을 만족시키려면 항상 같은 HashCode를 반환하면 되지않나?
@Override
public int hashCode() {
return 42;
}
안된다. 항상 같은 hashCode는 성능에 정말 좋지않다.
다시한번 조회함수를 보자. 20번째 줄은 hashCode로 찾은 첫번째 노드에 값이 실제로 equals를 만족하지 못해서 다음 노드를 찾는 조건이다.
즉 해쉬값이 같은데 실제 값은 다르면 해당 노드에 다음 노드의 주소를 저장해놓는 것이다. 이렇게 될경우 항상 같은 해쉬코드를 반환하도록 하면 그냥 연결리스트를 쓰는 것과 똑같은 시간복잡도를 갖게 될 것이다.
*올바른 해쉬코드를 만드는 법
@Override
public int hashCode() {
int result = Integer.hashCode(areaCode);
result = 31 * result + Integer.hashCode(areaCode);
result = 31 * result + Integer.hashCode(areaCode);
return result;
}
자세한 순서는 이펙티브자바 69p를 참고
주의할 점
- equals에 논리적 동치를 확인하는데 사용하지 않는 필드는 빼고 해야한다.
- 만약 hash로 바꾸려는 필드가 기본 타입이 아니면 해당 필드의 hashCode를 불러 구현한다.
* 계산이 복잡한 경우는 표준형을 만들어 구현한다. (아이템 10) 55p참고 - 참조 타입 필드가 null일 경우 0을 사용.
- 31을 곱하는 이유는 비슷한 필드가 여러개일 때 해시효과를 크게 높여주기 위해서다.
- 비슷한 값들이 여러개 있을때 그냥 더하면 같은 값이 나올 확률이 높다. 그래서 31을 곱해 큰수로 만들어 해시효과를 증가시킨다.
- 31인 이유
- 짝수이고 오버플로가 발생한다면 정보를 잃게된다. -> 홀수를 곱해도 똑같은 상황이 발생하는 거 아닌가?
- 소수여서 31이다. (책에도 소수를 곱해야하는 정확한 이유는 모른다고 했다.
- 연산을 빠르게 처리할 수 있다. 31 * i는 jvm이 최적화해 (i << 5) - i로 바꿔 쉬프트 연산을 하도록 설계되어있다.
- 해쉬 충돌이 더 적은 방법은 구아바의 Hashing을 참고하자.
- Objects클래스에 있는 hash함수도 있지만 성능이 느리다. 인텔리제이의 자동완성 해쉬는 Objects의 해쉬를 사용한다.
@Override
public int hashCode() {
return Objects.hash(lineNum,prefix,areaCode);
}
2. 해쉬코드를 지연 초기화하자.
- 자주 해쉬의 키로 사용할 것 같으면 인스턴스가 만들어질 대 해시코드를 계산해 줘야 한다.
- 해쉬의 키로 사용되지 않는 경우이고 해쉬계산에 많은 시간이 걸린다면 지연초기화하면 좋다.
private int hashCode;
@Override
public int hashCode() {
int result = hashCode;
if(result == 0) {
int result = Integer.hashCode(areaCode);
result = 31 * result + Integer.hashCode(areaCode);
result = 31 * result + Integer.hashCode(areaCode);
hashCode = result;
}
return result;
}
지연초기화 상황은 스레드 안정성을 고려해야 한다. 동시에 여러 쓰레드가 hashCode를 호출하면 여러 쓰레드가 동시에 계산하여 우리의 처음 의도와는 다르게 여러번 계산하는 상황이 발생할 수 있다. 그래서 지연 초기화를 하려면 동기화를 신경써주는 것이 좋다.
private String name;
private CountDownLatch latch;
private int hashCode;
@Override
public int hashCode() {
int result = hashCode;
if (result == 0) {
try {
Thread.sleep(1000L);
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
hashCode = Objects.hash(name);
result = hashCode;
}
return result;
}
@DisplayName("지연 초기화시 다수의 스레드가 동시에 접근하면 초기화 로직을 여러 쓰레드가 수행할 수 있다")
@Test
void test() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(5);
MultiThreadJob job = new MultiThreadJob("processor", latch);
new Thread(job::hashCode).start();
new Thread(job::hashCode).start();
new Thread(job::hashCode).start();
new Thread(job::hashCode).start();
new Thread(job::hashCode).start();
latch.await();
assertThat(job.getCount()).isEqualTo(0);
}
hashCode를 계산하는데 1초가 걸린다고 가정해보자. CountDownLatch를 사용해 초기화 로직을 수행할때 countDown하도록 하고 테스트를 돌리면 다섯개의 쓰레드가 hashCode의 초기화 로직을 다 수행한 것을 확인할 수 있다.
3. 성능을 높인답시고 해시코드를 계산할 때 핵심 필드 생략을 하지말자.
- 핵심필드를 생략하고 적은 수의 필드만 쓰면 당연히 성능은 빨라지겠지만, 해시 품질이 나빠질것이다.
- 예를 들어 javabom.tistory.com/manage 와 javabom.tistory.com/manage/newpost 가 있다고 가정해 보자. 자바 2전의 String은 최대 16개의 문자만 뽑아내 사용해 hashCode를 사용했다. 그럴경우 위 url은 겹치는 문자가 많아 겹치는 해쉬코드 값들이 많아질 것이다.
'Reading Record > 이펙티브자바' 카테고리의 다른 글
아이템 [7] - 다 쓴 객체 참조를 해제하라 (0) | 2020.02.08 |
---|---|
아이템 [3] - private 생성자나 열거타입으로 싱글턴임을 보증하라 (0) | 2020.02.08 |
아이템[14] - Comparable을 구현할지 고려하라 (0) | 2020.02.07 |
아이템 [10] - equals는 일반 규약을 지켜 재정의하라 (0) | 2020.01.26 |
아이템 [1] - 생성자 대신 정적 팩터리 메서드를 고려하라 (0) | 2020.01.20 |