본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 11 - equals 를 재정의하려거든 hashCode 도 재정의하라 - 완벽 공략

by 개발인생 2022. 10. 26.
반응형

아이템 11 - equals 를 재정의하려거든 hashCode 도 재정의하라 - 완벽 공략

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

해시맵 내부의 연결 리스트

자바8 이전 까지는 해시 충돌 발생시 링크드 리스트 를 사용했다.

이때 링크드 리스트해시맵 내부 에 구현되어 있는 링크드 리스트를 사용했다.

링크드 리스트에서 데이터를 추가 할때 성능은 O(1) 이다.

링크드 리스트에서 데이터를 조회 할때 성능은 O(n) 이다.

해시 충돌 이 자주 발생할수록 해당 버킷 에 들어있는 링크드 리스트 에서 값을 꺼낼때 성능이 저하된다.

자바8 버전부터는 성능 최적화를 위해 링크드 리스트 대신에 이진 트리 를 사용하도록 바뀌었다.

이진 트리 에서 값을 꺼낼 때의 성능은 O(logN) 이다.

사실 이렇게 해시 충돌 이 자주 발생하는 경우는 드물다.


Thread-safety

Thread-safety 란, 멀티 스레드 환경에서 안전한 코드 를 뜻한다.

멀티 스레드 환경이란 동시다발 적으로 여러 스레드가 해당 코드를 실행한다는 의미다.

이때 코드가 예측한 대로 동작하지 않는다면 그 코드는 멀티 스레드 환경에서 안전한 코드가 아니다.

// equals를 재정의하면 hashCode로 재정의해야 함을 보여준다. (70-71쪽)
public final class PhoneNumber {
   private final short areaCode, prefix, lineNum;

   public PhoneNumber(int areaCode, int prefix, int lineNum) {
      this.areaCode = rangeCheck(areaCode, 999, "area code");
      this.prefix   = rangeCheck(prefix,   999, "prefix");
      this.lineNum  = rangeCheck(lineNum, 9999, "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 o) {
      if (o == this)
         return true;
      if (!(o instanceof PhoneNumber))
         return false;
      PhoneNumber pn = (PhoneNumber)o;
      return pn.lineNum == lineNum && pn.prefix == prefix
            && pn.areaCode == areaCode;
   }

   // 해시코드를 지연 초기화하는 hashCode 메서드 - 스레드 안정성까지 고려해야 한다. (71쪽)
   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);
         this.hashCode = result;
         return result;
      }
   }
}

위의 코드에서 hashCode 메서드 같은 경우 여러 스레드에서 동시 접근할 시 문제가 될 수 있다.

멀티 스레드 환경에서 안전한 코드를 만드는 방법 중 가장 원시적인 방법은 다음과 같다.

public final class PhoneNumber {
   private final short areaCode, prefix, lineNum;

   public PhoneNumber(int areaCode, int prefix, int lineNum) {
      this.areaCode = rangeCheck(areaCode, 999, "area code");
      this.prefix   = rangeCheck(prefix,   999, "prefix");
      this.lineNum  = rangeCheck(lineNum, 9999, "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 o) {
      if (o == this)
         return true;
      if (!(o instanceof PhoneNumber))
         return false;
      PhoneNumber pn = (PhoneNumber)o;
      return pn.lineNum == lineNum && pn.prefix == prefix
            && pn.areaCode == areaCode;
   }

   // 해시코드를 지연 초기화하는 hashCode 메서드 - 스레드 안정성까지 고려해야 한다. (71쪽)
   private int hashCode; // 자동으로 0으로 초기화된다.

   @Override public synchronized int hashCode() {
      int result = hashCode;
      if (result == 0) {
         result = Short.hashCode(areaCode);
         result = 31 * result + Short.hashCode(prefix);
         result = 31 * result + Short.hashCode(lineNum);
         this.hashCode = result;
         return result;
      }
   }
}

synchronized 키워드를 사용하여 동시에 공유되는 코드를 감싸는 방법이다.

이렇게하면 한번에 하나의 스레드 밖에 못사용한다.

대신에 메서드 단위로 synchronized 키워드를 사용하면 하나의 스레드 밖에 사용하지 못하기 때문에 성능에 문제가 생긴다.

이 문제를 해결하기 위해 double checked locking 기법이 있다.

public final class PhoneNumber {
   private final short areaCode, prefix, lineNum;

