J-S-09 자바 객체의 생존과 소멸 그리고 깊이 있는 메모리 레이아웃 분석
글 정보
- 카테고리
- Programming/Java/Starter
- 태그
- JavaLevel2
1. 도달 가능성 분석: GC는 삭제 대상을 어떻게 결정할까?
Java의 가비지 컬렉터(GC)는 단순히 메모리가 꽉 찼다고 해서 무작정 객체를 지우지 않습니다.
가장 먼저 이 객체가 살아있어야 할 이유(도달 가능성) 가 있는지 확인하는 과정을 거칩니다.
- Reachability Analysis (도달 가능성 분석)
- GC Root라고 불리는 '확실한 생존 객체'들을 기점으로 탐색을 시작합니다.
- 이 Root로부터 참조 사슬이 연결된 객체는 Reachability(도달 가능) 상태로 판단하여 살려둡니다.
- 반면, 어떤 경로로도 도달할 수 없는 객체는 회수 대상이 됩니다.
- 누가 GC Root가 될 수 있는가?
- JVM 스택 프레임: 현재 실행 중인 메서드 내의 지역 변수나 매개변수.
- 메서드 영역: 클래스의 정적(Static) 필드나 상수(Constant)가 참조하는 객체.
- JNI(Java Native Interface): 네이티브 코드(C/C++) 레벨에서 참조하는 객체.
- Synchronized 객체: 현재 Lock이 걸려있는 객체는 절대 회수되면 안 되므로 Root로 간주합니다.
2. G1 GC와 현대적인 메모리 관리
Java 9부터 기본(Default) GC로 채택된 G1(Garbage First) GC는 대용량 힙 메모리를 효율적으로 관리하기 위해 등장했습니다. (참고: Java 8의 기본 GC는 Parallel GC입니다.)
- 기존 GC와의 결정적 차이
- 기존(Serial, Parallel, CMS): Heap을 물리적으로 고정된 Young/Old 영역으로 나누어 관리했습니다.
- G1 GC: Heap을 Region(영역)이라는 독립적인 단위로 잘게 쪼개서 관리합니다. (보통 1MB ~ 32MB)
- G1의 핵심 철학
- 이름 그대로 쓰레기가 가장 많은 영역(Garbage First)을 우선순위에 둡니다.
- 전체 힙을 뒤지는 것이 아니라, 회수했을 때 이득이 가장 큰 영역을 먼저 청소하여 효율을 극대화합니다.
- 흥미로운 코드 실험: 왜 GC가 바로 작동하지 않았을까? 사용자가 직접 GC를 호출(
System.gc())했음에도 메모리가 회수되지 않는 현상에 대한 분석입니다.
public class Main {
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024]; // 64MB 할당
}
// int a = 0; // 이 줄을 추가하면 GC가 정상 작동함!
System.gc();
}
}
- JVM의 실제 동작 (물리적):
- 새로운 변수가 없는 경우
- JVM은 비어있는 서랍(예:
Slot 1)을placeholder에게 배정합니다. Slot 1에는 힙(Heap)에 있는 64MB 배열을 가리키는 참조 주소값이 저장됩니다.- JVM은 효율성을 위해 굳이
Slot 1의 내용을 0으로 지우지 않습니다. (메모리를 지우는 것도 CPU 비용이 드니까요.) - 그저 컴파일러 입장에서 "이제
Slot 1은 주인이 없어, 다른 변수가 써도 돼"라고 마킹만 해둡니다. - 문제점:
Slot 1내부에는 여전히 64MB 배열을 가리키는 주소값이 그대로 남아있습니다. - GC의 판단: GC가 스택을 훑어보다가
Slot 1을 봅니다. "어? 여기에 힙을 가리키는 주소가 있네? (비록 유효 범위는 끝났어도 값은 유효하니) 이 객체는 아직 살아있다!"라고 오해하게 됩니다. - 변수가 있고, 선언되는 경우
- 동작:
- JVM은 새 변수
a를 저장할 공간이 필요합니다. - "어? 아까
placeholder가 쓰던Slot 1이 지금 주인 없이 비어있네? 이걸 재사용하자." Slot 1에 남아있던 주소값을 덮어버리고, 정수0을 기록합니다.- 결과:
- 이제
Slot 1에는 힙 영역 주소가 없습니다. - GC가 다시 스택을 훑을 때, 64MB 배열을 가리키는 연결고리가 완전히 끊어진 것을 확인합니다.
- 드디어 GC 대상이 되어 메모리가 회수됩니다.
3. 자바 객체 내부 들여다보기: JOL(Java Object Layout)
우리가 new Object()를 할 때, 메모리에는 데이터만 저장되는 것이 아닙니다. org.openjdk.jol 라이브러리로 확인해 보면 객체는 크게 세 부분으로 나뉩니다.
1) Header (객체 헤더)
- Mark Word: 객체의 생존과 관리를 위한 핵심 정보가 담깁니다.
- HashCode:
Object.hashCode()가 호출될 때 계산되어 저장됩니다. (CPU 절약을 위해 Lazy하게 생성됨) - Object Age: GC 과정에서 몇 번 살아남았는지 기록합니다.
- Lock Flag: 멀티스레드 동기화 상태(경량 락, 중량 락 등)를 표시합니다.
- Klass Word: 이 객체가 어떤 클래스(메타데이터)의 인스턴스인지 가리키는 주소입니다.
- Length: 배열 객체인 경우에만 존재하며, 배열의 길이를 저장합니다.
2) Instance Data
- 개발자가 클래스에 정의한 실제 필드 값들이 저장되는 공간입니다.
3) Padding
- 현대 CPU는 데이터 효율을 위해 메모리를 특정 단위(예: 8바이트)로 읽습니다.
- 객체의 크기가 이 단위에 맞지 않을 경우, 빈 공간을 채워 넣어 자릿수를 맞추는데 이를 패딩이라 합니다.

4. 동등성(Equality) vs 동일성(Identity)
자바 초심자가 가장 많이 헷갈리는 "같다"의 두 가지 의미를 명확히 구분해야 합니다.
동일성 (Identity)
- 키워드: == 연산자, 물리적 주소, 1개의 인스턴스.
- 의미: "두 변수가 메모리 상의 완전히 똑같은 하나의 객체를 가리키고 있는가?"
- 비유: 얕은 복사(Shallow Copy) 관계.
동등성 (Equality)
- 키워드:
equals()메서드, 논리적 내용, 2개의 인스턴스. - 의미: "서로 다른 객체일지라도, 그 안에 담긴 내용(Value)이 같은가?"
- 비유: 깊은 복사(Deep Copy) 후 내용 비교.
💡 핵심 요약Object클래스의 기본equals()는 내부적으로 \=\=을 사용합니다.
따라서 객체의 내용을 비교하고 싶다면, 반드시equals()와hashCode()를 재정의(Override)해야 합니다.