자바 변수는 두 종류뿐이다 — 값을 직접 담는 것과 객체의 주소를 담는 것.
메모리에서 무엇이 다른가
int a = 10; // 스택의 변수 칸에 10 이 직접 들어간다
String s = "hi"; // 스택의 변수 칸에는 '주소' 가 들어가고 실체는 힙에 있다
[스택] [힙]
a │ 10 │ ┌──────────┐
s │ 0x1A4 │ ───────────→ │ "hi" │
└──────────┘
대입할 때 무엇이 복사되나
int a = 10, b = a; // 값 10 이 복사된다. 이후 서로 무관
b = 20; // a 는 여전히 10
int[] x = {1,2,3}, y = x; // '주소' 가 복사된다. 같은 배열을 가리킨다
y[0] = 99; // x[0] 도 99 가 된다
자바는 항상 값 전달(pass by value) 이다. 다만 참조 타입에서는 그 값이 주소라서 호출된 메서드가 같은 객체를 건드릴 수 있다.
void f(int[] arr) { arr[0] = 99; } // 호출자에게 보인다 (같은 객체)
void g(int[] arr) { arr = new int[]{7}; } // 안 보인다 (지역 변수만 다른 곳을 가리킴)
비교가 갈린다
int a = 128, b = 128;
a == b; // true — 값을 비교한다
Integer x = 128, y = 128;
x == y; // false! — 주소를 비교한다
x.equals(y); // true
여덟 개의 원시 타입
-
boolean — (크기 미정의)
-
byte — 1B short 2B int 4B long 8B
-
char — 2B (UTF-16 코드 유닛)
-
float 4B — double 8B
-
나머지는 전부 참조 타입 — 클래스 · 인터페이스 · 배열 · enum
char가 2바이트라는 점이 함정이다. 이모지 같은 보조 문자는 2 코드 유닛을 차지해 String.length()가 사람이 세는 글자 수와 달라진다.
"👍".length(); // 2
"👍".codePointCount(0, 2); // 1
null은 참조 타입만 가진다
int i = null; // 컴파일 에러
Integer i = null; // 가능 → 그래서 언박싱에서 NPE 가 난다
면접 함정
- ❌ "자바는 참조 전달을 지원한다" → 항상 값 전달이다. 참조 값을 전달할 뿐이다.
- ❌ "원시 타입은 힙에 없다" → 객체의 필드로 있으면 그 객체와 함께 힙에 있다. 지역 변수일 때만 스택이다.
객체 하나가 실제로 몇 바이트인가
-
64비트 JVM · 압축 OOP 기준
-
객체 헤더 — 12바이트 (mark word 8 + klass pointer 4)
-
필드 — int 4 · long 8 · 참조 4 (압축 시)
-
정렬 패딩 — 8의 배수로 맞춘다
-
class P { int x, y; } — → 12 + 4 + 4 = 20 → 패딩으로 24바이트 int 두 개(8바이트)를 담는 데 24바이트를 쓴다
원시 타입 배열이 객체 배열보다 훨씬 조밀한 이유다.
- int[1000] — 헤더 16 + 4×1000 ≈ 4KB
- Integer[1000] — 배열 헤더 + 참조 4×1000 + Integer 객체 1000개(각 16B) ≈ 20KB
- 게다가 객체가 흩어져 있어 캐시 미스가 잦다
String이 특별한 이유
String a = "hi", b = "hi";
a == b; // true — 컴파일 시점 상수는 풀에서 공유된다
String c = new String("hi");
c == a; // false
c.intern() == a; // true — 풀의 인스턴스를 얻는다
Java 9부터 String은 내부적으로 byte[] + 인코딩 플래그다(Compact Strings). 라틴 문자만 있으면 문자당 1바이트를 써 메모리를 절반으로 줄인다.