자바 객체의 생존과 소멸 그리고 깊이 있는 메모리 레이아웃 분석

JV-ST-09calendar_today2025-12-28 20:17#Java #Level2

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())했음에도 메모리가 회수되지 않는 현상에 대한 분석입니다.
    java
    public class Main {
        public static void main(String[] args) {
            {
                byte[] placeholder = new byte[64 * 1024 * 1024]; // 64MB 할당
            }
            // int a = 0; // 이 줄을 추가하면 GC가 정상 작동함!
            System.gc();
        }
    }
    
  • JVM의 실제 동작 (물리적):
    • 새로운 변수가 없는 경우
      1. JVM은 비어있는 서랍(예: Slot 1)을 placeholder에게 배정합니다.
      2. Slot 1에는 힙(Heap)에 있는 64MB 배열을 가리키는 참조 주소값이 저장됩니다.
      3. JVM은 효율성을 위해 굳이 Slot 1의 내용을 0으로 지우지 않습니다. (메모리를 지우는 것도 CPU 비용이 드니까요.)
      4. 그저 컴파일러 입장에서 "이제 Slot 1은 주인이 없어, 다른 변수가 써도 돼"라고 마킹만 해둡니다.
      5. 문제점: Slot 1 내부에는 여전히 64MB 배열을 가리키는 주소값이 그대로 남아있습니다.
      6. 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)**해야 합니다.