JVM 메모리 구조: 스택, 힙, 메서드 영역
메서드 호출이 쌓이는 스택, 객체가 만들어지는 힙, 클래스 정보가 담기는 메서드 영역이 각각 무슨 일을 하는지 예제를 따라가며 이해합니다.
지금까지 예제를 실행하면서 몇 가지 현상을 만났습니다. 메서드 안에서 선언한 지역 변수는 호출이 끝나면 더 쓸 수 없는데, 메서드에 넘긴 객체의 필드를 바꾸면 그 변경은 호출이 끝난 뒤에도 남습니다. 또 객체를 가리키던 변수를 모두 null로 바꾸면 그 객체에는 다시 접근할 방법이 사라집니다. 이 현상들은 따로 외울 규칙이 아닙니다. 자바가 실행 중에 메모리를 어떻게 나누어 쓰는지 알면 한 가지 그림으로 모두 설명됩니다.
이 글에서는 JVM(Java Virtual Machine)이 프로그램을 실행할 때 쓰는 세 가지 메모리 영역, 즉 스택(stack), 힙(heap), 메서드 영역(method area)을 다룹니다. 여기서 말하는 영역은 메모리 주소를 외우기 위한 지도가 아니라, 실행 결과가 왜 그렇게 나오는지 설명하기 위한 모델입니다. return과 호출 스택에서 본 호출 스택과 메서드 호출과 값 전달에서 본 참조값 복사를 떠올리며 읽으면 따라오기 쉽습니다.
메서드를 호출할 때마다 쌓이는 스택 프레임
먼저 메서드 세 개가 차례로 호출되는 프로그램을 보겠습니다. 각 메서드가 시작할 때와 끝날 때 한 줄씩 출력하므로, 출력 순서를 보면 어느 메서드가 언제 실행 중인지 알 수 있습니다.
public final class CallOrder {
public static void main(String[] args) {
System.out.println("main:start");
first();
System.out.println("main:end");
}
private static void first() {
System.out.println("first:start");
second();
System.out.println("first:end");
}
private static void second() {
System.out.println("second");
}
}main:start
first:start
second
first:end
main:endmain이 first를 호출하면 main은 그 자리에서 멈추고 first가 끝나기를 기다립니다. first가 다시 second를 호출하면 이번에는 first가 기다립니다. 그래서 second가 끝난 뒤에야 first:end가, first가 끝난 뒤에야 main:end가 출력됩니다. 가장 나중에 시작한 메서드가 가장 먼저 끝나는 것입니다.
JVM은 이 순서를 호출 스택(call stack)으로 관리합니다. 메서드를 호출할 때마다 그 호출 하나를 위한 공간인 스택 프레임(stack frame)을 스택 맨 위에 올리고, 메서드가 끝나면 맨 위의 프레임을 걷어 냅니다. 나중에 들어간 것이 먼저 나오므로 이런 구조를 후입선출(LIFO, last in first out)이라고 합니다. 위 프로그램의 스택은 다음처럼 변합니다.
| 시점 | 스택(왼쪽이 맨 위) |
|---|---|
main 시작 | main |
first 호출 | first, main |
second 호출 | second, first, main |
second 반환 | first, main |
first 반환 | main |
main 반환 | 비어 있음 |
엄밀히 말하면 System.out.println도 메서드이므로 출력할 때마다 그 프레임이 잠깐 올라갔다가 내려갑니다. 표에는 우리가 직접 작성한 메서드만 적었습니다.
프레임에는 그 호출에 속한 매개변수와 지역 변수가 들어 있고, 호출이 끝난 뒤 호출한 메서드의 어디서부터 실행을 이어 갈지도 함께 관리됩니다. 지역 변수가 메서드가 끝나면 사라지는 이유가 여기에 있습니다. 지역 변수는 프레임 안에 있으므로 프레임이 걷히면 함께 없어집니다. 같은 메서드를 두 번 호출하면 프레임도 두 개가 따로 만들어지므로, 두 호출의 지역 변수는 이름이 같아도 서로 다른 변수입니다.
스택은 스레드(thread)마다 하나씩 있습니다. 스레드는 18장에서 다룹니다. 지금까지 작성한 코드는 모두 main 스레드 하나에서 실행되므로, 스택도 하나만 생각하면 됩니다.
끝나지 않는 재귀와 StackOverflowError
프레임이 쌓이기만 하고 걷히지 않으면 어떻게 될까요? 자기 자신을 다시 호출하는 재귀(recursion) 메서드에서 멈출 조건을 빠뜨리면 바로 그런 일이 일어납니다. 재귀로 문제를 나누어 푸는 방법 자체는 자료구조·알고리즘 교재의 재귀·기저 사례·호출 스택에서 자세히 다룹니다.
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);
}
}실행 결과의 앞부분은 다음과 같습니다. 오류 줄 아래에는 recurse를 호출한 위치를 가리키는 at 줄이 아주 많이 반복되므로 앞부분만 옮겼습니다.
depth=1
depth=2
depth=3
Exception in thread "main" java.lang.StackOverflowErrorrecurse는 호출될 때마다 자기 depth 값을 담은 새 프레임을 만들고, 그 안에서 다시 recurse를 호출합니다. 어떤 호출도 끝나지 않으므로 프레임은 하나도 걷히지 않고 계속 쌓입니다. 그런데 스택에 쓸 수 있는 메모리에는 한도가 있어서, 새 프레임을 올릴 자리가 없어지는 순간 JVM이 StackOverflowError를 던집니다. 출력이 세 줄뿐인 것은 depth가 3 이하일 때만 출력하게 했기 때문이고, 실제로는 그보다 훨씬 많은 호출이 쌓인 뒤에 오류가 납니다.
오류 메시지 첫머리의 Exception in thread "main"은 아무도 처리하지 않은 오류가 스레드를 끝냈을 때 붙는 공통 문구이고, 실제 오류의 종류는 그 뒤의 java.lang.StackOverflowError입니다. 그 아래로는 at RecursiveStackOverflow.recurse(RecursiveStackOverflow.java:10) 같은 줄이 아주 많이 이어집니다. 이 목록을 스택 트레이스(stack trace)라고 하며, 오류가 났을 때 스택에 쌓여 있던 프레임을 맨 위부터 한 줄에 하나씩 보여 줍니다. 10번 줄은 recurse(depth + 1);이 있는 줄이고, 같은 메서드의 같은 줄이 끝없이 반복된다는 것은 그 메서드가 끝나지 않고 자기 자신을 계속 호출했다는 뜻입니다. 맨 위 한 줄만은 스택이 정확히 어느 지점에서 바닥났는지에 따라 메서드 본문의 첫 줄인 7번으로 나오기도 합니다.
StackOverflowError는 힙에 객체가 너무 많다는 뜻이 아닙니다. 위 코드는 객체를 쌓아 두지 않는데도 이 오류가 나듯이, 이 오류는 호출 깊이가 스택의 한도를 넘었다는 신호입니다. 그래서 이 오류를 보면 먼저 멈추지 않는 재귀나 지나치게 깊은 호출이 있는지 찾아야 합니다. 클래스 이름이 Exception이 아니라 Error로 끝나는 것은, 일반적인 프로그램이 잡아서 복구하려고 하면 안 되는 심각한 문제라는 표시입니다. 오류와 예외의 종류는 예외 계층과 검사 예외에서 자세히 다룹니다.
멈출 조건이 있는 재귀
올바른 재귀는 더 이상 자기 자신을 호출하지 않는 종료 조건(base case)을 먼저 확인합니다. 다음 sumTo는 1부터 value까지의 합을 구합니다.
private static int sumTo(int value) {
if (value <= 0) return 0;
return value + sumTo(value - 1);
}sumTo(3)을 호출하면 value가 3, 2, 1, 0인 프레임이 차례로 쌓입니다. value가 0보다 큰 호출은 value + sumTo(value - 1)을 계산하는 도중에 멈춰서, 위에 올라간 호출이 결과를 돌려주기를 기다립니다. 그러다 value가 0인 호출이 종료 조건에 걸려 0을 반환하면, 그때부터 프레임이 맨 위부터 하나씩 걷히며 결과가 아래로 전달됩니다.
| 단계 | 일어나는 일 | 남아 있는 sumTo 프레임의 value(왼쪽이 맨 위) |
|---|---|---|
| 1 | sumTo(3) 호출 | 3 |
| 2 | sumTo(2) 호출 | 2, 3 |
| 3 | sumTo(1) 호출 | 1, 2, 3 |
| 4 | sumTo(0) 호출 | 0, 1, 2, 3 |
| 5 | sumTo(0)이 0 반환 | 1, 2, 3 |
| 6 | sumTo(1)이 1 + 0 = 1 반환 | 2, 3 |
| 7 | sumTo(2)가 2 + 1 = 3 반환 | 3 |
| 8 | sumTo(3)이 3 + 3 = 6 반환 | 없음 |
다만 종료 조건이 있어도 호출 깊이는 입력에 비례합니다. sumTo(100000)을 계산하려면 프레임이 10만 개 넘게 쌓여야 하므로, 기본 설정의 JVM에서는 결과가 나오기 전에 StackOverflowError가 날 수 있습니다. 이런 계산은 반복문으로 바꾸면 프레임 하나 안에서 끝나므로, 입력이 커져도 스택 깊이가 늘지 않습니다.
private static long sumToLoop(int value) {
long total = 0;
for (int number = 1; number <= value; number++) {
total += number;
}
return total;
}이 버전은 합을 long에 모은다는 점도 다릅니다. 스택과는 별개의 문제지만, int로 합을 구하면 value가 65536 이상일 때 합이 int의 최댓값 2147483647을 넘어 엉뚱한 값이 됩니다. sumToLoop(100000)은 스택 걱정 없이 5000050000을 돌려줍니다.
스택, 힙, 메서드 영역이 하는 일
지역 변수가 프레임 안에 있다면, new로 만든 객체는 어디에 있을까요? 객체도 프레임 안에 있다면 객체를 만든 메서드가 끝날 때 함께 사라져야 합니다. 하지만 메서드 호출과 값 전달에서 본 팩터리 메서드처럼, 메서드 안에서 만든 객체를 반환해 호출한 쪽에서 계속 쓸 수 있습니다. 객체는 프레임과는 다른 곳에 있다는 뜻입니다. JVM은 실행 중에 쓰는 메모리를 역할에 따라 나누어 관리하며, 이 장에서는 다음 세 영역으로 이루어진 모델을 씁니다.
스택은 메서드 호출을 위한 공간입니다. 앞에서 본 대로 메서드를 호출할 때마다 프레임이 하나씩 생기고, 호출이 끝나면 사라집니다. 매개변수와 지역 변수는 모두 프레임 안에 있습니다. 지역 변수가 참조형이라면 프레임에 들어 있는 것은 객체 자체가 아니라 객체를 가리키는 참조값입니다.
힙은 객체가 만들어지는 공간입니다. new로 만든 객체와 배열은 모두 힙에 생깁니다. 힙은 특정 호출에 속하지 않으므로, 객체를 만든 메서드가 끝나도 객체는 그대로 남고 여러 프레임의 참조 변수가 같은 객체를 함께 가리킬 수도 있습니다. 대신 자바에는 힙의 객체를 직접 지우는 문법이 없습니다. 어떤 참조로도 닿을 수 없게 된 객체의 메모리는 가비지 컬렉터(garbage collector, GC)가 대신 회수하며, 그 시점은 뒤에서 다시 살펴봅니다.
메서드 영역은 클래스 정보가 있는 공간입니다. 클래스의 이름, 필드와 메서드의 목록, 메서드의 실행 코드처럼 클래스마다 한 벌만 있으면 되는 정보가 여기에 들어갑니다. 그래서 같은 클래스의 객체를 100개 만들어도 메서드 코드가 객체마다 복사되지는 않습니다. 객체 100개는 각자 자기 필드 값만 가지고, 메서드를 실행할 때는 메서드 영역에 있는 한 벌의 코드를 함께 씁니다.
| 영역 | 들어 있는 것 | 언제 생기고 없어지나 | 누가 함께 쓰나 |
|---|---|---|---|
| 스택 | 프레임(매개변수, 지역 변수) | 프레임은 호출할 때 생기고 호출이 끝나면 없어짐 | 스레드마다 따로 |
| 힙 | 객체와 배열 | 객체는 new로 생기고, 닿을 수 없게 된 뒤 GC가 회수 | 모든 스레드 |
| 메서드 영역 | 클래스 정보, 메서드 코드 | 클래스 정보는 클래스를 불러올 때 생김 | 모든 스레드 |
다음 글에서 다룰 static 변수는 객체가 아니라 클래스에 속하는 값이라서, 이 모델에서는 메서드 영역 쪽에 그립니다. 다만 이것은 '클래스에 하나만 있다'는 성질을 나타내는 그림일 뿐이고, 값이 실제 메모리의 어디에 놓이는지는 JVM 구현에 따라 다릅니다. 이 차이는 글 끝의 '더 깊이' 단락에서 설명합니다.
흔히 '기본형은 스택에, 객체는 힙에 저장된다'고 줄여 말하는데, 이 말은 절반만 맞습니다. 기본형 지역 변수는 프레임 안에 있지만, 객체의 필드는 기본형이라도 객체와 함께 힙에 있습니다. 예를 들어 Member 객체의 int age 필드는 힙에 있는 Member 객체 안에 들어 있고, new int[3]으로 만든 배열의 칸도 모두 힙에 있습니다. 어느 영역에 있는지를 정하는 것은 타입이 아니라, 그 변수가 지역 변수인지 아니면 객체의 필드나 배열의 칸인지입니다.
지역 변수는 셋, 객체는 하나
이제 세 영역이 함께 움직이는 모습을 회원 객체로 따라가 보겠습니다. 다음 프로그램은 main에서 만든 Member 객체를 celebrateBirthday에 넘기고, celebrateBirthday는 그 객체를 다시 inspect에 넘깁니다. 실행하기 전에, 생일로 늘어난 나이가 main으로 돌아온 뒤에도 남아 있을지 예측해 보세요.
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출력 순서는 앞의 CallOrder와 같은 원리를 따릅니다. 호출은 main, celebrateBirthday, inspect 순으로 쌓이고, 반환은 inspect, celebrateBirthday, main 순으로 일어납니다. 그래서 birthday:end는 inspect: 줄 다음에, main:end는 맨 마지막에 나옵니다.
더 눈여겨볼 것은 나이입니다. main 프레임의 member, celebrateBirthday 프레임의 parameter, inspect 프레임의 parameter는 서로 다른 세 지역 변수입니다. 하지만 메서드를 호출할 때 복사된 것은 객체가 아니라 참조값이므로, 세 변수는 모두 힙에 있는 같은 Member 객체 하나를 가리킵니다. parameter.celebrateBirthday()는 그 참조를 따라가 객체의 age를 40에서 41로 바꿉니다. 그래서 inspect에서도, 두 프레임이 모두 걷힌 뒤의 main에서도 41이 보입니다.
inspect 진입 시 세 프레임이 같은 Member를 참조한다
main→celebrateBirthday→inspect 프레임의 다른 지역 변수가 같은 Member를 가리켜 age 40→41을 관찰합니다. 고정 호출 역할과 현재 프레임/공유 필드 전환을 함께 보여 줄 수 있습니다.
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의 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 객체 참조를 생일 함수에 넘깁니다. |
|
|
| 생일 반영 celebrateBirthday의 parameter로 age를 41로 바꿉니다. |
|
|
| inspect 프레임 inspect가 추가된 세 프레임 모두 같은 객체를 참조합니다. |
|
|
inspect 진입 시 세 프레임이 같은 Member를 참조한다
main→celebrateBirthday→inspect 프레임의 다른 지역 변수가 같은 Member를 가리켜 age 40→41을 관찰합니다. 고정 호출 역할과 현재 프레임/공유 필드 전환을 함께 보여 줄 수 있습니다.
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의 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 객체 참조를 생일 함수에 넘깁니다. |
|
|
| 생일 반영 celebrateBirthday의 parameter로 age를 41로 바꿉니다. |
|
|
| inspect 프레임 inspect가 추가된 세 프레임 모두 같은 객체를 참조합니다. |
|
|
반대로 메서드 안에서 parameter = null;처럼 매개변수 자체에 다른 값을 대입하면 결과가 다릅니다. 이 대입은 그 호출의 프레임에 있는 지역 변수 하나만 바꾸므로, 호출한 쪽의 member는 여전히 원래 객체를 가리킵니다. '참조를 따라가 객체를 바꾸는 것'과 '참조 변수에 다른 값을 넣는 것'을 구분하면, 메서드가 호출한 쪽에 영향을 주는 경우와 주지 않는 경우를 모두 설명할 수 있습니다.
inspect와 celebrateBirthday의 프레임이 사라진 뒤에도 객체를 계속 쓸 수 있는 것은 main 프레임의 member가 아직 그 객체를 가리키고 있기 때문입니다. 그렇다면 객체를 가리키는 변수가 하나도 남지 않으면 어떻게 될까요?
참조가 모두 끊긴 객체와 가비지 컬렉션
다음 프로그램은 한 객체를 가리키는 참조를 하나씩 끊어 봅니다. Member는 캡슐화로 회원 저장소 완성하기에서 소개한 record로 선언했습니다.
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;
}
}
}second = first는 객체를 복사하지 않고 참조값만 복사하므로, 두 변수가 같은 [email protected] 객체를 가리킵니다. 그다음 first = null로 한쪽 참조를 끊어도 second가 아직 그 객체를 가리키므로 첫 번째 출력이 정상적으로 나옵니다. 마지막으로 second에 새 [email protected] 객체를 대입하면, 처음 만든 [email protected] 객체를 가리키는 변수는 하나도 남지 않습니다.
참조 재대입 뒤 남는 Member 도달 경로
first=null 후 second는 Kim을 유지하고 second에 Lee를 대입한 뒤 Kim의 지역 도달 경로가 사라집니다. GC 즉시 회수는 관측하지 않고 참조 연결의 세 상태를 추적합니다.
ReachabilityTrace의 별칭 생성 후, first를 null로 바꾼 후, second를 새 Member로 바꾼 후의 지역 참조 경로를 비교한다. GC 실행 시점을 측정한 표가 아니다.
참조를 재대입하면 기존 객체로 가는 경로가 줄어든다
first를 null로 바꿔도 second 경로가 남고, second의 새 객체 대입으로 그 경로도 사라집니다.
| 시점 | 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입니다. 출력은 이 순서로 두 줄입니다. 마지막 행은 기존 객체에 대한 참조 경로 제거를 설명하며, 그 순간 실제 메모리가 회수됐다는 뜻은 아닙니다.
이 예의 조건
- 지역 도달 관계 설명이며 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을 가리킵니다. |
|
|
| 첫 경로 제거 first=null 뒤 second만 Kim을 가리킵니다. |
|
|
| 남은 경로 교체 second에 새 Lee 50 참조를 대입합니다. |
|
|
참조 재대입 뒤 남는 Member 도달 경로
first=null 후 second는 Kim을 유지하고 second에 Lee를 대입한 뒤 Kim의 지역 도달 경로가 사라집니다. GC 즉시 회수는 관측하지 않고 참조 연결의 세 상태를 추적합니다.
ReachabilityTrace의 별칭 생성 후, first를 null로 바꾼 후, second를 새 Member로 바꾼 후의 지역 참조 경로를 비교한다. GC 실행 시점을 측정한 표가 아니다.
참조를 재대입하면 기존 객체로 가는 경로가 줄어든다
first를 null로 바꿔도 second 경로가 남고, second의 새 객체 대입으로 그 경로도 사라집니다.
| 시점 | 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입니다. 출력은 이 순서로 두 줄입니다. 마지막 행은 기존 객체에 대한 참조 경로 제거를 설명하며, 그 순간 실제 메모리가 회수됐다는 뜻은 아닙니다.
이 예의 조건
- 지역 도달 관계 설명이며 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을 가리킵니다. |
|
|
| 첫 경로 제거 first=null 뒤 second만 Kim을 가리킵니다. |
|
|
| 남은 경로 교체 second에 새 Lee 50 참조를 대입합니다. |
|
|
이렇게 어떤 참조로도 닿을 수 없게 된 객체를 도달할 수 없는(unreachable) 객체라고 합니다. 어떤 객체에 아직 닿을 수 있는지는 스택에 프레임이 남아 있는, 즉 아직 끝나지 않은 메서드 호출의 지역 변수나 static 변수처럼 프로그램이 곧바로 쓸 수 있는 참조에서 출발해, 참조를 차례로 따라가 보면 알 수 있습니다. 가비지 컬렉터는 어디서도 닿지 않는 객체를 회수 대상으로 봅니다. 프로그램도 이런 객체를 다시 손에 넣을 방법이 없으므로, 메모리를 회수하더라도 프로그램의 동작은 달라지지 않습니다.
여기서 '회수 대상이 되었다'와 '회수되었다'는 다른 말입니다. 가비지 컬렉터가 언제 실행될지는 JVM이 정하므로, 도달할 수 없게 된 객체가 그 즉시 회수된다는 보장은 없습니다. System.gc()를 호출해도 마찬가지입니다. 이 메서드는 JVM에 가비지 컬렉션을 하도록 요청할 뿐이어서, 특정 객체가 그 시점에 회수된다고 기대하고 코드를 짜면 안 됩니다.
같은 이유로, 파일이나 네트워크 연결처럼 다 쓰면 반드시 닫아야 하는 자원은 GC에 맡기지 않습니다. 객체가 언제 회수될지 모르니, 회수될 때 닫히기를 기대하면 언제 닫힐지도 알 수 없기 때문입니다. 이런 자원은 try-with-resources와 공통 예외 처리에서 배울 try-with-resources처럼 닫는 시점을 코드에 분명히 적는 방법으로 다룹니다.
참조 하나에서 이어지는 객체 그래프
실제 프로그램의 객체는 혼자 있지 않고 다른 객체를 필드로 가리킵니다. 다음 프로그램은 회원 저장소를 줄여서, 어떤 객체들이 생기고 서로 어떻게 연결되는지 보여 줍니다.
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이 프로그램에서 눈여겨볼 객체는 네 개입니다. createRegistry의 new MemberRegistry(2)가 저장소 객체를 만들고, 그 생성자의 new Member[capacity]가 길이 2인 배열을 만듭니다. 이 배열은 회원 객체가 아니라 회원을 가리킬 참조 두 칸이며, 처음에는 두 칸 모두 null입니다. 회원 객체 두 개는 add가 호출될 때마다 new Member(...)로 하나씩 만들어져 배열의 칸에 연결됩니다. 이메일 문자열 같은 다른 객체도 힙에 있지만, 여기서는 저장소의 구조만 보려고 세지 않았습니다.
스택 쪽을 보면 main의 registry와 printTotal의 registry는 서로 다른 프레임의 지역 변수지만, 같은 저장소 객체를 가리킵니다. totalAge의 total과 index도 totalAge가 호출될 때 생긴 프레임에 있다가 반환과 함께 사라집니다. createRegistry의 프레임은 이미 사라졌지만, 그 메서드가 만든 저장소 객체는 반환된 참조가 main의 registry에 담겼으므로 계속 쓰입니다. 그리고 MemberRegistry와 Member의 메서드 코드는 객체마다 따로 있지 않고 메서드 영역의 클래스 정보에 한 벌씩 있습니다.
MemberRegistryMemoryModel에서 두 회원 추가 후 printTotal 진입 직후 main과 printTotal의 registry가 같은 저장소에 도달하고 저장소가 길이 2 배열과 두 Member에 이어지는 참조를 보관한다.
하나의 registry 참조에서 저장소와 회원에 도달한다
같은 저장소가 배열을 참조하고, 배열의 두 칸이 서로 다른 회원을 참조합니다.
MemberRegistryMemoryModel · 두 회원 추가 뒤 printTotal 진입 직후
- 두 지역 참조
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의 registry 하나에서 출발하면 저장소, 배열, 두 회원 객체에 모두 닿을 수 있습니다. 이렇게 참조로 이어진 객체들의 묶음을 객체 그래프(object graph)라고 합니다. 가비지 컬렉터가 도달할 수 있는지 판단하는 것도 이 그래프를 따라가는 일입니다. 그래서 main이 registry로 저장소를 계속 쓰는 동안에는 그래프 안의 객체도 모두 도달할 수 있으므로 회수 대상이 되지 않습니다. 반대로 저장소를 가리키는 참조가 모두 사라지면 네 객체가 한꺼번에 도달할 수 없게 됩니다.
이 예제의 add는 메모리 관계만 보여 주려고 용량 검사를 생략했습니다. 그래서 세 번째 회원을 추가하면 배열 범위를 벗어나 ArrayIndexOutOfBoundsException이 납니다. 실제 저장소라면 앞 장에서 만든 것처럼 추가하기 전에 남은 자리를 확인해야 합니다.
더 깊이: JVM 명세가 정한 것과 HotSpot의 실제 구현
지금까지 쓴 세 영역은 JVM 명세(The Java Virtual Machine Specification)가 정의한 런타임 데이터 영역(run-time data areas)을 단순하게 줄인 모델입니다. 명세는 스레드마다 하나씩 생기는 pc 레지스터와 JVM 스택, 네이티브 메서드를 지원하는 구현이 보통 스레드마다 두는 네이티브 메서드 스택, 그리고 모든 스레드가 공유하는 힙과 메서드 영역을 정의합니다. 클래스마다 있는 런타임 상수 풀(run-time constant pool)은 메서드 영역 안에 할당됩니다. pc 레지스터는 스레드가 지금 실행 중인 명령의 위치를 담고, 네이티브 메서드 스택은 자바가 아닌 언어로 작성된 메서드를 실행할 때 쓰입니다. 프레임의 구성도 명세에 정해져 있는데, 지역 변수 배열과 피연산자 스택(operand stack), 그리고 현재 메서드가 속한 클래스의 런타임 상수 풀에 대한 참조입니다.
명세가 정하는 것은 각 영역의 역할과 오류 조건이지 메모리 배치가 아닙니다. JVM 스택만 해도 크기를 고정하든 늘려 가든 구현이 고를 수 있고, 프레임을 힙에 할당해도 된다고 적혀 있습니다. 대신 허용된 것보다 깊은 스택이 필요하면 StackOverflowError를, 스택을 늘리거나 새 스레드의 스택을 만들 메모리가 모자라면 OutOfMemoryError를 던져야 한다는 조건은 분명히 정합니다. 힙도 객체를 명시적으로 해제하지 않고 자동 메모리 관리로 회수한다는 점만 정할 뿐, 어떤 가비지 컬렉션 방식을 쓸지는 구현에 맡깁니다. 메서드 영역은 '논리적으로 힙의 일부'라고 하면서도 위치와 관리 방법은 정하지 않습니다.
그래서 이런 질문의 답은 구현마다 다릅니다. OpenJDK와 대부분의 JDK 배포판에 들어 있는 HotSpot JVM은 JDK 7까지 클래스 메타데이터를 PermGen(permanent generation)이라는, 가비지 컬렉터가 관리하는 별도 구역에 두었습니다. JDK 8에서 JEP 122로 PermGen이 없어지면서 클래스 메타데이터는 힙 밖의 네이티브 메모리인 Metaspace로 옮겨졌습니다. 그리고 현재의 HotSpot은 static 변수의 값과 intern된 문자열을 일반 Java 힙에 둡니다. 많은 책이 'static 변수는 메서드 영역에 있다'고 설명하는 것은 클래스 단위의 값이라는 개념 모델이나 예전 구현을 기준으로 한 말로 이해하면 됩니다.
스택의 한도와 객체 할당도 HotSpot의 세부 사항을 알면 더 정확히 읽힙니다. HotSpot에서 스레드 하나의 스택 크기는 -Xss 옵션으로 정하며, x86-64 Linux의 기본값은 1MB입니다. 앞의 RecursiveStackOverflow가 몇 번째 호출에서 멈추는지도 이 크기와 프레임 크기, JIT 컴파일 여부에 따라 달라지므로 특정 숫자를 기대하면 안 됩니다. 또 HotSpot의 JIT 컴파일러는 탈출 분석(escape analysis)으로 메서드 밖으로 빠져나가지 않는 객체를 찾아내, 힙 할당 자체를 없애기도 합니다. 이런 최적화는 프로그램이 관찰할 수 있는 결과를 바꾸지 않으므로, 코드의 동작을 설명할 때는 이 글의 모델을 그대로 써도 됩니다.
도달 가능성도 변수가 선언된 범위와 정확히 같지는 않습니다. Java 언어 명세는 더 이상 쓰이지 않을 지역 변수나 매개변수를 컴파일러가 미리 null로 만든 것처럼 다루어, 그 변수가 가리키던 객체를 더 일찍 회수할 수 있게 하는 최적화를 허용합니다. 그러니 '변수가 범위 안에 있으니 객체도 반드시 살아 있다'고 가정하는 코드는 피해야 합니다.
정리
- 메서드를 호출할 때마다 스택에 프레임이 하나 쌓이고, 반환하면 걷힙니다. 매개변수와 지역 변수는 프레임 안에 있으므로 호출이 끝나면 함께 사라집니다.
- 종료 조건 없는 재귀처럼 프레임이 걷히지 않고 쌓이기만 하면 스택 한도를 넘어
StackOverflowError가 납니다. 깊이가 입력에 비례하는 재귀는 반복문으로 바꾸는 것을 검토합니다. new로 만든 객체와 배열은 힙에 있습니다. 여러 프레임의 지역 변수가 같은 객체를 가리킬 수 있으므로, 참조를 따라 객체를 바꾸면 그 변경은 호출이 끝난 뒤에도 남습니다.- 어떤 참조로도 닿지 않는 객체는 가비지 컬렉션 대상이 되지만, 언제 회수될지는 JVM이 정합니다. 반드시 닫아야 하는 자원은 GC에 맡기지 않습니다.
- 클래스 정보와 메서드 코드는 메서드 영역에 클래스마다 한 벌씩 있습니다. 실제 메모리 배치는 명세가 아니라 구현이 정합니다.
연습 문제
main이Counter객체를 만들고twice(counter)를 호출하며,twice는increment(counter)를 두 번 호출하는 프로그램을 작성하세요. 세 메서드가 시작하고 끝날 때마다 한 줄씩 출력하고, 마지막에main에서Counter의 값을 출력합니다. 실행하기 전에 출력 순서를 먼저 적어 보세요.- 1번 프로그램의
twice에서 첫 번째increment(counter);바로 뒤에counter = new Counter();를 넣으면,main이 출력하는 값은 얼마일까요? - 다음 코드를 주석이 있는 줄까지 실행했을 때, 스택의
main프레임에 있는 것과 힙에 있는 것을 구분해 보세요.Member는 앞의StackHeapWalkthrough에 있는 클래스라고 가정합니다.
public static void main(String[] args) {
int count = 2;
int[] scores = new int[count];
scores[0] = 90;
Member member = new Member("[email protected]", 40);
// 여기까지 실행한 시점
}해설 보기
1번은 다음과 같이 작성할 수 있습니다.
public final class CounterStackTrace {
public static void main(String[] args) {
System.out.println("main:start");
Counter counter = new Counter();
twice(counter);
System.out.println("main:count=" + counter.value);
System.out.println("main:end");
}
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:start");
counter.value++;
System.out.println("increment:end");
}
private static final class Counter {
int value;
}
}main:start
twice:start
increment:start
increment:end
increment:start
increment:end
twice:end
main:count=2
main:endincrement 프레임 두 개는 동시에 쌓이지 않습니다. 첫 번째 호출이 increment:end를 출력하고 반환해 프레임이 걷힌 뒤에야 두 번째 호출의 프레임이 만들어집니다. 그래서 직접 작성한 메서드만 세면, 스택이 가장 깊을 때도 increment, twice, main 세 개입니다. 세 메서드의 counter는 서로 다른 지역 변수지만 모두 힙의 같은 Counter 객체를 가리키므로, increment 프레임이 사라진 뒤에도 늘어난 값은 객체에 남아 main:count=2가 출력됩니다.
2번의 답은 1입니다. 첫 번째 increment는 main과 같은 객체의 값을 1로 올립니다. 그다음 counter = new Counter();는 twice 프레임의 지역 변수만 새 객체로 바꾸므로, 두 번째 increment는 새 객체의 값을 올립니다. main의 counter는 여전히 원래 객체를 가리키고 그 값은 1입니다. twice가 반환하면 새 Counter를 가리키는 변수가 하나도 남지 않으므로, 그 객체는 가비지 컬렉션 대상이 됩니다.
3번에서 main 프레임에는 매개변수 args와 지역 변수 count, scores, member가 있습니다. count에는 값 2 자체가 들어 있지만, scores와 member에는 힙의 객체를 가리키는 참조값이 들어 있습니다. 힙에는 칸의 값이 90과 0인 길이 2짜리 int 배열, email과 age 필드를 가진 Member 객체, 문자열 "[email protected]" 객체, 그리고 args가 가리키는 String 배열(명령행 인수가 없으면 길이 0)이 있습니다. age의 40은 기본형 값이지만 지역 변수가 아니라 객체의 필드이므로 힙에 있다는 점이 핵심입니다. Member 클래스의 정보와 생성자, 메서드의 코드는 메서드 영역에 있습니다.
다음 글
다음 글에서는 객체마다 따로 생기는 인스턴스 변수와 달리 클래스에 하나만 있는 static 변수를 다룹니다. 이 글의 메모리 모델로 보면, 객체가 몇 개든 함께 쓰는 값이 왜 필요한지와 그 값이 언제까지 남는지가 자연스럽게 설명됩니다.