본문 바로가기

스택·힙·메서드 영역

호출 스택·힙 객체·메서드 영역의 역할을 구분하고 회원 저장소 객체가 생성·전달·회수 가능한 상태가 되는 과정을 추적합니다.

메모리 구조는 특정 주소를 외우는 문제가 아니라 실행 현상을 설명하는 모델입니다.

지역 변수는 왜 호출이 끝나면 사라지는지, 서로 다른 매개변수가 어떻게 같은 객체를 바꾸는지, 객체 참조를 잃으면 왜 더 사용할 수 없는지를 스택·힙·메서드 영역의 역할로 읽습니다.

호출 스택은 현재 실행 중인 메서드의 순서를 쌓아 두는 구조입니다.

정상 호출부터 보면 어렵지 않습니다.

정상 호출 스택의 최소 예제
public static void main(String[] args) {
    first();
}

private static void first() {
    second();
}

private static void second() {
    System.out.println("done");
}

실행 중에는 main → first → second 순서로 스택 프레임이 쌓이고, 출력 뒤에는 second → first → main 순서로 사라집니다.

각 프레임은 그 호출의 매개변수·지역 변수·돌아갈 위치를 보관합니다.

이 정상 흐름을 기준으로 종료되지 않는 재귀가 왜 스택을 소진하는지 살펴봅니다.


재귀 호출과 스택 고갈

lab/RecursiveStackOverflow.java
public final class RecursiveStackOverflow {
    public static void main(String[] args) {
        recurse(1);
    }

    private static void recurse(int depth) {
        if (depth <= 3) {
            System.out.println("depth=" + depth);
        }
        recurse(depth + 1);
    }
}
실패 관찰
depth=1
depth=2
depth=3
Exception in thread "main" java.lang.StackOverflowError

재귀 호출마다 depth 매개변수와 돌아갈 위치를 가진 새 스택 프레임이 쌓입니다.

종료 조건이 없으므로 프레임이 제거되지 않고 스택 영역의 한계에 도달합니다.

StackOverflowError는 힙에 객체가 많다는 뜻이 아니라 호출 깊이가 지나치게 커졌다는 신호입니다.

정상 재귀는 종료 조건을 먼저 둡니다.

private static int sumTo(int value) {
    if (value <= 0) return 0;
    return value + sumTo(value - 1);
}

호출이 가장 깊은 종료점에 도달한 뒤 마지막 프레임부터 반환하며 제거됩니다.

큰 입력에는 반복문이 스택 깊이를 일정하게 유지하는 더 안전한 선택일 수 있습니다.

예시

sumTo(3)의 반환값을 먼저 종이에 써 보세요. 호출은 3 → 2 → 1 → 0으로 내려가고, 반환은 0 → 1 → 3 → 6으로 올라옵니다. 한 호출이 자기 value를 보관한 채 다음 호출의 결과를 기다리므로, 마지막 반환에서 3 + 3 = 6이 됩니다. 종료 조건이 있어도 아주 큰 양수는 호출 깊이를 크게 만듭니다. 또 반환형이 int이므로 스택 문제와 합계의 정수 범위 문제를 따로 확인해야 합니다.


메모리 영역의 역할

메서드 영역

메서드 영역은 클래스 정보와 메서드 코드를 설명하는 개념적 영역입니다. 이 모델에서는 static도 클래스 소유 상태로 구분하지만, 필드의 물리적 저장 위치를 보장하는 것은 아닙니다.

같은 클래스의 인스턴스를 100개 만들어도 메서드 코드 100벌을 각 객체에 복제하지 않습니다.

스택 영역

메서드 호출마다 스택 프레임을 만들고 매개변수와 지역 변수를 관리합니다.

호출이 끝나면 해당 프레임이 제거됩니다.

지역 참조 변수 자체는 스택 프레임에 있지만 그 변수가 가리키는 객체는 힙에 있습니다.

힙 영역

new로 생성한 객체와 배열이 존재합니다.

여러 참조 변수가 같은 힙 객체를 가리킬 수 있습니다.

더 이상 도달 가능한 참조가 없는 객체는 가비지 컬렉션 대상이 됩니다.

“즉시 삭제되는 정확한 시점”은 개발자가 일반 코드에서 보장하지 않습니다.

JVM 구현 세부는 더 복잡할 수 있지만, 언어 수준의 실행 흐름을 이해할 때 이 구분이 유용합니다.


스택 프레임과 힙 객체

src/StackHeapWalkthrough.java
public final class StackHeapWalkthrough {
    public static void main(String[] args) {
        System.out.println("main:start");
        Member member = new Member("[email protected]", 40);
        celebrateBirthday(member);
        System.out.println("main:" + member.describe());
        System.out.println("main:end");
    }

    private static void celebrateBirthday(Member parameter) {
        System.out.println("birthday:start");
        parameter.celebrateBirthday();
        inspect(parameter);
        System.out.println("birthday:end");
    }

    private static void inspect(Member parameter) {
        System.out.println("inspect:" + parameter.describe());
    }

    private static final class Member {
        private final String email;
        private int age;

        Member(String email, int age) {
            this.email = email;
            this.age = age;
        }

        void celebrateBirthday() {
            if (age >= 120) throw new IllegalStateException("maximum age");
            age++;
        }

        String describe() {
            return email + "=" + age;
        }
    }
}
main:start
birthday:start
inspect:[email protected]=41
birthday:end
main:[email protected]=41
main:end

main 프레임의 member, celebrateBirthday 프레임의 parameter, inspect 프레임의 parameter는 서로 다른 지역 변수입니다.

그러나 세 변수에 복사된 참조값은 같은 Member 힙 객체를 가리킵니다.

객체 전체를 매번 복사한 것이 아닙니다. parameter.celebrateBirthday()는 복사된 참조값으로 같은 객체에 도달해 그 객체의 age를 바꿉니다. 반면 메서드 안에서 parameter = null;을 실행하면 그 호출의 지역 변수만 바뀌고 호출자의 member는 그대로입니다. “객체를 바꾸는 대입”과 “참조 변수를 바꾸는 대입”을 구분하면, 메서드 반환 뒤에도 나이 41이 남는 이유를 설명할 수 있습니다.

inspect 프레임이 사라져도 main의 member가 객체에 도달하므로 객체는 계속 사용됩니다.

메서드 호출 순서는 main → celebrateBirthday → inspect이고 반환은 inspect → celebrateBirthday → main입니다.

후입선출 스택 구조가 호출과 복귀를 자연스럽게 관리합니다.

inspect 진입 시 세 프레임이 같은 Member를 참조한다

main→celebrateBirthday→inspect 프레임의 다른 지역 변수가 같은 Member를 가리켜 age 40→41을 관찰합니다. 고정 호출 역할과 현재 프레임/공유 필드 전환을 함께 보여 줄 수 있습니다.

inspect 진입 시 세 프레임이 같은 Member를 참조한다

StackHeapWalkthrough의 inspect 진입 직후 세 지역 참조가 같은 age 41 Member에 도달하는 호출 모델이다. 뒤이어 describe 호출 프레임이 잠시 추가된다.

세 프레임의 서로 다른 변수가 한 Member를 참조한다

inspect 진입 직후 같은 회원의 나이는 이미 41이며, 참조값만 각 호출로 전달됩니다.

StackHeapWalkthrough · inspect 진입 직후

세 호출 프레임의 참조와 하나의 Member 스택 위부터 inspect, 바깥 celebrateBirthday, main이 있고 각 parameter 또는 member가 같은 [email protected]의 age 41 객체를 가리킨다. Member.describe는 아직 호출 전이다. 호출 스택 · 위가 나중에 호출됨 힙 객체 · 개념 모델 inspect 프레임 parameter 다음: Member.describe() 호출 celebrateBirthday 프레임 parameter inspect() 반환을 기다림 main 프레임 member 바깥 생일 호출 반환 대기 같은 Member 객체 [email protected] age = 41 생일 반영 후 · 40 → 41
세 프레임의 서로 다른 변수가 한 Member를 참조한다 inspect 진입직후 inspect.parameter,celebrateBirthday.parameter,main.member 서로다른지역변수→동일kim/41 Member;객체세개아님. 바깥호출반환순서는기존설명유지. 호출 스택 · 진입 직후 inspect 프레임 parameter 같은 Member 참조 생일 처리 프레임 celebrateBirthday parameter inspect 반환 대기 main 프레임 member 같은 Member 참조 힙 · 같은 객체 하나 Member [email protected] age = 41 실선: 객체 참조값의 대상 세 변수 ≠ 객체 세 개
스택 위 · inspect
parameter가 아래의 같은 Member를 가리킵니다. 현재 프레임이며 다음에 Member.describe()를 호출합니다.
그 아래 · celebrateBirthday
parameter가 같은 Member를 가리키며 inspect()의 반환을 기다립니다.
스택 아래 · main
member가 같은 Member를 가리키며 바깥 celebrateBirthday()의 반환을 기다립니다.

공유하는 Member: [email protected], age=41. 객체의 생일 메서드는 이미 반환했습니다.

세 변수는 서로 다르지만 참조 대상은 같습니다. 뒤이은 describe() 호출 때 프레임이 잠시 추가됩니다. 이 세 바깥 호출은 inspect → celebrateBirthday → main 순서로 끝나며, inspect 반환 뒤에도 main.member로 객체를 사용합니다. 물리 주소와 JVM 내부 호출은 표시하지 않습니다.

이 예의 조건

  • main의 age 40부터 생일 쓰기와 inspect 진입을 추적합니다. 마지막 inspect 단계는 Member.describe 호출 전이며 생일 40→41 반영 뒤의 개념 스택입니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보inspect 진입 시 세 프레임이 같은 Member를 참조한다 inspect 진입 시 세 프레임이 같은 Member를 참조한다 StackHeapWalkthrough의 inspect 진입 직후 세 지역 참조가 같은 age 41 Member에 도달하는 호출 모델이다. 뒤이어 describe 호출 프레임이 잠시 추가된다. 세 프레임의 서로 다른 변수가 한 Member를 참조한다 inspect 진입 직후 같은 회원의 나이는 이미 41이며, 참조값만 각 호출로 전달됩니다. StackHeapWalkthrough · inspect 진입 직후 스택 위 · inspect parameter 가 아래의 같은 Member를 가리킵니다. 현재 프레임이며 다음에 Member.describe() 를 호출합니다. 그 아래 · celebrateBirthday parameter 가 같은 Member를 가리키며 inspect() 의 반환을 기다립니다. 스택 아래 · main member 가 같은 Member를 가리키며 바깥 celebrateBirthday() 의 반환을 기다립니다. 공유하는 Member: [email protected] , age=41 . 객체의 생일 메서드는 이미 반환했습니다. 세 변수는 서로 다르지만 참조 대상은 같습니다. 뒤이은 describe() 호출 때 프레임이 잠시 추가됩니다. 이 세 바깥 호출은 inspect → celebrateBirthday → main 순서로 끝나며, inspect 반환 뒤에도 main.member 로 객체를 사용합니다. 물리 주소와 JVM 내부 호출은 표시하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
main 호출

main의 member가 Kim 객체 참조를 생일 함수에 넘깁니다.

  • main: 외부 호출 복귀 대기
  • 객체 초기 age: 40
  • 인자 참조 복사가 같은 객체의 추가 경로를 만듭니다.
생일 반영

celebrateBirthday의 parameter로 age를 41로 바꿉니다.

  • 객체: Kim 41
  • 다음: inspect 호출
  • 객체 상태가 바뀌어도 지역 참조는 같은 객체입니다.
inspect 프레임

inspect가 추가된 세 프레임 모두 같은 객체를 참조합니다.

  • 위부터: inspect,celebrateBirthday,main
  • 다음: Member.describe 호출
  • 세 프레임이 세 Member 객체를 뜻하지 않습니다.

inspect 진입 시 세 프레임이 같은 Member를 참조한다

main→celebrateBirthday→inspect 프레임의 다른 지역 변수가 같은 Member를 가리켜 age 40→41을 관찰합니다. 고정 호출 역할과 현재 프레임/공유 필드 전환을 함께 보여 줄 수 있습니다.

inspect 진입 시 세 프레임이 같은 Member를 참조한다

StackHeapWalkthrough의 inspect 진입 직후 세 지역 참조가 같은 age 41 Member에 도달하는 호출 모델이다. 뒤이어 describe 호출 프레임이 잠시 추가된다.

세 프레임의 서로 다른 변수가 한 Member를 참조한다

inspect 진입 직후 같은 회원의 나이는 이미 41이며, 참조값만 각 호출로 전달됩니다.

StackHeapWalkthrough · inspect 진입 직후

세 호출 프레임의 참조와 하나의 Member 스택 위부터 inspect, 바깥 celebrateBirthday, main이 있고 각 parameter 또는 member가 같은 [email protected]의 age 41 객체를 가리킨다. Member.describe는 아직 호출 전이다. 호출 스택 · 위가 나중에 호출됨 힙 객체 · 개념 모델 inspect 프레임 parameter 다음: Member.describe() 호출 celebrateBirthday 프레임 parameter inspect() 반환을 기다림 main 프레임 member 바깥 생일 호출 반환 대기 같은 Member 객체 [email protected] age = 41 생일 반영 후 · 40 → 41
세 프레임의 서로 다른 변수가 한 Member를 참조한다 inspect 진입직후 inspect.parameter,celebrateBirthday.parameter,main.member 서로다른지역변수→동일kim/41 Member;객체세개아님. 바깥호출반환순서는기존설명유지. 호출 스택 · 진입 직후 inspect 프레임 parameter 같은 Member 참조 생일 처리 프레임 celebrateBirthday parameter inspect 반환 대기 main 프레임 member 같은 Member 참조 힙 · 같은 객체 하나 Member [email protected] age = 41 실선: 객체 참조값의 대상 세 변수 ≠ 객체 세 개
스택 위 · inspect
parameter가 아래의 같은 Member를 가리킵니다. 현재 프레임이며 다음에 Member.describe()를 호출합니다.
그 아래 · celebrateBirthday
parameter가 같은 Member를 가리키며 inspect()의 반환을 기다립니다.
스택 아래 · main
member가 같은 Member를 가리키며 바깥 celebrateBirthday()의 반환을 기다립니다.

공유하는 Member: [email protected], age=41. 객체의 생일 메서드는 이미 반환했습니다.

세 변수는 서로 다르지만 참조 대상은 같습니다. 뒤이은 describe() 호출 때 프레임이 잠시 추가됩니다. 이 세 바깥 호출은 inspect → celebrateBirthday → main 순서로 끝나며, inspect 반환 뒤에도 main.member로 객체를 사용합니다. 물리 주소와 JVM 내부 호출은 표시하지 않습니다.

이 예의 조건

  • main의 age 40부터 생일 쓰기와 inspect 진입을 추적합니다. 마지막 inspect 단계는 Member.describe 호출 전이며 생일 40→41 반영 뒤의 개념 스택입니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보inspect 진입 시 세 프레임이 같은 Member를 참조한다 inspect 진입 시 세 프레임이 같은 Member를 참조한다 StackHeapWalkthrough의 inspect 진입 직후 세 지역 참조가 같은 age 41 Member에 도달하는 호출 모델이다. 뒤이어 describe 호출 프레임이 잠시 추가된다. 세 프레임의 서로 다른 변수가 한 Member를 참조한다 inspect 진입 직후 같은 회원의 나이는 이미 41이며, 참조값만 각 호출로 전달됩니다. StackHeapWalkthrough · inspect 진입 직후 스택 위 · inspect parameter 가 아래의 같은 Member를 가리킵니다. 현재 프레임이며 다음에 Member.describe() 를 호출합니다. 그 아래 · celebrateBirthday parameter 가 같은 Member를 가리키며 inspect() 의 반환을 기다립니다. 스택 아래 · main member 가 같은 Member를 가리키며 바깥 celebrateBirthday() 의 반환을 기다립니다. 공유하는 Member: [email protected] , age=41 . 객체의 생일 메서드는 이미 반환했습니다. 세 변수는 서로 다르지만 참조 대상은 같습니다. 뒤이은 describe() 호출 때 프레임이 잠시 추가됩니다. 이 세 바깥 호출은 inspect → celebrateBirthday → main 순서로 끝나며, inspect 반환 뒤에도 main.member 로 객체를 사용합니다. 물리 주소와 JVM 내부 호출은 표시하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
main 호출

main의 member가 Kim 객체 참조를 생일 함수에 넘깁니다.

  • main: 외부 호출 복귀 대기
  • 객체 초기 age: 40
  • 인자 참조 복사가 같은 객체의 추가 경로를 만듭니다.
생일 반영

celebrateBirthday의 parameter로 age를 41로 바꿉니다.

  • 객체: Kim 41
  • 다음: inspect 호출
  • 객체 상태가 바뀌어도 지역 참조는 같은 객체입니다.
inspect 프레임

inspect가 추가된 세 프레임 모두 같은 객체를 참조합니다.

  • 위부터: inspect,celebrateBirthday,main
  • 다음: Member.describe 호출
  • 세 프레임이 세 Member 객체를 뜻하지 않습니다.

도달 가능성과 객체 수명

src/ReachabilityTrace.java
public final class ReachabilityTrace {
    public static void main(String[] args) {
        Member first = new Member("[email protected]", 40);
        Member second = first;

        first = null;
        System.out.println(second.describe());

        second = new Member("[email protected]", 50);
        System.out.println(second.describe());
    }

    private record Member(String email, int age) {
        String describe() {
            return email + "=" + age;
        }
    }
}

first를 null로 바꿔도 second가 기존 [email protected] 회원 객체를 가리키므로 도달 가능합니다.

second에 새 [email protected] 회원 객체를 대입하면 기존 [email protected] 회원 객체를 가리키는 지역 참조가 없어집니다.

그 객체는 즉시 사라졌다고 단정하지 않고 가비지 컬렉션 대상이 되었다고 표현합니다.

참조 재대입 뒤 남는 Member 도달 경로

first=null 후 second는 Kim을 유지하고 second에 Lee를 대입한 뒤 Kim의 지역 도달 경로가 사라집니다. GC 즉시 회수는 관측하지 않고 참조 연결의 세 상태를 추적합니다.

참조 재대입 뒤 남는 Member 도달 경로

ReachabilityTrace의 별칭 생성 후, first를 null로 바꾼 후, second를 새 Member로 바꾼 후의 지역 참조 경로를 비교한다. GC 실행 시점을 측정한 표가 아니다.

참조를 재대입하면 기존 객체로 가는 경로가 줄어든다

first를 null로 바꿔도 second 경로가 남고, second의 새 객체 대입으로 그 경로도 사라집니다.

ReachabilityTrace · 각 문장 실행 뒤의 지역 참조 모델
시점firstsecond기존 kim/40으로 가는 경로
second = first 뒤kim/40 객체같은 kim/40 객체두 변수에서 같은 객체로 연결
first = null 뒤null기존 kim/40 객체second 경로가 남음
새 lee/50을 second에 대입한 뒤null새 lee/50 객체그림의 두 지역 변수에서는 경로 없음
참조를 재대입하면 기존 객체로 가는 경로가 줄어든다 별칭생성first/second→kim40;first=null후second→kim40만;second새대입후second→lee50,kim으로의두지역참조없음. 화살표객체참조,GC시점미측정. 별칭 생성 뒤 지역 참조 변수 first → kim second → kim Member 하나 [email protected] age = 40 first = null 뒤 지역 참조 변수 first = null second → kim Member 하나 [email protected] age = 40 second 새 객체 대입 뒤 지역 참조 변수 first = null second → lee Member 하나 [email protected] age = 50 마지막 상태: kim 객체로 가는 지역 참조 없음 GC 대상 관계 설명 · 즉시 회수 관찰이 아님
참조를 재대입하면 기존 객체로 가는 경로가 줄어든다 별칭생성first/second→kim40;first=null후second→kim40만;second새대입후second→lee50,kim으로의두지역참조없음. 화살표객체참조,GC시점미측정. 별칭 생성 뒤 지역 참조 변수 first → kim second → kim Member 하나 [email protected] age = 40 first = null 뒤 지역 참조 변수 first = null second → kim Member 하나 [email protected] age = 40 second 새 객체 대입 뒤 지역 참조 변수 first = null second → lee Member 하나 [email protected] age = 50 kim 객체로 가는 경로 없음 변수 재대입: 실선 경로 변경 회수 시점은 표시하지 않음

ReachabilityTrace · 문장 실행 뒤의 지역 참조 모델

  1. second = first 뒤: first와 second 모두 같은 kim/40 객체를 가리킵니다.
  2. first = null 뒤: first는 null이고 second는 기존 kim/40 객체를 계속 가리킵니다.
  3. 새 lee/50을 second에 대입한 뒤: first는 null, second는 새 객체를 가리킵니다. 두 지역 변수에서 기존 kim/40으로 가는 경로는 없습니다.

kim/40과 lee/50은 각각 [email protected]=40, [email protected]=50인 서로 다른 Member입니다. 출력은 이 순서로 두 줄입니다. 마지막 행은 기존 객체에 대한 참조 경로 제거를 설명하며, 그 순간 실제 메모리가 회수됐다는 뜻은 아닙니다.

이 예의 조건

  • 지역 도달 관계 설명이며 GC 시점을 측정하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2별칭 생성 first/second→kim 40, first=null 후 second→kim 40만 남고, second에 새로 대입한 후 second→lee 50이 되며 kim으로의 두 지역 참조는 없다. 화살표는 객체 참조이며 GC 시점은 측정하지 않는다.
원 도표의 설명과 주석
구분내용
설명별칭 생성 first/second→kim 40, first=null 후 second→kim 40만 남고, second에 새로 대입한 후 second→lee 50이 되며 kim으로의 두 지역 참조는 없다. 화살표는 객체 참조이며 GC 시점은 측정하지 않는다.
전체 읽기 정보참조 재대입 뒤 남는 Member 도달 경로 참조 재대입 뒤 남는 Member 도달 경로 ReachabilityTrace의 별칭 생성 후, first를 null로 바꾼 후, second를 새 Member로 바꾼 후의 지역 참조 경로를 비교한다. GC 실행 시점을 측정한 표가 아니다. 참조를 재대입하면 기존 객체로 가는 경로가 줄어든다 first를 null로 바꿔도 second 경로가 남고, second의 새 객체 대입으로 그 경로도 사라집니다. ReachabilityTrace · 각 문장 실행 뒤의 지역 참조 모델 시점 first second 기존 kim/40으로 가는 경로 second = first 뒤 kim/40 객체 같은 kim/40 객체 두 변수에서 같은 객체로 연결 first = null 뒤 null 기존 kim/40 객체 second 경로가 남음 새 lee/50을 second 에 대입한 뒤 null 새 lee/50 객체 그림의 두 지역 변수에서는 경로 없음 ReachabilityTrace · 문장 실행 뒤의 지역 참조 모델 second = first 뒤: first 와 second 모두 같은 kim/40 객체를 가리킵니다. first = null 뒤: first 는 null 이고 second 는 기존 kim/40 객체를 계속 가리킵니다. 새 lee/50을 second 에 대입한 뒤: first 는 null , second 는 새 객체를 가리킵니다. 두 지역 변수에서 기존 kim/40으로 가는 경로는 없습니다. kim/40과 lee/50은 각각 [email protected]=40 , [email protected]=50 인 서로 다른 Member입니다. 출력은 이 순서로 두 줄입니다. 마지막 행은 기존 객체에 대한 참조 경로 제거를 설명하며, 그 순간 실제 메모리가 회수됐다는 뜻은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
별칭 생성

first와 second가 Kim 40을 가리킵니다.

  • 지역 경로: 2개
  • 객체: Kim 40
  • 참조 복사는 같은 객체의 도달 경로를 늘립니다.
첫 경로 제거

first=null 뒤 second만 Kim을 가리킵니다.

  • first: null
  • second: Kim 40
  • 경로 하나 제거로 객체가 즉시 도달 불가가 되지는 않습니다.
남은 경로 교체

second에 새 Lee 50 참조를 대입합니다.

  • second: Lee 50
  • Kim 지역 경로: 없음
  • 도달 경로 없음과 즉시 회수 관측을 구별합니다.

참조 재대입 뒤 남는 Member 도달 경로

first=null 후 second는 Kim을 유지하고 second에 Lee를 대입한 뒤 Kim의 지역 도달 경로가 사라집니다. GC 즉시 회수는 관측하지 않고 참조 연결의 세 상태를 추적합니다.

참조 재대입 뒤 남는 Member 도달 경로

ReachabilityTrace의 별칭 생성 후, first를 null로 바꾼 후, second를 새 Member로 바꾼 후의 지역 참조 경로를 비교한다. GC 실행 시점을 측정한 표가 아니다.

참조를 재대입하면 기존 객체로 가는 경로가 줄어든다

first를 null로 바꿔도 second 경로가 남고, second의 새 객체 대입으로 그 경로도 사라집니다.

ReachabilityTrace · 각 문장 실행 뒤의 지역 참조 모델
시점firstsecond기존 kim/40으로 가는 경로
second = first 뒤kim/40 객체같은 kim/40 객체두 변수에서 같은 객체로 연결
first = null 뒤null기존 kim/40 객체second 경로가 남음
새 lee/50을 second에 대입한 뒤null새 lee/50 객체그림의 두 지역 변수에서는 경로 없음
참조를 재대입하면 기존 객체로 가는 경로가 줄어든다 별칭생성first/second→kim40;first=null후second→kim40만;second새대입후second→lee50,kim으로의두지역참조없음. 화살표객체참조,GC시점미측정. 별칭 생성 뒤 지역 참조 변수 first → kim second → kim Member 하나 [email protected] age = 40 first = null 뒤 지역 참조 변수 first = null second → kim Member 하나 [email protected] age = 40 second 새 객체 대입 뒤 지역 참조 변수 first = null second → lee Member 하나 [email protected] age = 50 마지막 상태: kim 객체로 가는 지역 참조 없음 GC 대상 관계 설명 · 즉시 회수 관찰이 아님
참조를 재대입하면 기존 객체로 가는 경로가 줄어든다 별칭생성first/second→kim40;first=null후second→kim40만;second새대입후second→lee50,kim으로의두지역참조없음. 화살표객체참조,GC시점미측정. 별칭 생성 뒤 지역 참조 변수 first → kim second → kim Member 하나 [email protected] age = 40 first = null 뒤 지역 참조 변수 first = null second → kim Member 하나 [email protected] age = 40 second 새 객체 대입 뒤 지역 참조 변수 first = null second → lee Member 하나 [email protected] age = 50 kim 객체로 가는 경로 없음 변수 재대입: 실선 경로 변경 회수 시점은 표시하지 않음

ReachabilityTrace · 문장 실행 뒤의 지역 참조 모델

  1. second = first 뒤: first와 second 모두 같은 kim/40 객체를 가리킵니다.
  2. first = null 뒤: first는 null이고 second는 기존 kim/40 객체를 계속 가리킵니다.
  3. 새 lee/50을 second에 대입한 뒤: first는 null, second는 새 객체를 가리킵니다. 두 지역 변수에서 기존 kim/40으로 가는 경로는 없습니다.

kim/40과 lee/50은 각각 [email protected]=40, [email protected]=50인 서로 다른 Member입니다. 출력은 이 순서로 두 줄입니다. 마지막 행은 기존 객체에 대한 참조 경로 제거를 설명하며, 그 순간 실제 메모리가 회수됐다는 뜻은 아닙니다.

이 예의 조건

  • 지역 도달 관계 설명이며 GC 시점을 측정하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2별칭 생성 first/second→kim 40, first=null 후 second→kim 40만 남고, second에 새로 대입한 후 second→lee 50이 되며 kim으로의 두 지역 참조는 없다. 화살표는 객체 참조이며 GC 시점은 측정하지 않는다.
원 도표의 설명과 주석
구분내용
설명별칭 생성 first/second→kim 40, first=null 후 second→kim 40만 남고, second에 새로 대입한 후 second→lee 50이 되며 kim으로의 두 지역 참조는 없다. 화살표는 객체 참조이며 GC 시점은 측정하지 않는다.
전체 읽기 정보참조 재대입 뒤 남는 Member 도달 경로 참조 재대입 뒤 남는 Member 도달 경로 ReachabilityTrace의 별칭 생성 후, first를 null로 바꾼 후, second를 새 Member로 바꾼 후의 지역 참조 경로를 비교한다. GC 실행 시점을 측정한 표가 아니다. 참조를 재대입하면 기존 객체로 가는 경로가 줄어든다 first를 null로 바꿔도 second 경로가 남고, second의 새 객체 대입으로 그 경로도 사라집니다. ReachabilityTrace · 각 문장 실행 뒤의 지역 참조 모델 시점 first second 기존 kim/40으로 가는 경로 second = first 뒤 kim/40 객체 같은 kim/40 객체 두 변수에서 같은 객체로 연결 first = null 뒤 null 기존 kim/40 객체 second 경로가 남음 새 lee/50을 second 에 대입한 뒤 null 새 lee/50 객체 그림의 두 지역 변수에서는 경로 없음 ReachabilityTrace · 문장 실행 뒤의 지역 참조 모델 second = first 뒤: first 와 second 모두 같은 kim/40 객체를 가리킵니다. first = null 뒤: first 는 null 이고 second 는 기존 kim/40 객체를 계속 가리킵니다. 새 lee/50을 second 에 대입한 뒤: first 는 null , second 는 새 객체를 가리킵니다. 두 지역 변수에서 기존 kim/40으로 가는 경로는 없습니다. kim/40과 lee/50은 각각 [email protected]=40 , [email protected]=50 인 서로 다른 Member입니다. 출력은 이 순서로 두 줄입니다. 마지막 행은 기존 객체에 대한 참조 경로 제거를 설명하며, 그 순간 실제 메모리가 회수됐다는 뜻은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
별칭 생성

first와 second가 Kim 40을 가리킵니다.

  • 지역 경로: 2개
  • 객체: Kim 40
  • 참조 복사는 같은 객체의 도달 경로를 늘립니다.
첫 경로 제거

first=null 뒤 second만 Kim을 가리킵니다.

  • first: null
  • second: Kim 40
  • 경로 하나 제거로 객체가 즉시 도달 불가가 되지는 않습니다.
남은 경로 교체

second에 새 Lee 50 참조를 대입합니다.

  • second: Lee 50
  • Kim 지역 경로: 없음
  • 도달 경로 없음과 즉시 회수 관측을 구별합니다.

System.gc()를 호출해도 특정 객체가 즉시 회수된다는 보장은 없습니다.

파일이나 네트워크 연결 같은 자원의 종료를 GC에 맡기지 않고 try-with-resources 같은 명시적 수명 관리를 사용합니다.


MemberRegistry 실행 메모리

src/MemberRegistryMemoryModel.java
public final class MemberRegistryMemoryModel {
    public static void main(String[] args) {
        MemberRegistry registry = createRegistry();
        registry.add("[email protected]", 40);
        registry.add("[email protected]", 50);
        printTotal(registry);
    }

    private static MemberRegistry createRegistry() {
        return new MemberRegistry(2);
    }

    private static void printTotal(MemberRegistry registry) {
        System.out.println("total=" + registry.totalAge());
    }

    private static final class MemberRegistry {
        private final Member[] members;
        private int size;

        MemberRegistry(int capacity) {
            members = new Member[capacity];
        }

        void add(String email, int age) {
            members[size++] = new Member(email, age);
        }

        int totalAge() {
            int total = 0;
            for (int index = 0; index < size; index++) {
                total += members[index].age();
            }
            return total;
        }
    }

    private record Member(String email, int age) {}
}
total=90

MemberRegistry 객체, Member 배열, 두 Member 객체는 힙에 생성됩니다.

여기서 new Member[2]는 배열 한 개를 만들며 두 칸은 처음에 null입니다. 회원 두 명은 이후 두 번의 new Member(...)가 따로 만듭니다. 따라서 그림에서 저장소 1개·배열 1개·회원 2개를 구분합니다. 문자열이나 JVM 내부 객체까지 세는 전체 할당 측정값은 아닙니다. 이 예제의 add에는 용량과 나이 검사가 없으므로, 정해진 정상 추가 두 번의 메모리 관계를 관찰하는 코드로 사용합니다.

main의 registry와 printTotal의 registry 매개변수는 스택 프레임에 있는 참조 변수입니다.

totalAge의 total과 index도 해당 호출 프레임의 지역 변수입니다.

클래스 정의와 메서드 실행 코드는 메서드 영역의 클래스 정보와 연결됩니다.

MemberRegistry 객체가 배열을 가리키고 배열이 각 Member를 가리키므로 main의 registry 하나에서 전체 객체 그래프에 도달할 수 있습니다.

registry 참조에서 저장소·배열·두 Member에 도달한다

MemberRegistryMemoryModel에서 두 회원 추가 후 printTotal 진입 직후 main과 printTotal의 registry가 같은 저장소에 도달하고 저장소가 길이 2 배열과 두 Member에 이어지는 참조를 보관한다.

하나의 registry 참조에서 저장소와 회원에 도달한다

같은 저장소가 배열을 참조하고, 배열의 두 칸이 서로 다른 회원을 참조합니다.

MemberRegistryMemoryModel · 두 회원 추가 뒤 printTotal 진입 직후

하나의 저장소로 이어지는 두 지역 참조와 객체 그래프 main과 printTotal의 registry는 같은 size 2 저장소를 참조한다. 저장소의 members는 길이 2 배열을 가리키고 0번은 kim 40, 1번은 lee 50 Member를 가리킨다. 호출 프레임의 지역 참조 힙의 객체 참조 관계 · 개념 모델 main 프레임 registry printTotal 프레임 registry 같은 MemberRegistry size = 2 members Member[] · length = 2 [0] [1] Member [email protected] age = 40 Member [email protected] age = 50
하나의 registry 참조에서 저장소와 회원에 도달한다 main.registry와printTotal.registry→같은size2Registry→동일길이2Member배열;slot0→kim40,slot1→별개lee50. 실선객체참조이며문자열/내부JVM객체생략. 호출 프레임의 지역 참조 main 프레임 registry printTotal 프레임 registry 힙 · 같은 저장소 하나 MemberRegistry size = 2 members 배열 참조 members 필드의 참조 Member[] · 길이 2 [0] [1] 첫 Member [email protected] age = 40 둘째 Member [email protected] age = 50 배열 두 칸 → 다른 회원 객체 합계 40 + 50 = 90
두 지역 참조
main.registry와 printTotal의 registry 매개변수는 같은 MemberRegistry를 가리킵니다.
MemberRegistry 객체
size=2이며 members 필드가 길이 2의 Member 배열을 가리킵니다.
배열의 두 슬롯
[0]은 [email protected]·40세 Member, [1]은 별개의 [email protected]·50세 Member를 가리킵니다.

createRegistry() 호출은 이미 끝났지만 반환한 저장소에 계속 도달합니다. 이어서 totalAge()는 40+50을 합쳐 total=90을 출력하게 합니다. 저장소·배열·Member 두 개만 표시했으며 문자열과 JVM 내부 객체는 생략했습니다.


연습 문제

main이 Counter를 만들고 twice(counter)를 호출하며, twice가 increment(counter)를 두 번 호출하게 하세요.

각 메서드 시작과 종료, 최종 count를 출력해 스택 반환 순서를 확인합니다.

해설 보기
src/CounterStackTrace.java
public final class CounterStackTrace {
    public static void main(String[] args) {
        Counter counter = new Counter();
        System.out.println("main:start");
        twice(counter);
        System.out.println("main:count=" + counter.value);
    }

    private static void twice(Counter counter) {
        System.out.println("twice:start");
        increment(counter);
        increment(counter);
        System.out.println("twice:end");
    }

    private static void increment(Counter counter) {
        System.out.println("increment");
        counter.value++;
    }

    private static final class Counter {
        int value;
    }
}
main:start
twice:start
increment
increment
twice:end
main:count=2

세 메서드의 counter 지역 변수는 서로 다르지만 같은 힙 객체를 가리킵니다.

increment 프레임이 사라져도 객체 필드 변경은 남습니다.

위 해설의 increment 한 줄 출력은 호출 횟수만 보여 줍니다. 문제에서 요구한 각 메서드의 시작·종료까지 확인하려면 increment를 다음으로 바꾸고, main의 마지막 출력 뒤에 System.out.println("main:end");를 덧붙이세요. twice와 Counter는 그대로 둡니다.

CounterStackTrace의 increment만 바꿀 부분
private static void increment(Counter counter) {
    System.out.println("increment:start");
    counter.value++;
    System.out.println("increment:end");
}

이때 출력 순서는 main:start → twice:start → increment:start → increment:end → increment:start → increment:end → twice:end → main:count=2 → main:end입니다. 두 increment 프레임은 동시에 쌓이는 것이 아니라 첫 호출이 반환한 뒤 두 번째가 만들어집니다. 각 종료 출력은 메서드의 마지막 문장이 실행됐다는 표식이며, 실제 반환으로 프레임이 제거되는 것은 그 직후입니다.

독립적으로 확인해 보세요. twice 안에서 첫 increment 뒤 지역 변수 counter = new Counter();를 넣었다고 가정해 보세요. 두 번째 증가는 새 객체에 적용되고 main의 원래 Counter 값은 1입니다. 지역 참조 재대입이 호출자의 참조까지 바꾸지 않는다는 점으로 이유를 설명할 수 있습니다.