포스트

Java (1) - Memory: 객체는 어디에 있나

지역 변수는 스택에 객체는 힙에 있다는 것을 메서드 인자 전달과 문자열 리터럴, Integer 캐시로 직접 확인하는 것을 다룹니다.

Java (1) - Memory: 객체는 어디에 있나

자바 기초 시리즈의 1편입니다. 전체 목차는 0편에 있습니다.

0편의 마지막 예제에서 127==true128false였습니다. 그 답이 이 편에 있습니다.

먼저 더 단순한 질문부터 시작합니다.

한 줄이 하는 일

1
Member m = new Member("김");

이 한 줄은 세 가지를 합니다.

1
2
3
new Member("김")   ① 힙에 Member 객체를 만든다
Member m           ② 스택에 m 이라는 변수 칸을 만든다
       =           ③ ①이 만들어진 주소를 ②에 적는다

m은 객체가 아닙니다. 객체가 있는 자리를 가리키는 값입니다. 이 구분이 이 시리즈 전체에서 계속 나옵니다.

스택과 힙

자바 프로그램이 쓰는 메모리는 크게 두 덩어리입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        스택 (Stack)                          힙 (Heap)
 ┌────────────────────────┐          ┌────────────────────────────┐
 │ changeName() 프레임    │          │                            │
 │    Member m  = ●───────┼────┐     │   ┌────────────────────┐   │
 │    int i     = 0       │    ├────────▶│ Member             │   │
 ├────────────────────────┤    │     │   │    name = ●────────┼───┼──┐
 │ main() 프레임          │    │     │   └────────────────────┘   │  │
 │    int n     = 1       │    │     │                            │  │
 │    Member m  = ●───────┼────┘     │   ┌────────────────────┐   │  │
 │                        │          │   │ String "김"        │◀──┼──┘
 └────────────────────────┘          │   └────────────────────┘   │
                                     └────────────────────────────┘
   스레드마다 하나씩                     모든 스레드가 같이 쓴다
   메서드가 끝나면 통째로 사라짐           GC가 치운다
  스택
들어가는 것 지역 변수, 매개변수, 메서드 호출 정보 모든 객체와 배열
소유 스레드마다 하나씩 모든 스레드가 공유
정리 메서드가 끝나면 자동으로 사라짐 GC가 치운다 (7편)
크기 작다 (기본 512KB~1MB 수준) 크다 (0편에서 본 그 힙)

지역 변수는 스택에, 객체는 힙에 있습니다. 원시 타입(int, long, boolean 등) 지역 변수는 값 자체가 스택에 있고, 참조 타입 지역 변수는 힙에 있는 객체의 주소가 스택에 있습니다.

두 가지 사실이 여기서 바로 따라 나옵니다.

첫째, 스택은 스레드마다 따로입니다. 그래서 지역 변수는 동시성 문제가 생기지 않습니다. 동시성 문제가 생기는 건 대개 힙에 있는 것(엔티티, 필드처럼 여러 스레드가 같이 보는 것)입니다.

둘째, 스택은 유한합니다. 재귀가 안 끝나면 스택이 넘칩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Stack {
    static int depth = 0;
    static void recurse() { depth++; recurse(); }

    public static void main(String[] args) {
        try {
            recurse();
        } catch (StackOverflowError e) {
            System.out.println(e.getClass().getName());
            System.out.println("들어간 깊이 = " + depth);
        }
    }
}
1
2
java.lang.StackOverflowError
들어간 깊이 = 47512

스택 크기를 줄이면 더 얕은 곳에서 터집니다.

1
java -Xss256k Stack.java
1
2
java.lang.StackOverflowError
들어간 깊이 = 1603

깊이 숫자는 환경마다 다릅니다. 메서드가 지역 변수를 몇 개 쓰느냐에 따라 프레임 크기가 달라지기 때문입니다. 봐야 할 건 4만 대와 1600처럼 자릿수가 바뀐다는 것이지 정확한 값이 아닙니다.

StackOverflowErrorException이 아니라 Error라는 것도 눈에 담아둘 만합니다. 6편에서 다시 봅니다.

자바는 항상 값으로 넘긴다

“자바는 값 전달인가 참조 전달인가”는 면접 단골입니다. 답은 항상 값 전달입니다.

헷갈리는 이유는 넘기는 값이 참조값일 때가 있기 때문입니다. 참조값을 복사해서 넘기는 것도 값 전달입니다.

