요약

정수 같은 작은 값은 변수라는 칸에 그대로 들어간다. 배열이나 문자열처럼 큰 것은 따로 넓은 창고에 두고, 변수에는 그것이 있는 자리 번호만 적어 둔다. 집을 통째로 들고 다니지 않고 주소가 적힌 쪽지를 들고 다니는 것과 같다. 쪽지는 베끼기 쉬워서 큰 것을 싸게 주고받을 수 있다. 대신 쪽지를 베껴 가진 두 사람이 같은 집을 보고 있으므로, 한쪽이 집을 고치면 다른 쪽에게도 고쳐진 집이 보인다.

예시로 보기

학생 30명의 점수를 변수 30개에 담을 수는 없다. 한 덩어리로 묶은 배열이 필요하다. 그런데 배열은 크기가 제각각이라, 크기가 정해진 변수 칸에 통째로 넣을 수 없다. 그래서 자바는 덩어리를 다른 곳에 두고 변수에는 그곳의 주소만 넣는다.

이 차이는 변수를 다른 변수에 넣을 때 드러난다.

int age = 20;
int copy = age;
copy = 30;
System.out.println(age);     // 20

int[] p = {1, 2, 3};
int[] q = p;
q[0] = 99;
System.out.println(p[0]);    // 99

위쪽은 copy를 고쳐도 age가 그대로다. 아래쪽은 q로 고쳤는데 p로 봐도 바뀌어 있다. 두 경우 모두 자바가 한 일은 같다. 변수 칸에 든 것을 그대로 베꼈다. 다만 칸에 든 것이 다르다.

 기본 타입: 칸에 값이 들어 있다        참조 타입: 칸에 주소가 들어 있다

 age  [ 20 ]                           p [ 5번지 ] ----+
 copy [ 30 ]   <- 20을 베낀 뒤 30으로                  +--> 5번지: [99][2][3]
                                       q [ 5번지 ] ----+
                                         (5번지라는 주소를 베꼈다)

q = p는 배열을 하나 더 만들지 않는다. 주소가 적힌 쪽지를 한 장 더 쓸 뿐이다. 그림의 “5번지”는 설명을 위해 지어낸 번호다. 자바는 실제 주소를 보여 주지 않는다1.

검증: 위 출력 20과 99, Calculator 예제의 출력 5 + 3 = 8, 메서드에 넘긴 int와 배열의 변화, 매개변수에 새 배열을 넣어도 원래 변수가 그대로인 것, 끝없는 재귀의 StackOverflowError, 카드 C2·C5 — 07_java-reference-types_impl.java

참조 타입

타입은 두 갈래다.

  기본 타입 참조 타입
변수에 든 것 값 자체 객체가 만들어진 곳의 메모리 주소
종류 정수(byte, char, short, int, long), 실수(float, double), 논리(boolean) 배열, 열거, 클래스, 인터페이스

객체는 메모리에 만들어진 데이터 덩어리다. 배열도 문자열도 객체다. 참조 타입 변수는 객체의 주소를 “참조한다”. 이름표 p가 가리키는 곳을 따라가면 객체가 있다는 뜻이다.

int age = 20;                // 기본 타입: 칸에 20
String name = "박은지";      // 참조 타입: 칸에 문자열 객체의 주소

세 메모리 영역

JVM은 운영체제에서 받은 메모리를 세 영역으로 나눠 쓴다.

영역 들어가는 것 생기고 사라지는 때
메서드 영역(Method Area) 바이트코드 파일을 읽어 들인 내용. 클래스마다 상수, 정적 필드, 메서드 코드, 생성자 코드 클래스를 읽어 들일 때
힙 영역(Heap Area) 객체. 배열, 문자열 등 new로 만들 때 생기고, 아무도 가리키지 않으면 JVM이 치운다
스택 영역(Stack Area) 프레임. 메서드를 부를 때마다 하나씩 생기고, 그 안에 그 메서드의 변수가 들어간다 메서드를 부를 때 생기고 끝나면 사라진다

힙에 있는 객체의 주소는 메서드 영역의 상수와 스택의 변수가 들고 있다. 스택은 실행 줄기(스레드)마다 하나씩 따로 있고, 메서드 영역과 힙은 모두가 같이 쓴다.