   public PhoneNumber(int areaCode, int prefix, int lineNum) {
      this.areaCode = rangeCheck(areaCode, 999, "area code");
      this.prefix   = rangeCheck(prefix,   999, "prefix");
      this.lineNum  = rangeCheck(lineNum, 9999, "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 o) {
      if (o == this)
         return true;
      if (!(o instanceof PhoneNumber))
         return false;
      PhoneNumber pn = (PhoneNumber)o;
      return pn.lineNum == lineNum && pn.prefix == prefix
            && pn.areaCode == areaCode;
   }

   // 해시코드를 지연 초기화하는 hashCode 메서드 - 스레드 안정성까지 고려해야 한다. (71쪽)
   private int hashCode; // 자동으로 0으로 초기화된다.

   @Override public synchronized 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);
            this.hashCode = result;
         }
         return result;
      }
   }
}

synchronized 키워드 자체가 클래스에 lock 을 사용하는 것이다.

해당 블럭을 최소화 해주는 방법이다.

최초에 필드 값을 검사해 null 이 아니면 return 을 해준다.

하지만 두개의 스레드가 동시에 들어왔을 때를 대비해

      synchronized (this) {
         int result = hashCode;
         if (result == 0) {
            result = Short.hashCode(areaCode);
            result = 31 * result + Short.hashCode(prefix);
            result = 31 * result + Short.hashCode(lineNum);
            this.hashCode = result;
         }
         return result;
      }

위처럼 synchronized 를 사용한다.

밖에서 한번, synchronized 블럭 안에서 한번 총 두번 체크를 하기 때문에 double checked locking 이라고한다.

   private volatile int hashCode; 

@Override public synchronized 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);
      this.hashCode = result;
      }
      return result;
      }
      }

여기에 추가로 hashCode 필드에 volatile 키워드를 추가한다.

volatile 키워드는 cpu 의 캐시에 데이터를 저장하게 되는데

캐시에 저장된 데이터를 읽어올 때는 값이 업데이트 되었지만 예전에 캐싱된 데이터를 불러올 수도 있다.

volatile 키워드를 사용하게되면 해당 값을 메인 메모리 에 저장하게 된다.

그래서 값을 읽어올 때 가장 최근의 데이터 를 읽어오게 된다.


다른 방법으로는 한 스레드 안에서만 유요한 local 변수를 사용하는 ThreadLocal 방법이 있다.

또한 불변 객체를 사용하면 Thread-safety 하다.

그리고 Synchronized 가 되어있는 컬렉션이 있는데 그걸 사용하는 방법이 있다.

만들어질때부터 Synchronized 가 적용되어 있는 클래스가 있다.

HashTable 과 같은 클래스가 그 예이다.

HashTableThread-safety 하지만, HashMapThread-safety 하지 않다.

공유하는 데이터를 HashTable 처럼 태생적으로 Thread-safety 한 데이터를 사용한다면 Thread-safety 한 코드가 된다.

다른 방법으로는 Concurrent 를 지원하는 List 나 Set 을 사용하는 방법이 있다.

Concurrent 는 동시에 접근이 가능하다.

값을 읽어오는 경우 굳이 한번에 하나의 스레드만 읽어가게 하는 대신에 동시에 여러 스레드를 허용하는 방법도 있을 것이다.

쓰기의 경우에도 동시성을 보장하는 방법이 있을 것이다.

Concurrent 가 없을 때에는 동시에 접근이 허용되지 않았다.

동시에 여러 스레드가 값을 읽고, 추가하고 삭제할 수 있도록 Concurrent 를 지원하는 List 나 Set 이 있다.

이러한 List 나 Set 을 사용하는 방법도 Thread-safety 한 방법이라고 할 수 있다.

반응형

댓글