이 글은 String Class의 hashCode 구현 로직을 살펴보고 그와 관련된 궁금증들을 해소하는 과정을 담고있습니다.
글 작성 계기
JDK 7부터 switch
문의 조건에 String
이 포함될 수 있다. 그리고 hashCode
를 통해 문자열이 같은지 판단한다고 한다. 여기서 “같은 문자열이면 항상 같은 hashCode
인가?” "그게 어떻게 가능하지?"라는 궁금증으로 탐구를 시작했다.
서로 다른 객체가 같은 문자열을 가지면 hashCode는 같을까?
public class Main {
public static void main(String[] args) {
String a = "January";
String b = "January";
System.out.println(a.hashCode() + " " + b.hashCode());
}
}
// result: -162006966 -162006966
동일한 문자열 값을 가진 서로 다른 객체의 hashCode
값은 동일하다. 왜 그럴까?
hashCode()
를 살펴보자 (JDK 17)
private int hash;
private boolean hashIsZero;
private final byte[] value;
public int hashCode() {
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true; // 계산 결과가 실제로 0인 경우를 구분해 캐싱 처리
} else {
hash = h; // 0이 아닌 해시는 필드에 캐싱
}
}
return h; // 캐시된 값 또는 계산 결과 반환
}
hash
: 이전에 계산된 해시 값을 캐싱한다.String
은 불변이라 한 번 계산하면 재사용 가능하다.hashIsZero
: 계산 결과가 0일 수 있으므로, “아직 계산 안 됨(0)”과 “계산 결과가 0”을 구분하기 위한 플래그다.value
: 실제 문자열 데이터를 담는 바이트 배열이다. 내부 인코딩(Latin-1
/UTF-16
)에 따라 해시 계산 경로가 달라진다.
요약하면, “아직 해시를 계산하지 않았다면 인코딩에 맞게 계산하고, 그 결과를 캐싱한다”는 흐름이다.
해시 계산 로직: Latin-1
vs UTF-16
Latin-1
public static int hashCode(byte[] value) {
int h = 0;
for (byte v : value) {
h = 31 * h + (v & 0xff);
}
return h;
}
- 바이트 배열을 순회하며
(v & 0xff)
로 부호 없는 정수로 확장하고, 31을 곱해 누적한다.
UTF-16
public static int hashCode(byte[] value) {
int h = 0;
int length = value.length >> 1; // 2바이트 단위(char)
for (int i = 0; i < length; i++) {
h = 31 * h + getChar(value, i);
}
return h;
}
- 2바이트 단위로 문자를 꺼내 동일한 방식으로 31을 곱해 누적한다.
오버플로우는 문제가 없을까?
해시 특성상, 값을 고르게 퍼지게 하기 위해 모듈러 연산을 주로 사용한다. 오버플로우가 모듈러 연산처럼 작용해 문제가 되지 않을 것이다. 오히려, 오버플로우가 의도되었다고 볼 수도 있겠다.
왜 하필 해시 값을 int에 저장할까?
int는 총 42억의 범위를 갖는다. 다른 정수형 변수의 범위(short: 6.4만, long: 1만 8천조)는 해싱을 하기에 너무 좁거나, 넓어 효율적이지 못하다. 따라서 int로 선택되었다고 생각한다.
Equals도 hashCode()를 통해 구현될까?
두 객체의 값이 동일한 지 판단할 때 사용하는 메서드는 equals()이다.
이때, 문자열을 기반으로 생성된 hashCode 값을 활용할 수 있지 않을까? 라는 생각을 했고, 실제로 그러한지 확인해 보았다.
public boolean equals(Object anObject) {
if (this == anObject) return true;
return (anObject instanceof String aString)
&& (!COMPACT_STRINGS || this.coder == aString.coder)
&& StringLatin1.equals(value, aString.value);
}
1. 동일성(this == anObject
)을 먼저 확인한다.
2. 같은 타입, 같은 인코딩이면 내부 바이트 배열을 1:1로 비교한다.
3. StringLatin1.eqauls는 다음과 같이 구현된다.
public static boolean equals(byte[] value, byte[] other) {
if (value.length == other.length) {
for (int i = 0; i < value.length; i++) {
if (value[i] != other[i]) {
return false;
}
}
return true;
}
return false;
}
hashCode() 함수를 굳이 사용하지 않는 이유는 무엇일까?
HashCode는 계산 시 임시 변수, 반복, 사칙연산 등 여러 로직이 수행된다. 이에 비해 단순 문자열 비교 연산은 문자열 크기만큼만 수행하면 되기에, 굳이 더 복잡하고 무거운 HashCode를 사용할 이유가 없다고 생각했다.
equals와 hashCode의 관계, 규약
알아보니, 계산의 복잡성을 떠나, 근본적으로 equals와 hashCode는 쓰임새가 달랐다.
String이 아닌, 일반적인 객체를 기준으로 각 메서드의 쓰임은 다음과 같다:
equals: 논리적 동등성(객체 필드 값이 동일한가? 등)
hashCode: 메모리 주소를 기반으로 생성되는 임의 값(경우에 따라 생성 로직 수정 가능)
equals와 hashCode의 관계에 대해서 조금 더 알아보면, 일반적으로 hashCode가 쓰이는 경우는 다음과 같다.
- Hash 기반 컬렉션에서 저장위치 선정
- 객체 동등성 비교
- 객체 고유 식별자 역할
해시 특성 상, 해시 값 충돌은 어쩔 수 없이 발생한다. 다른 객체임에도 낮은 확률로 같은 해시 값이 설정되어 같은 객체로 판단될 수도 있다는 의미이다. 이런 경우를 피하고자, equals 메서드가 사용된다.
즉, hashCode 비교 -> equals() 흐름을 통해 해시값 충돌로 인한 잘못된 비교 연산을 방지한다.
따라서 Java에서는 equals() 오버라이딩 시 hashCode()도 함께 오버라이딩 하도록 권장한다.
다시 String 으로 돌아오면,
equals는 byte 배열의 값이 같으면 동일하다고 판단할 수 있으므로 단순 반복문으로 구현했던 것이고,
hashCode는 같은 문자열을 가진 객체가 동일한 객체임을 보장해야 하기 때문에, 문자열의 값을 기준으로 해시코드를 생성하도록 오버라이딩하게 된 것이다.
'알아보자!' 카테고리의 다른 글
JAVA 명령어 동작 흐름 알아보기! (JAVA_HOME만 바꿨는데 왜 버전이 바뀔까?) (1) | 2025.07.03 |
---|---|
도커 컨테이너의 네임스페이스를 체감(?)해보자 (0) | 2025.04.07 |
nslookup과 AWS DNS 캐싱에 대한 궁금증 (0) | 2025.03.26 |
JWT를 이용한 로그아웃 구현 (1) | 2024.02.28 |
JWT(JSON Web Token) 구성 요소 (0) | 2024.02.24 |