Calculator 클래스로 따라가 본다.

public class Calculator {
    public static int add(int a, int b) {
        int sum = a + b;
        return sum;
    }

    public static void main(String[] args) {
        int result = add(5, 3);
        System.out.println("5 + 3 = " + result);   // 5 + 3 = 8
    }
}

add 안의 int sum = a + b;를 막 실행한 순간의 메모리다2.

 메서드 영역                  스택 영역 (main 스레드)          힙 영역
+----------------------+    +--------------------------+    +------------------+
| Calculator 클래스    |    | add의 프레임   <- 맨 위  |    |                  |
|   add의 코드         |    |   a   = 5                |    |  String[] (길이 0)|
|   main의 코드        |    |   b   = 3                |    |        ^         |
|   상수 "5 + 3 = "    |    |   sum = 8                |    |        |         |
+----------------------+    +--------------------------+    |        |         |
                            | main의 프레임            |    |        |         |
                            |   args   = (주소) -------------------- +         |
                            |   result = (아직 없음)   |    |                  |
                            +--------------------------+    +------------------+
stateDiagram-v2
    [*] --> M1: java Calculator
    M1: 스택에 main 프레임만 있다
    M2: main 프레임 위에 add 프레임이 쌓였다
    M3: add 프레임이 사라지고 main만 남았다
    M1 --> M2: add(5, 3)을 부른다
    M2 --> M3: return sum (8을 돌려준다)
    M3 --> [*]: main이 끝난다
  1. JVM이 Calculator.class를 읽어 메서드 영역에 올린다.
  2. main을 부른다. 스택에 main의 프레임이 생기고 그 안에 args와 result 자리가 잡힌다.
  3. add(5, 3)을 부른다. main 프레임 위에 add의 프레임이 쌓인다. 5와 3이 a와 b에 베껴진다.
  4. add가 끝난다. 8을 돌려주고 add의 프레임이 통째로 사라진다. a, b, sum도 함께 사라진다.
  5. 돌려받은 8이 main 프레임의 result에 들어간다.

접시를 쌓듯 나중에 쌓인 프레임이 먼저 치워진다. 스택(쌓아 올린 더미)이라는 이름이 여기서 왔다. 변수의 범위가 블록으로 끊기는 것도 변수가 프레임 안에 살기 때문이다.

지켜 주는 것과 지켜 주지 않는 것

지켜 주는 것3.

  • 메서드의 변수는 그 메서드의 프레임 안에만 있다. 다른 메서드가 이름으로 건드릴 수 없다.
  • 변수를 다른 변수에 넣거나 메서드에 넘길 때는 늘 칸에 든 것을 베낀다. 기본 타입이면 값이, 참조 타입이면 주소가 베껴진다.
  • 객체는 그것을 가리키는 변수가 하나라도 남아 있으면 사라지지 않는다. 그 객체를 만든 메서드가 끝난 뒤에도 그렇다.

지켜 주지 않는 것.

  • 아무도 가리키지 않게 된 객체를 언제 치우는지는 정해져 있지 않다. JVM이 알아서 정한다. 이 청소가 가비지 컬렉션이다.
  • 주소 값 자체는 볼 수도 계산에 쓸 수도 없다. C의 포인터처럼 주소에 1을 더해 옆 칸으로 가는 일은 못 한다.
  • 같은 객체를 가리키는 변수가 여럿일 때, 한 변수로 고친 것을 다른 변수에게 숨겨 주지 않는다.

무너지는 경우.

상황 일어나는 일 대처
메서드가 자기 자신을 끝없이 부른다 프레임이 계속 쌓여 스택이 꽉 찬다. StackOverflowError로 멈춘다 재귀가 끝나는 조건을 둔다
객체를 계속 만들면서 모두 붙잡고 있다 힙이 꽉 찬다. OutOfMemoryError로 멈춘다 안 쓰는 객체를 가리키는 변수를 없앤다
변수가 아무 객체도 가리키지 않는데 객체를 쓰려 한다 NullPointerException으로 멈춘다 참조 비교와 null

스스로 설명해 보기

아래 코드의 각 줄이 끝난 뒤 스택과 힙이 어떻게 되는지 답한다2.

static void addTenToFirst(int[] arr) { arr[0] += 10; }
static void replace(int[] arr) { arr = new int[] {7, 7, 7}; arr[0] = 100; }

int[] data = {5, 6};       // 1
addTenToFirst(data);       // 2
replace(data);             // 3
1. 1번 줄에서 무엇이 어디에 생기는가

힙에 길이 2인 배열 [5][6]이 생긴다. main 프레임의 data 칸에 그 배열의 주소가 들어간다. 배열은 객체라서 힙에, 변수는 메서드의 것이라 스택에 있다(표의 힙·스택 줄).

2. 2번 줄이 끝나면 data[0]은 얼마이고, 왜 그런가

15다. 메서드를 부를 때 data 칸의 주소가 arr 칸에 베껴진다(“늘 칸에 든 것을 베낀다”). arr와 data가 같은 배열을 가리키므로 arr[0] += 10은 그 하나뿐인 배열의 첫 칸을 고친다. 프레임은 사라져도 힙의 배열은 data가 가리키고 있어 남는다.

3. 3번 줄이 끝나면 data[0]은 100인가

아니다. 15 그대로다. arr = new int[] {7, 7, 7}은 arr 칸에 새 배열의 주소를 덮어쓴다. arr 칸은 replace의 프레임에 있고 data 칸은 main의 프레임에 있다. 서로 다른 칸이라 data는 여전히 처음 배열을 가리킨다. 100은 새 배열에 들어갔고, replace가 끝나면 그 배열을 가리키는 변수가 없어 쓰레기가 된다.

이 추적의 핵심 생각은?

“칸을 고치는가, 칸이 가리키는 객체를 고치는가”를 가른다. arr[0] = ...은 객체를 고친다. 그 객체를 가리키는 모두에게 보인다. arr = ...은 칸을 고친다. 그 칸의 주인에게만 보인다.

같은 생각을 쓸 수 있는 다른 상황은?

두 변수가 같은 배열을 가리킬 때의 대입(q = p; q[0] = 99;), 배열 칸에 든 객체(String[]의 각 칸도 주소다, 배열), 2차원 배열을 복사했는데 안쪽 배열은 같이 쓰이는 경우.

활용

  • 큰 배열을 메서드에 넘겨도 느려지지 않는다. 주소 하나만 베끼기 때문이다3.
  • 넘겨받은 배열을 메서드 안에서 고치면 부른 쪽 배열이 바뀐다. 원본을 지켜야 하면 복사본을 만들어 넘긴다(배열의 복사).
  • 흔한 실수: 배열을 “백업”한다며 int[] backup = data;라고 쓴다. 쪽지만 한 장 더 생겼을 뿐 배열은 하나다.
  • 스택은 스레드마다 따로 있고 힙은 같이 쓴다. 그래서 여러 스레드가 지역 변수로 다투는 일은 없고, 힙의 객체를 두고 다툰다(스레드).

연결

자주 하는 오해

“배열 변수를 다른 변수에 넣으면 배열이 복사된다”

틀렸다. 복사되는 것은 주소다. 배열은 여전히 하나이고 그것을 가리키는 변수가 둘이 된다. int b = a;가 값을 복사하니까 int[] q = p;도 내용을 복사하리라 믿기 쉽다. 실제로 두 줄은 똑같이 “칸에 든 것”을 복사한다. 다만 int 칸에는 값이, int[] 칸에는 주소가 들어 있다. 확인하는 법: q[0] = 99; 뒤에 p[0]을 찍으면 99가 나온다. p == q도 true다.

“메서드에 배열을 넘기면 원본이 넘어가니까, 메서드 안에서 무엇을 해도 원본이 바뀐다”

절반만 맞다. 넘어가는 것은 원본도 복사본도 아닌 주소의 사본이다. 그 주소를 따라가 배열의 칸을 고치면(arr[0] = 1) 부른 쪽에서도 보인다. 매개변수에 다른 배열을 넣으면(arr = new int[3]) 메서드 안의 쪽지만 바뀌고 부른 쪽 변수는 그대로다. 확인하는 법: 위 “스스로 설명해 보기”의 3번 줄 뒤에 data[0]을 찍으면 100이 아니라 15다.

