움직이는 객체
JVM을 공부해 본 사람이라면 GC가 일어나는 과정을 공부해 본 적이 있을 것이다. 가비지 컬렉터는 메모리를 여러 영역으로 나눠서 관리한다. 새롭게 생성된 객체는 Eden 영역에 할당되고, GC가 일어날 때까지 살아남은 객체들은 Survivor 영역으로 이동된다. 이렇게 몇 차례를 살아남은 객체들은 Old 영역으로 이동한다. GC 알고리즘에 따라 조금씩 다를 수 있지만 기본적인 원리는 그렇다.
어쨌든, 우리의 직관적인 생각과는 다르게 한 번 생성된 객체들은 메모리 상에서 가만히 있지 않고 끊임없이 움직인다. 이 말은 객체의 주소값이 계속 변한다는 것이고, 따라서 오래된 객체를 참조하면 segmentation fault가 발생하게 된다는 뜻인가? 물론 Java 개발자라면 이런 경우를 본 적이 없을 것이다. Java는 OOP라는 구조체를 이용해 이러한 문제를 해결한다. 당연히 여기서 OOP는 Object Oriented Programming이 아니고, Ordinary Object Pointer의 줄임말이다.
hashCode() 메소드
OOP에 대해 살펴보기 전에, 먼저 제목에 있는 질문부터 알아보자. toString을 오버라이드하지 않고 사용하면 이상한 문자열이 나오는 것을 확인할 수 있다. 이 문자열이 hashCode라는 것은 알지만, 기본적으로 hashCode가 어떻게 생성되는지는 모르고 넘어가는 경우가 많다. 일부는 인스턴스의 주소값을 바탕으로 만들어졌다고 하는 경우도 많이 봤다. 그 말이 사실이라면, 위에서 말한 것처럼 GC가 일어나는 경우 hashCode의 값이 바뀔 것이다. 직접 실험해보자.
import java.util.ArrayList;
public class Test {
public static void main(String[] args) {
Point point = new Point(-1, -1);
ArrayList<Point> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 1000000; j++) {
list.add(new Point(i, j));
}
list.clear();
System.gc();
System.out.println(point);
}
}
static class Point {
int x;
int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
}
Test$Point@28a418fc
Test$Point@28a418fc
Test$Point@28a418fc
...............생략...............
Test$Point@28a418fc
Test$Point@28a418fc
위 코드는 Point 객체 100만개를 임의로 생성하고, GC를 강제로 발생시키는 코드다. 보시다시피 1000번을 반복해도 hashCode값은 전혀 변하지 않았다. 따라서 hashCode값이 주소값을 바탕으로 만들어졌다는 말은 일반적으로 사실이 아니다.
Object 클래스의 hashCode 메소드를 직접 확인해보자.
@IntrinsicCandidate
public native int hashCode();
hashCode는 native 메소드이기 때문에 JVM 코드를 직접 찾아봐야 한다. JVM 코드를 따라가다 보면 hashCode를 생성하는 ObjectSynchronizer::FastHashCode와 get_next_hash 함수를 찾을 수 있다.
[get_next_hash] https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/synchronizer.cpp#l555
[FastHashCode] https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/synchronizer.cpp#l601
먼저, FastHashCode를 살펴보면, 상당히 길고 복잡하지만 반복되는 부분이 있다.
hash = mark->hash();
if (hash == 0) {
hash = get_next_hash(Self, obj);
}
결국 mark에 hash가 있는지 확인하고, 없다면 직접 hash를 생성한다. 여기서 mark는 markWord라는 구조체를 의미하며, 각 객체별로 hashCode 값과 GC로부터 살아남은 횟수, lock과 관련된 정보 등을 이곳에 저장한다.
get_next_hash는 hashCode라는 변수에 의해 6가지 구현으로 나누어진다. 랜덤한 값으로 생성하거나, 순차적으로 증가하는 sequence, 고정된 값 등이 존재한다. OpenJDK 8 이상에서는 스레드의 상태를 XOR shift라는 방법으로 랜덤하게 생성하는 것을 기본으로 정하고 있다.
[OpenJDK 15] https://hg.openjdk.org/jdk/jdk15/file/0dabbdfd97e6/src/hotspot/share/runtime/globals.hpp#l702
[XOR shift] https://en.wikipedia.org/wiki/Xorshift
[Thread hashState] https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/b7f0e16c80dd/src/share/vm/runtime/thread.cpp#l268
결론적으로, (JDK에 따라 다를 수 있지만) 일반적으로는 hashCode 값은 객체의 주소값과 관련이 없다는 것을 알았다. 그러나 아직 한 가지 궁금한 것이 남았다. hashCode는 결국 랜덤한 값인데, 어떻게 매번 같은 값이 나오는 것일까? 이미 위에서 살펴본 markWord에 hashCode 값을 저장하기 때문이다.
Ordinary Object Pointer
Ordinary Object Pointer란 Java의 객체를 표현하는 자료구조이다. 객체마다 필드와 메소드 등에 대한 정보, hashCode값, lock과 관련된 정보들을 저장하고 있다. 좀 더 정확하게는 markWord와 klass로 이루어진 헤더부분과 필드를 저장하는 부분으로 나누어진다. 필드는 단순히 기본형 혹은 참조형 데이터를 저장하는 것이고, markWord와 klass에 대해 자세히 살펴보자.
MarkWord는 hashCode와 GC에서 살아남은 횟수를 세는 age, lock과 biased-lock을 위한 정보로 이루어진다. 환경에 따라서 markWord의 사이즈가 달라지는데, age는 4bit, lock은 2bit, biased-lock은 1bit로 고정이고 hashCode의 사이즈는 32bit 환경에서 25bit, 64bit 환경에서는 31bit를 사용한다. 64bit 환경에서 나머지 25bit는 사용하지 않는다. 참고로, biased-lock은 lock을 최적화하기 위한 것이었지만, OpenJDK 15에서 deprecated 되었다.
[Deprecate Biased Locking] https://openjdk.org/jeps/374
Klass는 이름에서도 알 수 있듯이 클래스에 대한 정보를 저장한다. 당연하게도 모든 객체가 메소드 실행 코드를 가지고 있을 필요가 없다. 따라서 메소드 영역에 코드를 저장해두고 객체에서는 메소드를 참조해 사용한다. 객체에서 메소드를 참조하기 위해서 klass 내부에는 vtable이라는 자료구조를 가진다. Klass는 당연히 메소드의 구현을 알지 못하고, 단순히 참조를 통해 메소드를 실행시킬 뿐이다. 따라서 vtable을 덮어쓰면 메소드의 구현이 바뀐다. 이것이 바로 Java 다형성의 원리이다.
[oop] https://hg.openjdk.org/jdk/jdk15/file/0dabbdfd97e6/src/hotspot/share/oops/oop.hpp
[markWord] https://hg.openjdk.org/jdk/jdk15/file/0dabbdfd97e6/src/hotspot/share/oops/markWord.hpp
[klass] https://hg.openjdk.org/jdk/jdk15/file/0dabbdfd97e6/src/hotspot/share/oops/klass.hpp
참고로, OOP는 클래스 메타데이터를 저장한다는 점에서 Class 객체와 비슷하지만 전혀 다른 개념이다. OOP는 C++로 작성된 JVM에서 사용하는 구조체이고, Class 객체는 Java 오브젝트로 리플렉션을 사용하기 위한 객체이다.
3줄 요약
- GC가 일어나면 객체는 움직이지만, hashCode는 변하지 않는다
- hashCode는 JVM 종류에 따라 다르지만 랜덤한 값이라고 보면 된다
- 모든 객체는 OOP라는 구조체로 이루어진다
'Java > 기타' 카테고리의 다른 글
[Java] JNI(Java Native Interface) 사용해보기 (0) | 2023.03.20 |
---|---|
[Java] Java Native Image - JVM 없이 Java 어플리케이션 실행하기 (0) | 2023.03.19 |