확인 1: 메서드에 넘겨서 바꿔보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Mem {
    static class Member {
        String name;
        Member(String name) { this.name = name; }
    }

    static void changePrimitive(int n)       { n = 999; }
    static void changeReference(Member m)    { m.name = "바뀜"; }
    static void reassignReference(Member m)  { m = new Member("새 객체"); }
    static void changeArray(int[] a)         { a[0] = 999; }

    public static void main(String[] args) {
        int n = 1;
        changePrimitive(n);
        System.out.println("원시 타입 n = " + n);

        Member m = new Member("원래 이름");
        changeReference(m);
        System.out.println("필드를 바꾼 뒤 m.name = " + m.name);

        Member m2 = new Member("원래 이름");
        reassignReference(m2);
        System.out.println("참조를 갈아끼운 뒤 m2.name = " + m2.name);

        int[] arr = {1, 2, 3};
        changeArray(arr);
        System.out.println("배열 = " + java.util.Arrays.toString(arr));
    }
}
1
2
3
4
원시 타입 n = 1
필드를 바꾼 뒤 m.name = 바뀜
참조를 갈아끼운 뒤 m2.name = 원래 이름
배열 = [999, 2, 3]

세 번째 줄이 핵심입니다. 필드를 바꾼 건 밖에 보이는데, 참조를 갈아끼운 건 안 보입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[changeReference]  m.name = "바뀜"

   main의 m ●──┐
                ├──▶ ┌──────────────┐
  메서드의 m ●──┘    │ Member       │  ← 같은 객체를 고친다
                     │  name="바뀜" │     밖에서도 보인다
                     └──────────────┘

[reassignReference]  m = new Member("새 객체")

   main의 m ●───────▶ ┌──────────────────┐
                      │ Member           │  ← 아무도 안 건드림
                      │  name="원래 이름"│
                      └──────────────────┘
 메서드의 m ●───────▶ ┌──────────────────┐
                      │ Member           │  ← 새로 만든 것
                      │  name="새 객체"  │     메서드 끝나면 아무도 안 가리킴
                      └──────────────────┘

메서드 안의 mmainm이 들고 있던 주소를 복사한 별개의 변수입니다. 복사본에 다른 주소를 적어도 원본은 그대로입니다.

이걸 알면 “메서드에 넘겼는데 왜 안 바뀌나요”와 “메서드에 넘겼는데 왜 바뀌었나요”가 같은 규칙 하나로 설명됩니다. 필드를 고치면 바뀌고, 변수에 다시 대입하면 안 바뀝니다.

같다는 게 두 가지다

==equals는 서로 다른 질문에 답합니다.

  묻는 것 기본 동작
== 같은 자리를 가리키나 (동일성) 원시 타입은 값 비교, 참조 타입은 주소 비교
equals() 내용이 같나 (동등성) Object의 기본 구현은 ==와 동일

Object.equals()의 원래 구현이 return (this == obj);라는 게 포인트입니다. 재정의하지 않으면 equals도 주소 비교입니다.

확인 2: new로 만든 문자열 두 개

1
2
3
4
5
6
7
8
9
public class Str {
    public static void main(String[] args) {
        String c = new String("hello");
        String d = new String("hello");

        System.out.println("c == d      : " + (c == d));
        System.out.println("c.equals(d) : " + c.equals(d));
    }
}
1
2
c == d      : false
c.equals(d) : true

new를 두 번 불렀으니 힙에 객체가 두 개 생겼습니다. 주소가 다르니 ==false 입니다. Stringequals를 내용 비교로 재정의해 뒀으니 equalstrue입니다.

1
2
3
4
5
6
7
       스택                     힙
   c ●────────▶ ┌───────────────────┐
                │ String "hello"    │
                └───────────────────┘
   d ●────────▶ ┌───────────────────┐
                │ String "hello"    │   ← 내용은 같지만 다른 객체
                └───────────────────┘

문자열은 특별 취급을 받는다

그런데 new 없이 쓰면 이야기가 달라집니다.

확인 3: 리터럴은 같은 객체를 재사용한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Pool {
    public static void main(String[] args) {
        String a = "hello";
        String b = "hello";
        String c = new String("hello");

        System.out.println("a == b            : " + (a == b));
        System.out.println("a == c            : " + (a == c));
        System.out.println("a == c.intern()   : " + (a == c.intern()));

        String e = "hel" + "lo";
        System.out.println("a == \"hel\"+\"lo\"   : " + (a == e));

        String part = "hel";
        String f = part + "lo";
        System.out.println("a == part+\"lo\"    : " + (a == f));
    }
}
1
2
3
4
5
a == b            : true
a == c            : false
a == c.intern()   : true
a == "hel"+"lo"   : true
a == part+"lo"    : false

네 줄을 하나씩 봅니다.

a == btrue인 이유 — 자바는 문자열 리터럴을 String Pool이라는 곳에 모아두고 재사용합니다. "hello"라고 두 번 쓰면 두 번째는 만들지 않고 이미 있는 걸 가리킵니다.

1
2
3
4
5
6
7
8
9
10
       스택                  힙
                   ┌─────────── String Pool ────────────┐
   a ●─────────┐   │                                    │
               ├──▶│   ┌────────────────────┐           │
   b ●─────────┘   │   │ String "hello"     │           │
                   │   └────────────────────┘           │
                   └────────────────────────────────────┘
   c ●─────────────▶   ┌────────────────────┐
                       │ String "hello"     │  ← new 로 만든 건 풀 밖
                       └────────────────────┘

c.intern()a와 같은 이유intern()은 “풀에 같은 내용이 있으면 그걸 돌려달라”는 메서드입니다. 그래서 a와 같은 객체가 나옵니다.

"hel" + "lo"a와 같은 이유 — 둘 다 리터럴이라 컴파일 시점에 "hello"로 합쳐집니다. 실행할 때는 이미 "hello" 리터럴 하나입니다.

part + "lo"가 다른 이유part는 변수라서 컴파일 시점에 값을 확정할 수 없습니다. 실행 중에 새 String 객체를 만듭니다. 풀에 넣지도 않습니다.

그래서 문자열은 ==가 아니라 equals로 비교합니다. 리터럴만 쓰다 보면 ==가 우연히 잘 동작해서 문제를 못 느끼다가, 사용자 입력이나 DB에서 읽은 값과 비교하는 순간 조용히 false가 됩니다. 그 값들은 실행 중에 만들어진 객체라 풀에 없습니다.

Integer는 -128부터 127까지 캐시한다

0편에서 남겨둔 문제입니다.

확인 4: 127과 128

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Box {
    public static void main(String[] args) {
        Integer a = 127, b = 127;
        Integer c = 128, d = 128;
        System.out.println("127 == 127      : " + (a == b));
        System.out.println("128 == 128      : " + (c == d));
        System.out.println("128.equals(128) : " + c.equals(d));

        Integer e = -128, f = -128;
        Integer g = -129, h = -129;
        System.out.println("-128 == -128    : " + (e == f));
        System.out.println("-129 == -129    : " + (g == h));

        Long l1 = 127L, l2 = 127L;
        Long l3 = 128L, l4 = 128L;
        System.out.println("Long 127 == 127 : " + (l1 == l2));
        System.out.println("Long 128 == 128 : " + (l3 == l4));
    }
}
1
2
3
4
5
6
7
127 == 127      : true
128 == 128      : false
128.equals(128) : true
-128 == -128    : true
-129 == -129    : false
Long 127 == 127 : true
Long 128 == 128 : false

경계가 정확히 -128 이상 127 이하입니다.

Integer a = 127;처럼 원시 타입을 참조 타입에 넣으면 오토박싱이 일어나고, 이건 Integer.valueOf(127) 호출로 바뀝니다. 그런데 Integer.valueOf()-128부터 127까지의 값에 대해 미리 만들어둔 객체를 돌려줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
        IntegerCache (클래스 로딩 시 미리 생성)
   ┌──────────────────────────────────────────────┐
   │ -128  -127  ...  -1   0   1  ...  126   127  │   ← 256개
   └──────────────────────────────────────────────┘
        ▲                                    ▲
        │                                    │
   Integer e = -128                     Integer a = 127
   Integer f = -128    → 같은 객체       Integer b = 127  → 같은 객체


   Integer c = 128  →  new Integer(128)  ┐
   Integer d = 128  →  new Integer(128)  ┘  매번 새 객체

이 범위는 바꿀 수 있습니다. 위쪽 경계만 옵션으로 조절합니다.