과목별 관점

컴파일러 (3-2학기). C 프로그램이 받은 메모리는 네 구역으로 나뉜다. 낮은 번지부터 코드, 전역 데이터, 힙, 스택이다. JVM의 세 영역과 나란히 놓으면 이렇다.

C 프로그램의 구역 든 것 JVM에서 같은 구실을 하는 곳
코드 기계어 명령 메서드 영역(메서드 코드)
전역 데이터 전역 변수. 컴파일할 때 번지가 정해진다 메서드 영역(정적 필드)
힙 malloc으로 받은 자리. free로 돌려준다 힙 영역. new로 만들고 JVM이 치운다
스택 활성 레코드. 함수를 부를 때 쌓고 끝나면 걷는다 스택 영역의 프레임

컴파일러 과목에서 보면 세 가지가 더 보인다4.

  • 프레임 안에 무엇이 있는가. 위에서 프레임에는 “그 메서드의 변수”가 들어간다고 했다. 한 칸씩 적으면 이렇다. 돌려줄 값을 적을 칸, 넘겨받은 값, 부른 쪽 프레임의 위치, 돌아갈 자리, 지역 변수와 계산 중간값이다. 이 덩어리를 활성 레코드라 부른다(활성 레코드).
  • 자라는 방향. 스택은 높은 번지에서 낮은 번지 쪽으로, 힙은 낮은 번지에서 높은 번지 쪽으로 자란다. 둘이 겹치지 않게 하려는 것이다. 둘 다 얼마나 커질지 미리 모르므로 빈 곳의 양 끝에서 마주 보고 자라게 한다(실행 시 메모리 배치).
  • 누가 치우는가. C에서는 free(a)를 불러야 힙의 자리가 없어진다. Java에서는 위에 적은 대로 아무도 가리키지 않는 객체를 JVM이 치운다. 스택 쪽은 두 언어가 같다. 함수(메서드)가 끝나면 그 프레임이 사라진다.

자료구조 (1-2학기). C에서는 주소를 담는 변수를 포인터라 부른다. 자바의 참조와 C의 포인터는 변수 칸에 다른 자리의 번호가 들어 있다는 점이 같다. 다른 점은 셋이다.

  자바의 참조 C의 포인터
주소를 얻는 길 new가 돌려준 것만 받는다 &로 아무 변수의 주소나 얻는다
주소로 하는 계산 못 한다 p + 1처럼 더하고 뺀다
다 쓴 자리 치우기 아무도 가리키지 않으면 JVM이 치운다 free를 직접 불러야 한다
  • 빌린 자리는 직접 돌려준다. C에서 malloc이나 calloc으로 힙에서 빌린 메모리는 함수가 끝나도 없어지지 않는다. 반드시 free로 돌려줘야 한다. 돌려주지 않고 함수가 끝나면 그 자리를 가리키던 변수만 사라지고 자리는 남는다. 주소를 아는 변수가 없어 다시는 돌려줄 수 없다. 이것을 메모리 누수라 부른다. 위에서 “아무도 가리키지 않는 객체는 JVM이 치운다”고 한 바로 그 상황이 C에서는 치워지지 않고 쌓인다: 동적 메모리 할당
  • 참조 변수라는 말. 참조 변수를 “이미 있는 메모리 자리에 붙인 또 하나의 이름”이고 “늘 같은 대상을 가리킨다”고 설명하기도 한다. 이것은 C++의 참조다. 자바의 참조 변수는 다른 객체를 가리키게 바꿀 수 있고 null도 될 수 있다. 그래서 C++의 참조보다 C의 포인터에 가깝다. C의 포인터는 포인터와 포인터 인자와 값 전달에 있다.

확인 문제

C1 JVM이 메모리를 나누는 세 영역의 이름과 각 영역에 들어가는 것을 쓰라.

