Hashing은 컴퓨터 과학에서 핵심정인 개념 중 하나이다. 자바에서 효율적인 해시 알고리즘은 HashMap 및 HashSet와 같은 잘 사용하는 컬렉션들의 밑바탕이 되는 개념입니다. 여기서는 hashCode가 동작하는 방식, 어떻게 컬력션에 기여하는지, 올바르게 구현하는 방법에 대해 알아보자. 아래는 Hash와 관련된 게시글 링크이다.
hashCode
Java에서 사용되는 해시코드(Hashcode)는 객체를 식별하기 위한 ID이다. 즉, hashCode는 각 객체의 주소값을 변환하여 생성한 객체의 고유한 정수값이다. Java의 모든 객체는 JVM에 의해 해시 알고리즘을 사용해서 고유 번호가 생성되며, 이 고유 번호가 해시코드이다. 해시 코드는 32비트 고유한 정수값으로 객체와 다른 객체를 구별하기 위해 사용되며, 객체의 내부 주소를 정수로 변환된 값이다. equals 메서드에 따라 동일한 두 객체는 반드시 같은 해시코드 값을 반환한다. 하지만 두 객체가 다르다고 해서 반드시 다른 해시 코드를 반환하지는 않다.
자바에서 해시 테이블을 사용할 때 컬렉션들은 컬력션에 접근할 때 매우 효율적으로 접근하기 위해 hashCode 메서드를 사용해서 지정된 키에 대한 해시값을 계산하여 데이터를 저장하는데 이값을 내부적으로 사용한다.
hashCode를 사용하는 이유
Java에서 hashCode를 기반으로 데이터를 관리하는 이유는 데이터 검색, 추가, 제거하는 작업이 쉬워지며, 시간 복잡도가 O(1)이므로 상당히 빠르게 동작하기 때문이다.
String 클래스의 hashCode()
public class CompareStringHashCode {
public static void main(String[] args) {
String name1 = new String("oneny");
String name2 = new String("oneny");
System.out.println("name1.hashCode = " + name1.hashCode()); // name1.hashCode = 105888433
System.out.println("name2.hashCode = " + name2.hashCode()); // name2.hashCode = 105888433
}
}
결론적으로 얘기하면 주소값을 기준으로 정수값의 hashCode를 생성하는 것이 아니다. 서로 다른 String 객체도 문자열이 같으면 hashCode를 반환한다. hashCode는 객체의 주소값을 해시 알고리즘을 사용하여 해시값으로 변환하여 반환한다고 했지만 String의 hashCode() 메서드를 보면 31 * h + ascii값 연산을 하기 때문에 특정 문자열 조합에서 동일한 정수값을 가지기 때문에 중복 가능성이 있다.
Collections 관련 Hash와 hashCode()
Collections에서는 이러한 해시함수를 통해 반환된 해시코드 값을 이용한다고 했는데 해시코드가 객체의 유일한 값이 아니라면 문제가 되지 않을까? HashMap을 대표적으로 생각해보자. HashMap에서 key는 hashCode를 기준으로 정해진다. 근데 만약 다른 객체의 hashCode가 같다면 중복 key가 되는 것이다. 하지만 Map의 기본 특징으로는 key는 중복이 되지 않는다.
public class StringTest {
public static void main(String[] args) {
Set<String> hashSet = new HashSet<>();
String a = "Z@S.ME";
String b = "Z@RN.E";
System.out.println("a = " + a.hashCode()); // a = -1656719047
System.out.println("b = " + b.hashCode()); // b = -1656719047
hashSet.add(a);
hashSet.add(b);
System.out.println(hashSet); // [Z@RN.E, Z@S.ME]
System.out.println(hashSet.size()); // 2
}
}
위 예제 코드를 보면 문자열 "Z@S.ME"과 "Z@RN.E"는 같은 해시코드 값을 가진다. 위에서 살펴본대로라면 중복 key이기 때문에 hashSet.size()는 1이 나와야 하는데 다행히도(?) 2가 나오는 것을 확인할 수 있다. 이렇게 적절히 잘 처리해주도록 구성해주는데에는 equals() 메서드 덕분이다. 다시 말해 a와 b의 해시코드 값은 같지만 equals()의 결과가 false이기 때문에 중복 key가 아닌 다른 key로 처리하는 것이다.
Collections에서 같은 key로 처리하는 경우
- equals()가 false이고, hashCode()가 true인 경우 => HashMap에서 다른 key로 처리
- equals()가 true이고, hashCode()가 false인 경우 => HashMap에서 다른 key로 처리
- equals()가 true이고, hashCode()가 true인 경우 => HashMap에서 같은 key로 처리
equals와 hashCode
이 게시글을 쓰는 이유도 equals와 hashCode를 왜 같이 사용해야 하는지부터 시작해서 쓰기 시작했다. 그러다보니 equals를 위해 Stack과 Heap 메모리부터 == 연산자와 equals 메서드 차이까지 공부하고, hashCode를 위해 기본적인 Hash 자료구조에 대해서 공부했다. 위에서도 잠깐 살펴봤지만 이제 제대로 equals와 hashCode 메서드를 같이 사용해야 하는 이유에 대해서 자세히 알아보자.
위 그림은 아까 "Z@S.ME"와 "Z@RN.E"를 가지고 hashCode가 같은 값을 같지만 equals()가 false이기 때문에 다른 key로 처리한다고 한 과정을 표현한 그림이다. 위에서는 HashSet을 사용했지만 Hash 값을 사용하는 Collection(HashMap, HashSet, HashTable)은 객체가 논리적으로 같은지를 비교할 때 위 그림과 같은 과정을 거친다. 그러면 equals만 오버라이딩하는 경우에는 어떻게 될까?
equals만 재정의한 경우
public class PersonTest {
public static void main(String[] args) {
Set<Person> persons = new HashSet<>();
persons.add(new Person("oneny"));
persons.add(new Person("oneny"));
System.out.println(persons.size());
}
}
class Person {
private final String name;
public Person(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return Objects.equals(name, person.name);
}
}
만약 위 코드를 실행하면 persons.size()의 결과는 어떻게 될까? Collections에 중복되지 않는 Person 객체만 넣도록 만들었는데 예상과는 다르게 2가 출력되는 것을 확인할 수 있다. 두 Person 객체의 이름이 같아서 논리적으로 같은 객체라 판단할 수 있지만 hashCode를 equals와 함께 재정의하지 않으면 코드가 위 예시 코드처럼 hash 값을 사용하는 Collections를 사용할 때 예상과 다르게 작동하는 문제를 일으킨다.
왜 이런 문제를 일으키는지에 대해 자세히 살펴보자. main 메서드의 HashSet에 Person 객체를 추가할 때도 위에서 살펴본 과정을 거치게 된다. 하지만 Person 클래스에는 hashCode 메서드가 오버라이딩되어 있지 않아서 Object 클래스의 hashCode 메서드를 사용한 것이다.
Object 클래스의 hashCode 메서드는 객체의 고유한 주소값을 int 값으로 변환하기 때문에 객체마다 다른 값을 리턴한다. 따라서 equals로 비교하기 전에 이미 hashCode에서 다른 값이기 때문에 다른 객체로 판단된 것이다.
hashCode와 equals를 같이 오버라이딩
class Person {
private final String name;
public Person(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
main 메서드 부분은 생략하고 다음과 같이 Person 클래스에 hashCode를 오버라이딩하도록 수정했다. 그러면 위에서 살펴본 과정대로 hashCode가 같다고 판단되고 equals() 리턴값도 true이기 때문에 결과는 우리의 예상과 똑같이 1이 출력되는 것을 확인할 수 있다.
출처
[Java] Object 클래스의 hashCode 메서드
equals와 hashCode는 왜 같이 재정의해야 할까?
'Java > Java' 카테고리의 다른 글
Java의 소수점 계산 오류 및 해결 (0) | 2023.07.16 |
---|---|
ArrayList와 LinkedList 차이 (0) | 2023.07.15 |
String Literal vs new String (0) | 2023.07.09 |
JVM의 Stack&Heap 이해하기 (0) | 2023.07.08 |
정적 팩토리 메서드, 인스턴스 캐싱 (0) | 2023.04.30 |