1
java -XX:AutoBoxCacheMax=1000 Box.java
1
2
3
4
5
6
127 == 127      : true
128 == 128      : true
-128 == -128    : true
-129 == -129    : false
Long 127 == 127 : true
Long 128 == 128 : false

128 == 128true로 바뀌었습니다. 아래쪽 -128은 그대로입니다. AutoBoxCacheMax는 위쪽 경계만 바꿉니다. Long은 이 옵션의 영향을 안 받습니다.

그래서 캐시 범위를 믿는 코드는 쓰면 안 됩니다. 실행 옵션 하나로 결과가 바뀌는 비교는 비교가 아닙니다. 박싱 타입은 항상 equals로 비교하고, 값을 다룰 거면 애초에 int를 씁니다.

이게 실무에서 어떻게 터지느냐면, 보통 이런 모습입니다.

1
2
3
if (order.getMemberId() == member.getId()) {   // 둘 다 Long
    ...
}

테스트 데이터는 id가 1, 2, 3이라 잘 통과합니다. 운영에 올라가서 id가 128을 넘는 순간부터 조용히 false가 됩니다. 예외도 안 납니다.

final이 막는 것

final변수에 다시 대입하는 것을 막습니다. 객체 내용을 못 바꾸게 하는 게 아닙니다.

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.*;

public class Fin {
    public static void main(String[] args) {
        final List<String> names = new ArrayList<>();
        names.add("김");
        names.add("이");
        System.out.println("final 리스트에 추가 = " + names);
        names.clear();
        System.out.println("final 리스트 비우기 = " + names);
    }
}
1
2
final 리스트에 추가 = [김, 이]
final 리스트 비우기 = []

final인데 요소를 넣고 비우는 게 전부 됩니다. 안 되는 건 이겁니다.

1
2
final List<String> names = new ArrayList<>();
names = new ArrayList<>();
1
2
3
4
Fin2.java:6: error: cannot assign a value to final variable names
        names = new ArrayList<>();
        ^
1 error
1
2
3
4
5
6
   names ●────▶ ┌──────────────┐
     │          │ ArrayList    │
     │          │  [김, 이]    │  ← 이 안은 마음대로 바꿀 수 있다
     │          └──────────────┘
     │
     └── final 은 이 화살표를 다른 데로 못 돌리게 할 뿐

final은 스택에 있는 값(주소)을 고정하는 것이고, 힙에 있는 객체 내용은 관여하지 않습니다. 앞에서 “변수는 객체가 아니라 주소를 들고 있다”고 한 게 여기서 그대로 쓰입니다.

내용까지 못 바꾸게 하려면 불변 객체를 쓰거나 List.of()처럼 수정 불가능한 컬렉션을 씁니다.

1
2
List<String> immutable = List.of("a", "b");
immutable.add("c");
1
List.of()에 add → UnsupportedOperationException

내가 헷갈렸던 지점

정리

  • 변수는 객체가 아니다. 객체는 힙에 있고 변수는 그 자리를 가리키는 값을 들고 있다. 지역 변수는 스택에, 객체와 배열은 전부 힙에 있다
  • 스택은 스레드마다 하나씩이라 지역 변수에는 동시성 문제가 없다. 대신 유한해서 재귀가 깊어지면 StackOverflowError가 난다
  • 자바는 항상 값으로 넘긴다. 참조 타입일 때 넘어가는 값이 주소일 뿐이다. 그래서 필드를 고치면 밖에 보이고, 변수에 다시 대입하면 안 보인다
  • ==는 같은 자리인지, equals는 내용이 같은지를 묻는다. Object.equals의 기본 구현은 ==와 같아서, 재정의하지 않으면 둘이 똑같이 동작한다
  • 문자열 리터럴은 String Pool에 모아두고 재사용해서 ==true가 되지만, new String()이나 변수를 합쳐 만든 문자열은 다른 객체다. 그래서 문자열은 equals로 비교한다
  • Integer-128 ~ 127을 미리 만들어 캐시한다. 그 바깥은 매번 새 객체라 ==false다. 위쪽 경계는 -XX:AutoBoxCacheMax로 바뀌므로 캐시를 믿는 비교는 쓰면 안 된다
  • final은 참조를 고정할 뿐 객체 내용은 막지 않는다. final List에도 add는 된다

2편에서 equals를 재정의합니다. 그리고 그것만으로는 HashSet에 중복이 들어간다는 것을 봅니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.