답: 메서드 영역: 읽어 들인 바이트코드의 내용(클래스별 상수, 정적 필드, 메서드 코드, 생성자 코드). 힙 영역: 객체(배열 포함). 스택 영역: 메서드를 부를 때마다 생기는 프레임과 그 안의 변수.
이유: 흔한 오답은 “참조 타입 변수는 힙에 있다”이다. 메서드 안에서 선언한 참조 변수의 칸은 스택에 있고, 그 칸이 가리키는 객체가 힙에 있다.

C2 다음 코드가 끝난 뒤 a[0], a[1]의 값은?
int[] a = {1, 2};
int[] b = a;
int[] c = {1, 2};
b[1] = 9;
c[0] = 7;

답: a[0] = 1, a[1] = 9.
이유: b는 a와 같은 배열을 가리켜서 b[1] = 9가 a에도 보인다. c는 내용만 같은 다른 배열이라 c[0] = 7은 a와 상관없다.

C3 메서드가 끝나면 그 안의 변수는 모두 사라진다. 그런데 메서드 안에서 new로 만든 배열을 return하면 부른 쪽에서 계속 쓸 수 있다. 왜 그런가?

답: 사라지는 것은 스택의 프레임과 그 안의 변수 칸이다. 배열은 힙에 있다. return이 배열의 주소를 부른 쪽에 넘겨 주고, 부른 쪽 변수가 그 주소를 가지고 있는 한 배열은 치워지지 않는다.
이유: 변수의 수명(프레임)과 객체의 수명(가리키는 변수가 있는 동안)은 따로 정해진다.

C4 int n = 3; String s = "hi"; int[] v = new int[2];를 실행한 직후의 스택과 힙을 그림으로 그려라.

답:

스택 (main 프레임)        힙
n [ 3 ]
s [ 주소 ] ------------> String "hi"
v [ 주소 ] ------------> int[] [0][0]

이유: n은 기본 타입이라 칸에 3이 그대로 있다. s와 v는 참조 타입이라 칸에는 주소만 있고 객체는 힙에 있다. new int[2]의 칸은 0으로 채워진다.

C5 static void addTen(int n) { n += 10; }와 static void addTenToFirst(int[] arr) { arr[0] += 10; }가 있다. int x = 3; int[] arr = {3}; addTen(x); addTenToFirst(arr); 뒤의 x와 arr[0]은? 왜 한쪽만 바뀌는가?

답: x = 3, arr[0] = 13.
이유: 둘 다 칸에 든 것을 베껴 넘긴다. x의 칸에는 값 3이 있어 메서드는 3의 사본만 고친다. arr의 칸에는 주소가 있어 메서드는 같은 배열을 찾아가 고친다.

C6 C 프로그램의 메모리를 낮은 번지부터 네 구역으로 그리고, 스택과 힙이 자라는 방향을 화살표로 표시하라. 구역마다 JVM의 어느 영역이 같은 구실을 하는지 적어라.

답:

 높은 번지  | 스택  ↓ (낮은 번지 쪽으로)  |  ← JVM 스택 영역(프레임)
            |        (빈 곳)             |
            | 힙    ↑ (높은 번지 쪽으로)  |  ← JVM 힙 영역
            | 전역 데이터                |  ← 메서드 영역(정적 필드)
 낮은 번지  | 코드                       |  ← 메서드 영역(메서드 코드)

이유: 크기가 미리 정해지는 코드와 전역 데이터를 아래에 고정하고, 실행 중에 크기가 바뀌는 스택과 힙을 남은 공간의 양 끝에서 마주 보고 자라게 한다. 흔한 오답은 스택을 낮은 번지에 두고 위로 자라게 그리는 것이다.

  1. 출력은 구현 코드로 확인했다. ↩

  2. 인자 없이 실행하면 args가 길이 0인 배열인 것은 배열 문서의 구현 코드에서 확인했다. ↩ ↩2

  3. Java Virtual Machine Specification §2.5.2(스택과 StackOverflowError), §2.5.3(힙과 OutOfMemoryError), Java Language Specification §8.4.1(인자는 값으로 넘어간다)에 있다. StackOverflowError와 인자 넘기기는 구현 코드로 확인했고, OutOfMemoryError는 실행해 보지 않았다. ↩ ↩2

  4. 구실이 같은 곳끼리 짝지은 것이고 두 구조가 똑같다는 뜻은 아니다. ↩