저우즈밍(周志明) 저, "JVM 밑바닥까지 파헤치기"를 기반으로 작성한 글입니다.
실행 환경은 Windows + Ubuntu 22.04.05 LTS 기반으로 진행합니다.
2.2가 런타임 모델, 2.3에서는 메모리 모델을 다룬다.
그 중 가장 보편적인 가상 머신인 핫스팟과 가장 보편적인 메모리 영역인 자바 힙을 예시로,
객체 생성(할당), 레이아웃, 접근 방법 등의 과정을 다룬다.
1. 객체 생성
📌 Overview
시작하기 앞서, 일련의 과정을 간략하게 다이어그램으로 표현해봤는데 정확하진 않다.
책에서 제공한 다이어그램이 아니기 때문에 대충 의미를 자체로 해석해서 이런 느낌이 아닐까 싶은데, 몇 가지 의문점들이 있다.
클래스 로더 시스템에서 로딩(loading), 해석(resolve), 초기화(initalize)은 과연 중간 단계에서 멈출 수 있는가?
예를 들어, 로딩만 된 상태로 있거나, 해석만 된 상태로 남아있는 경우가 존재할 수 있는가?
초기화의 경우 지연 초기화(lazy initalize)와 같은 방법이 존재하니 뭔가 가능할 거 같은데,
로딩과 해석 과정 도중 굳이 멈추고 진행하지 않은 경우가 존재할지는 잘 모르겠다.
이걸 알려면 자바 클래스 로더 명세서나 JVM 세부 구현 사항들을 보다 깊게 살펴봐야 할 듯 하다.
(ChatGPT에 의하면 JVM은 성능을 위해 로딩-링크 과정은 보통 원자적으로 처리한다고 한다.)
그리고 심벌 참조가 상수 풀 안에 존재하지 않으면 예외를 발생한다고 그려놨는데, 사실 책에 그런 내용은 없다.
이걸 당췌 모르겠는데, '상수 풀에 해당 클래스의 심벌 참조가 없으면 Disk에서 찾아올까? 아니면 이상한 케이스일까?' 에 대한 고민을 해봤다.
그런데 Disk에서 class를 찾는 작업은 심벌 참조가 확인되고 클래스 로딩 단계에서 이루어져야 하므로, 이미 컴파일 시점에 문제가 발생했다고 보는 게 맞을 거 같아서 예외로 그려놨다. 허허...사실 잘 모르겠다. 어렵다.
✒️ 클래스 로딩(load), 해석(resolve), 초기화(initialize)
클래스 로더는 위 세 단계를 순차적으로 시행한다.
1️⃣ 로딩
• 클래스 파일을 바이트 스트림으로 읽는다.
• 바이트코드를 메서드 영역에 저장한다.
2️⃣ 해석
• 검증(verify): 바이트코드 검증
• 준비(prepare): 정적 변수 생성 및 초기화
• 해석(resolution): 심볼릭 레퍼런스를 실제 레퍼런스로 교체
(resolve를 linking이라고 부르기도 하는 듯..?)
3️⃣ 초기화
• static 블록 실행
• static 변수의 명시적 값 할당
• 객체 초기화
1️⃣ new 키워드
Java 언어 수준에서 개발자가 객체를 생성하는 방법은 new 키워드를 사용하는 것이다.
(물론 리플렉션과 역직렬화, 복사가 존재하긴 하나 여기선 제외하고 생각)
코드로 new 키워드를 작성하면, 자바 컴파일러는 해당 키워드를 바이트코드 명령어(new, newarray, anewarray, multianewarray 등)로 변환한다.
이 바이트코드들은 객체 생성을 위한 JVM 명령어라고 이해하면 된다.
JVM이 해당 바이트코드를 만나면, 가장 먼저 매개 변수가 상수 풀 안의 클래스를 카리키는 심벌 참조인지를 확인한다.
✒️ 심벌 참조(Symbolic Reference) vs 직접 참조(Direct Reference)
코드를 작성하면서 사용한 class, field, method의 이름들을 지칭한다.
해석(resolve) 단계에 이르러서 class, field, method, 상수 풀의 symbolic refrences들을 실제 메모리 주소로 변환한다.
쉽게 말해서, 추상적인 값들을 구체적인 값으로 동적으로 결정하기 위한 방법.
2️⃣ 심벌 참조가 뜻하는 클래스 상태 확인
상수 풀 안의 클래스를 가리키는 심벌 참조가 맞다면, 해당 클래스의 상태를 확인한다.
즉, 로딩, 해석, 초기화 중 어느 단계에 있는 지를 확인한다.
준비되지 않은 클래스라면 로딩부터 해야한다.
3️⃣ 객체를 담을 메모리 할당
로딩이 완료되었다면 본격적으로 새 객체를 담을 메모리를 할당한다.
객체에 필요한 메모리 크기는 클래스를 로딩하면 완벽히 알 수 있다.
(바로 다음 세션인 "객체 메모리 레이아웃"을 확인하면 알 수 있다.)
그런데 이런 메모리를 할당하는 방법엔 두 가지가 있다.
이는 자바 힙이 규칙적인가에 따라 결정되며, 이건 GC가 컴팩트(compact)를 할 수 있느냐에 따라 결정된다.
(그런데 찾아보니 결국 어떻게든 포인터 밀치기를 쓰는 거 같긴 하다. 아무래도 메모리가 파편화되어 있으면 비효율적이라 그런 듯?)
1️⃣ 포인터 밀치기 (bump the pointer)
💡 GC가 컴팩트를 수행할 수 있으며, 자바 힙이 규칙적인 경우
- 자바 힙이 규칙적(한 쪽은 사용 중, 한 쪽은 여유, 포인터는 두 영역의 경계를 가리킬 수 있어야 함)임이 전제되어야 한다.
- 메모리를 할당하면 포인터를 여유 공간 쪽으로, 새 객체 크기만큼 이동시킨다.
2️⃣ 여유 목록 (free list)
💡 GC가 이론상의 CMS처럼 스윕(sweep) 알고리즘을 채택하고, 자바 힙이 불규칙적인 경우
- 가용 메모리 블록을 리스트로 따로 관리한다.
- 객체 인스턴스를 담기에 충분한 공간을 찾아서 할당한 후, 목록을 갱신한다.
👇 가용 공간을 어떻게 나눌 것인가?
멀티스레딩 환경에선 단순히 포인터를 옮기는 일조차 thread-safe 하지 않다.
예를 들어, 한 스레드가 객체 생성 요청을 하여 메모리 할당하는 도중, 포인터가 수정되기 전에 다른 스레드에서 객체를 할당할 수도 있기 때문이다.
이는 -XX:+-UseTLAB 매개 변수로 설정할 수 있는데, 이에 따라 방법이 두 가지로 나뉜다.
1️⃣ 메모리 할당 동기화
- 모든 스레드의 메모리 할당을 동기화(synchronizeation)하여 안전성을 보장한다.
- 비교 및 교환(CAS) 실패 시, 재시도 방식을 채택한 VM은 갱신을 원자적(Atomic)으로 수행한다.
2️⃣ TLAB(Thread Local Allocation Buffer; 스레드 로컬 할당 버퍼)
- 각 스레드마다 다른 메모리 공간을 할당
- 스레드는 힙 영역에 작은 크기의 전용 메모리를 미리 할당받고, 버퍼가 부족해지면 동기화를 통해 추가로 할당받는다.
4️⃣ 할당 공간 초기화
- VM이 할당받은 메모리 영역을 0으로 초기화하는 과정 (객체 헤더 제외)
- Java에서 인스턴스 필드를 초기화하지 않아도 각 데이터 타입에 해당하는 0값을 담는 이유.
만약, TLAB 방식의 가용 공간 할당 전략을 채택하면, TLAB 할당 시 미리 초기화를 수행한다고 한다.
5️⃣ 객체 설정
- 인스턴스의 클래스 타입, 클래스 메타 정보를 찾는 방법, 해시 코드, GC 세대 나이 등의 정보를 객체 헤더에 저장한다.
📌 해치웠나? 그럴리가..😏
- 실제로는 JVM 뿐만 아니라, 자바 프로그램 관점에서의 객체도 생성을 해야 온전한 객체가 된다.
- Java 컴파일러는 new 키워드를 발견하면, 두 개의 바이트코드 명령어로 변환한다.
- new → new: JVM 관점에서 객체 생성
- new → invokespecial: 자바 프로그램 관점에서 객체 생성 (생성자 init() 호출)
2. 객체 메모리 레이아웃
📌 객체 메모리 구조
Hotspot JVM은 객체를 세 부분으로 나누어 Heap에 저장한다.
- 객체 헤더: 시그니처 정보, 클래스 정보 등
- 인스턴스 데이터: 객체 자체의 실제 정보
- 정렬 패딩: bit 수를 채우기 위한 패딩값
📌 객체 헤더
객체 헤더는 총 3개의 정보를 포함한다.
- 마크 워드
- 클래스 워드
- 배열 길이
그런데 배열 길이는 배열이 아니면 필요 없는 정보라서 없을 수도 있다.
1️⃣ 마크 워드(Mark Word)
- 객체 자체의 런타임 정보
- 객체 해시 코드, GC 세대 나이
- 락 상태 플래그
- 스레드가 점유 중인 락 등
- 64 bits 가상 머신에선 객체 헤더도 64 bits (32 bits JVM이면 32 bits)
- 일반적으로 64 bits 구조에 다 담을 수 없어서, 최대한 효율적으로 사용해야 함.
- 그래서 마크 워드 데이터 구조는 동적으로 의미가 달라질 수 있다.
2️⃣ 클래스 워드(Klass Word)
- 클래스 관련 메타데이터를 가리키는 포인터
- Java의 Reflection으로 특정 객체의 클래스 타입을 Runtime에 알 수 있는 이유
3️⃣ 배열 길이
- 배열 타입에만 존재하며, 배열의 길이(원소 개수)를 저장
- 클래스 워드로 얻은 정보는 원소 타입 크기라서, 배열 길이를 곱해야 배열 객체의 실제 메모리 크기를 구할 수 있음.
📌 인스턴스 데이터
- 객체가 실제로 담고 있는 정보
- 필드 정보, 부모 클래스 유무, 부모 클래스에서 정의한 모든 필드
- 저장 순서는 두 가지로 결정된다.
- JVM 할당 전략 매개 변수(-XX:FieldsAllocationStyle)
- 필드 정의 순서
- Hotspot JVM 기본 할당 전략
- {long, double → int → short, char → byte, boolean → 일반 객체 포인터} 순서로 저장 (길이가 같은 필드들은 항상 같이 할당되고 저장)
- 필드 길이가 같다면, 부모 클래스 필드가 우선 배치
- +XX:CompactFields를 true로 설정(default: true)하면, 자식의 길이가 짧은 필드는 상위 클래스 변수 사이에 끼워넣어서 공간을 절약한다.
📌 정렬 패딩
- Hotspot JVM의 자동 메모리 관리 시스템에서 객체 시작 주소가 반드시 8 bytes 정수배여야 한다는 규칙 때문에 존재.
- 모든 객체의 크기가 8 bytes의 정수배여야 하므로, 인스턴스 데이터가 조건을 충족하지 못할 시 패딩값을 채운다.
- 객체 헤더는 8 bytes 정수배가 되도록 잘 설계되어 있어서 논외
3. 객체 접근
📌 객체 접근 방식은 JVM 나름
JVM Specification에선 참조 타입을 객체를 가리키는 참조라고만 정해놓았다.
그래서 객체 접근 방식도 JVM 구현 나름이다.
그런데 주로 핸들이나 다이렉트 포인터 방식을 사용한다고 한다.
📌 핸들 방식
💡 객체와 클래스 포인터를 가리키는 중간 영역을 두자!
- 안정성에 초점
- Java Heap에 핸들 저장용 pool을 별도로 두고, 인스턴스와 클래스 데이터를 가리키도록 만듦.
- 빈번하게 GC가 발생해 객체가 이동해도 참조 자체는 건드리지 않아도 된다. (핸들만 수정하면 됨)
📌 다이렉트 포인터 방식
💡 응, 그냥 객체가 알아서 잘 부모를 가리키고 있어~
- 속도에 초점 (Hotspot JVM도 이거 쓴다)
- 다른 객체 접근할 일이 많으므로, 핸들 거치는 오버헤드를 줄임.
결국 혼란스러운 GC 제어를 편하게 할 거냐, 편의를 버리더라도 속도에 미쳐버릴 것이냐, 그 차이인 거 같긴 하다.