hashCode?
객체 해시코드란 객체를 식별하는 정수값으로 Object 클래스의 hashCode메서드는 Heap 메모리 영역에 인스턴스들이 자리한 위치값을 리턴해주기 때문에 인스턴스마다 다른 위치 참조값을 가지고 있다.
만약 값을 표현하는 객체가 새롭게 인스턴스를 생성하여 비교했을 때 논리적으로 같은객체로 봐야한다면 equals 메서드를 재정의 하면서 hashCode 메서드도 재정의 해줘야 한다.
그렇지 않으면 hashCode 일반 규약을 어기게되어 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 객체를 비교한다면 우선 hashCode 메서드를 실행해서 값이 다르면 다른 객체로 판단하고 같으면 equals 메서드로 다시 비교한다.
Hash 컬렉션들은 <key, value> 형태로 데이터를 저장하는데 hashCode를 사용하여 key값을 기준으로 고유한 식별값인 해시값을 만들고 Bucket(컬렉션에 저장되는 공간)에 저장한다.
서로 다른 객체라 하더라도 같은 해시값을 가진 entry라면 같은 버킷공간에 LinkedList 형태로 객체를 추가하는데 put 메서드로 객체를 추가할 때 같은 버킷내에 값이 같은 객체가 있다면 equals = true로 기존객체를 덮어쓰고 값이 다르다면 equals = false로 해당 entry를 LinkedList에 추가한다.
get 메서드로 객체를 조회할 때는 key에 해당하는 해시코드 값을 먼저 가져온 후 해시에 해당하는 버킷에 있는 객체를 꺼내오는데 값이 같은 객체가 있다면 equals = true 로 해당 객체 리턴, 같은 객체가 없다면 equals = false로 null을 리턴한다.
hashCode 규약
- equals 비교에 사용하는 정보가 변경되지 않았다면 hashCode는 매번 같은 값을 리턴해야 한다. (변경되거나, 애플리케이션을 다시 실행했다면 달라질 수 있다.)
- 두 객체에 대한 equals가 같다면, hashCode 값도 같아야 한다.
- 두 객체에 대한 equals가 다르더라도, hashCode 값은 같을 수 있지만 해시 테이블 성능을 고려해 다른 값을 리턴하는 것이 좋다.
package item11.hashCode;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(int areaCode, int prefix, int lineNum) {
super();
this.areaCode = rangeCheck(areaCode, 999, "area code");
this.prefix = rangeCheck(prefix, 999, "prefix");
this.lineNum = rangeCheck(lineNum, 999, "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 obj) {
if (obj == this) return true;
if (!(obj instanceof PhoneNumber)) return false;
PhoneNumber pn = (PhoneNumber) obj;
return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode;
}
public static void main(String[] args) {
Map<PhoneNumber, String> m = new HashMap<PhoneNumber, String>();
m.put(new PhoneNumber(707, 867, 5390), null);
System.out.println(m.get(new PhoneNumber(707, 869, 5390)));
}// end of main
}// end of class
package item11.hashTable;
import java.util.HashMap;
import java.util.Map;
import item11.hashCode.PhoneNumber;
public class HashMapTest {
public static void main(String[] args) {
Map<PhoneNumber, String> map = new HashMap<PhoneNumber, String>();
PhoneNumber number1 = new PhoneNumber(123, 456, 789);
PhoneNumber number2 = new PhoneNumber(123, 456, 789);
System.out.println(number1.equals(number2)); // true
System.out.println(number1.hashCode()); // 1252585652
System.out.println(number2.hashCode()); // 2036368507
map.put(number1, "doyoun");
map.put(number2, "effective java");
String s = map.get(number2);
String s1 = map.get(new PhoneNumber(123, 456, 789));
System.out.println(s); // effective java
System.out.println(s1); // null
}
}
전화번호를 나타내는 PhoneNumber 클래스엔 논리적 동치성을 비교하는 equals 메서드만 정의하고 hashCode는 Object 클래스의 기능을 가져다쓰면 인스턴스를 새로 생성된 객체들은 각각 hashCode값이 다르니 HashMap에서 가져올 때 같은 전화번호를 가진 객체라면 동일한 객체로 판단하여 가져와야 하지만 hashCode가 동일한 entry를 찾을 수 없어 엉뚱한해시 버킷에 가서 객체를 찾았기 때문에 null을 뱉는다. 두번째 규약을 지키지 못한 것.
만약 두 인스턴스를 같은 버킷에 담았다 하더라도 HashMap은 hashCode가 다른 entry 끼리는 동치성 비교를 시도조차 하지 않도록 최적화 되어있어 null을 뱉는다.
hashCode를 오버라이딩 해보자
package item11.hashCode;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(int areaCode, int prefix, int lineNum) {
super();
this.areaCode = rangeCheck(areaCode, 999, "area code");
this.prefix = rangeCheck(prefix, 999, "prefix");
this.lineNum = rangeCheck(lineNum, 999, "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 obj) {
if (obj == this) return true;
if (!(obj instanceof PhoneNumber)) return false;
PhoneNumber pn = (PhoneNumber) obj;
return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode;
}
@Override
public int hashCode() {
return 42;
}
public static void main(String[] args) {
Map<PhoneNumber, String> m = new HashMap<PhoneNumber, String>();
m.put(new PhoneNumber(707, 867, 5390), null);
System.out.println(m.get(new PhoneNumber(707, 869, 5390)));
}// end of main
}// end of class
package item11.hashTable;
import java.util.HashMap;
import java.util.Map;
import item11.hashCode.PhoneNumber;
public class HashMapTest {
public static void main(String[] args) {
Map<PhoneNumber, String> map = new HashMap<PhoneNumber, String>();
PhoneNumber number1 = new PhoneNumber(123, 456, 789);
//PhoneNumber number2 = new PhoneNumber(123, 456, 789);
PhoneNumber number2 = new PhoneNumber(456, 789, 012);
System.out.println(number1.equals(number2)); // false
System.out.println(number1.hashCode()); // 42
System.out.println(number2.hashCode()); // 42
map.put(number1, "doyoun");
map.put(number2, "effective java");
String s = map.get(number1);
String s1 = map.get(new PhoneNumber(123,456,789));
System.out.println(s); // doyoun
System.out.println(s1); // doyoun
}
}
어떤 객체를 생성하던 같은 hashCode를 리턴한다면?
전에는 null 값이 나왔던 s1도 잘 표시가 되지만 같은 해시 버킷에 value 값이 다른 entry가 매핑된다.
이걸 해시 충돌(Hash Collision)이라고 하는데 충돌이 많이 발생할수록 저장하는 시간과 검출 시간이 오래걸린다. 버킷 오버플로우가 발생할 수 있다.
모든 객체가 해시 테이블 버킷 한군데에 계속 담겨진다면 linked list로 계속 연결되어 담아지게 된다. hash 값으로 마치 배열의 index로 바로 값을 찾는 것처럼 효율적인 해시 테이블을 linked list 내에서 equals로 차례대로 값을 찾아야 하거니와 entry를 넣을 때도 해당 list의 끝을 찾아 추가를 해야하니 객체가 많아지면 도저히 쓸 수 없게된다.
좋은 해시 함수라면 서로 다른 인스턴스에 다른 해시코드를 반환한다. 이것이 바로 hashCode의 세번째 규약이 요구하는 속성이다.
hashCode 구현 방법
1. int 변수 result를 선언한 후 값 c로 초기화.이때 c는 해당 객체의 첫번째 핵심필드의 해시코드이다. (핵심 필드란 equals 비교에 사용되는 필드)
- 기본 타입 필드라면 Type.hashCode(c)를 수행한다. 여기서 Type은 기본타입의 박싱 클래스 이다. ex) Short.hashCode(c)
- 필드가 레퍼런스 타입이라면 래퍼런스 자체의 해시코드를 사용한다. ex) point.hashCode()
- 필드가 배열이라면 Arrays.hashCode를 사용한다.
2. 해당 객체의 나머지 핵심 필드 각각 해시코드를 계산하여 result에 갱신한 후 result를 반환한다.
// 전형적인 hashCode 메서드
@Override
public int hashCode() {
// field 값이 primitive type 이면 wrapper 클래스의 hashCode 메서드 사용
int result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
return result;
}
// 한 줄짜리 hashCode 메서드 - 성능이 아쉽다
// Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공한다.
// 다만 입력 인수를 담기위한 배열이 만들어지고 입력 중 기본 타입이 있다면 박싱과 언박싱을 거쳐야한다.
@Override
public int hashCode() {
return Objects.hash(lineNum, prefix, areaCode);
}
// 해시코드를 지연 초기화 하는 메서드 - 스레드 안정성 고려
// 클래스가 불변이고 해시코드를 계산하는 비용이 크다면 매번 새로 계산하기 보다는
// 캐싱하는 방식을 고려해야한다.
private int hashCode; // 자동으로 0 초기화
@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 = reuslt;
}
return result;
}
hashCode를 다 구현했다면 이 메서드가 동치인 인스턴스에 대해 똑같은 해시코드를 반환할지 단위 테스트를 작성해보자.
동치인 인스턴스가 서로 다른 해시코드를 반환한다면 원인을 찾아 해결하자.
스레드 안전성을 고려한 hashCode 메서드
// 캐시에 저장된 데이터를 읽어올 때 다른 스레드가 업데이트 했어도 예전에 캐시한 데이터가 불려올 수 있는데
// volatile 키워드를 사옹하면 메인 메모리에 데이터를 저장해서 가장 최근에 업데이트된 데이터를 참조할 수 있다.
private volatile int hashCode;
// 스레드 안정성 고려한 hashCode 메서드
// double checked locking
@Override
public int hashCode {
if(this.hashCode != 0) {
return hashCode;
}
synchronized (this) {
int result = hashCode;
if (result == 0) {
result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
hashCode = reuslt;
}
return result;
}
}
'Effective Java > 정리' 카테고리의 다른 글
Item.13 clone 재정의는 주의해서 진행하라. (0) | 2023.02.05 |
---|---|
Item12. toString을 항상 재정의하라 (0) | 2023.02.04 |
item10. equals는 일반 규약을 지켜 재정의하라. (0) | 2023.01.29 |
[Item 9] try-finally보다는 try-with-resources를 사용하라 (0) | 2023.01.17 |
[Item 8] finalizer와 cleaner 사용을 피하라 (0) | 2023.01.17 |