2 minute read

JLS(Java Language Specification)에 따른 이유

  • 두 객체들이 equals()에 따라 동등하다면 그 객체들은 반드시 동일한 hashcode()를 지녀야 한다.
    • 그래서 equals()와 hashCode()를 모두 오버라이딩 해야 한다.
  • 두 객체들이 동일한 hashcode()를 지녔다고 해서 반드시 그 객체들이 동일해야 하는 것은 아니다. (hash collision 때문)

equals()와 hashCode()를 동시에 오버라이딩하지 않으면 발생하는 일

  • equals()만 오버라이딩 할 경우: 객체들이 동등하기는 하지만 해시 값은 달라서 HashMap, HashSet같은 자료형을 의도대로 사용할 수 없다.
  • hashcode()만 오버라이딩 할 경우: equals()가 객체들의 메모리 주소를 비교하기 때문에 실무에 맞지 않은 방식으로 동등성을 비교할 수 있다.
    • equals()는 오버라이딩되지 않으면 객체들의 메모리 주소를 비교한다. equals()의 기본 동작이다. (==의 작동 방식과 동일)

➕ 두 메서드들을 오버라이딩하는, 권장되는 방식

  • equals()와 hashCode()에 동일한 필드를 사용하여 결과의 일관성을 유지한다.
  • final로 설정된 필드들을 사용한다.
    • 필드의 값이 바뀌면 동등성도 달라질 수 있고 해시 값도 달라질 수 있다.
    • 특히 Hash와 연관된 자료형(HaspMap 등)을 사용할 때 치명적이다.
  • 객체의 신원을 나타내거나 주요한 의미를 지니는 필드를 사용한다.
    • equals()가 객체들의 논리적인 동등성을 따지기 때문이다.
    • ✅ Account 객체들의 email, password 값이 동일하니까 동일한 객체들이야.
    • ❌ Account 객체들의 updatedAt, isActive 값이 동일하니까 동일한 객체들이야.

➕ Hash Collision

  • 질문 배경
    • 왜 equals()로 동등하면 객체들의 해시 값이 동일해야 하는데, 해시 값이 동일하다고 equals()로 동등하리라는 보장은 없음?
  • 서로 다른 내용물을 지닌 객체들의 해시 값이 동일한 경우이다.
  • 발생 이유
    • 유한한 결과값 공간: 해시 코드는 자바에서 32비트의 정수다. (약 40억 개의 값이 가용함)
    • 무한한 입력값 공간: 객체의 상태로 가능한 경우는 무한에 가깝다. (객체의 필드를 구성하는 경우의 수 + 해시를 생성할 때 쓰는 필드의 경우의 수)
    • 비둘기장 칸(pigeonhold) 원칙: 해시 값보다 많은 수의 객체가 있다면 해시 값의 충돌은 불가피하다.
  • 가능한 해시 값이 40억개나 된다면서, 객체들의 해시 값이 겹칠 수 있다고?
    • 40억이 직관적으로는 많아 보이지만, 사실은 그렇게 많지 않다.
    • 23명의 사람만 있어도 그 중 두 명이 생일을 공유할 확률이 50%나 된다는 걸 생각해보자. (생일의 역설)
    • 생일의 역설에 따르면 객체 77,000개 정도만 있어도 두 객체가 해시 값을 공유할 확률이 50% 정도가 된다.
  • 해시 충돌을 해소하는 방법
    • HashMap의 내부적인 구조

        // Simplified HashMap bucket structure
        HashMap<K, V> {
            Node<K, V>[] table;  // Array of buckets
                  
            // Each bucket can contain multiple entries (linked list or tree)
            class Node<K, V> {
                final int hash;
                final K key;
                V value;
                Node<K, V> next;  // For collisions
            }
        }
      
    • Chaining

        // What happens during a collision
        HashMap<String, String> map = new HashMap<>();
              
        map.put("Aa", "Value1");  // Hash 2112 -> Bucket 0
        map.put("BB", "Value2");  // Hash 2112 -> Same bucket 0 (collision!)
              
        // Internally, bucket 0 becomes a linked list:
        // ["Aa" -> "Value1"] -> ["BB" -> "Value2"]
      
  • 해시 충돌이 성능에 미치는 영향
    • Java 8버전 이후 부터는 Java가 linked list를 red-black tree로 변환하여 최악의 경우에 발생하는 성능을 O(n)에서 O(log n)으로 개선했다.
    • 좋은 해시 함수 (충돌이 덜 남)

        Bucket 0: [Entry1]
        Bucket 1: [Entry2]
        Bucket 2: [Entry3]
        ...
        // O(1) operations
      
    • 나쁜 해시 함수 (충돌이 자주 남)

        Bucket 0: [Entry1] -> [Entry2] -> [Entry3] -> [Entry4] -> ...
        Bucket 1: [Entry5]
        Bucket 2: []
        ...
        // Degrades to O(n) operations in worst case
      
  • 핵심 요약
    • 해시 기반 컬렉션에서 충돌은 일반적이고 예측 가능한 현상이다.
    • 서로 다른 객체들의 해시가 동일할 수 있고, 동일할 것이다.
    • 컬렉션은 chaining을 사용하여 충돌을 내부적으로 해소한다. (linked list, tree)
    • 동등한 객체들은 해시 코드 값이 같지만, 해시 코드 값이 같다고 해서 동등한 객체라는 의미는 아니다.
    • 해시 함수를 잘못 짜면 성능이 나빠진다. 좋은 해시 함수는 성능이 O(1)과 비슷하다.
String a = "Aa";
String b = "BB";

System.out.println(a.hashCode());  // 2112
System.out.println(b.hashCode());  // 2112 - Same hash code!
System.out.println(a.equals(b));   // false - Different objects

Categories:

Updated: