본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
6장 : 메모리·정적 멤버·상수

스택·힙·메서드 영역

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

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

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

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

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

정상 호출 스택의 최소 예제
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);
}

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

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


메모리 영역의 역할

메서드 영역

클래스의 실행 코드와 클래스 정보, 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("kim@example.com", 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:kim@example.com=41
birthday:end
main:kim@example.com=41
main:end

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

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

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

메서드 호출 순서는 maincelebrateBirthdayinspect이고 반환은 inspectcelebrateBirthdaymain입니다.

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


도달 가능성과 객체 수명

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

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

        second = new Member("lee@example.com", 50);
        System.out.println(second.describe());
    }

    private record Member(String email, int age) {
        String describe() {
            return email + "=" + age;
        }
    }
}
kim@example.com=40
lee@example.com=50

firstnull로 바꿔도 second가 기존 kim@example.com 회원 객체를 가리키므로 도달 가능합니다.

second에 새 lee@example.com 회원 객체를 대입하면 기존 kim@example.com 회원 객체를 가리키는 지역 참조가 없어집니다.

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

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("kim@example.com", 40);
        registry.add("lee@example.com", 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 객체는 힙에 생성됩니다.

mainregistryprintTotalregistry 매개변수는 스택 프레임에 있는 참조 변수입니다.

totalAgetotalindex도 해당 호출 프레임의 지역 변수입니다.

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

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


연습 문제

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 프레임이 사라져도 객체 필드 변경은 남습니다.