Java (1) - Memory: 객체는 어디에 있나
지역 변수는 스택에 객체는 힙에 있다는 것을 메서드 인자 전달과 문자열 리터럴, Integer 캐시로 직접 확인하는 것을 다룹니다.
자바 기초 시리즈의 1편입니다. 전체 목차는 0편에 있습니다.
0편의 마지막 예제에서 127은 ==가 true고 128은 false였습니다. 그 답이 이 편에 있습니다.
먼저 더 단순한 질문부터 시작합니다.
한 줄이 하는 일
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처럼 자릿수가 바뀐다는 것이지 정확한 값이 아닙니다.
StackOverflowError가 Exception이 아니라 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="새 객체" │ 메서드 끝나면 아무도 안 가리킴
└──────────────────┘
메서드 안의 m은 main의 m이 들고 있던 주소를 복사한 별개의 변수입니다. 복사본에 다른 주소를 적어도 원본은 그대로입니다.
이걸 알면 “메서드에 넘겼는데 왜 안 바뀌나요”와 “메서드에 넘겼는데 왜 바뀌었나요”가 같은 규칙 하나로 설명됩니다. 필드를 고치면 바뀌고, 변수에 다시 대입하면 안 바뀝니다.
같다는 게 두 가지다
==와 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 입니다. String은 equals를 내용 비교로 재정의해 뒀으니 equals는 true입니다.
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 == b가 true인 이유 — 자바는 문자열 리터럴을 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 == 128이 true로 바뀌었습니다. 아래쪽 -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에 중복이 들어간다는 것을 봅니다.