먼저 가장 중요한 사실부터 정리해야 한다.
String은 객체다.
하지만 한 번 만들어지면 내부 내용이 절대 바뀌지 않는다.
이게 바로 불변 객체(Immutable Object) 다.
String s = "hi";
s = s + "!";
이 코드를 보면 "hi"가 "hi!"로 바뀐 것처럼 보이지만, 실제 동작은 다음과 같다.
1. String Pool에 "hi" 생성
2. s(스택의 참조 변수) → "hi" 를 가리킴
3. s + "!" 연산 발생
→ 변수 연산이므로 내부적으로 StringBuilder가 "hi!"를 만든다.
→ "hi!"는 일반 Heap 객체로 생성 (String Pool이 아님)
4. s → "hi!" 를 가리키도록 변경
5. "hi"는 String Pool에 그대로 남아있음
여기서 중요한 점 세 가지:
s는 객체가 아니라 스택에 있는 참조 변수다. 힙의 객체를 가리키는 주소값을 담고 있는 것이다."hi!"는 변수 연산의 결과물이므로 String Pool이 아닌 일반 Heap에 생성된다."hi"는 String Pool에서 제거되지 않는다. String Pool은 JVM이 관리하며 리터럴은 쉽게 제거되지 않는다.즉, String에서 "변경"처럼 보이는 모든 연산은 객체 교체다.
코드에 직접 작성된 고정값이다.
String a = "hello"; // "hello"가 리터럴
int n = 42; // 42가 리터럴
char c = 'A'; // 'A'가 리터럴
변수에 담기 전, 코드에 그대로 적힌 값 자체를 리터럴이라고 한다.
String a = "hello";
String b = "hello";
이때 "hello" 객체는 몇 개일까?
정답은 1개다.
문자열 리터럴은 String Pool이라는 특별한 Heap 영역에 String 객체로 저장된다. String Pool에 저장되는 건 문자열 자체가 아니라 String 객체이고, 그 객체가 내부적으로 문자열 데이터를 가지고 있는 것이다.
같은 리터럴이 이미 존재하면 새 객체를 만들지 않고 재사용한다.
[ String Pool ]
┌─────────────────┐
│ String 객체 │
│ value: "hello" │
└─────────────────┘
↑ ↑
a b (스택의 참조 변수)
이 구조가 가능한 이유는 단 하나다.
String이 불변이기 때문
만약 String이 가변 객체였다면, 한 곳에서 내용을 바꾸는 순간 같은 객체를 공유하는 모든 참조가 영향을 받게 된다.
그래서 문자열은 공유 가능한 값 객체로 설계되었다.
String s1 = "a" + "b";
이 경우 컴파일러가 미리 "ab"로 합쳐버린다. "ab"는 String Pool에 저장된 리터럴이다.
반면,
String a = "ab";
String b = "a";
String s = b + "b";
코드에 직접 작성된 "ab", "a", "b" 는 전부 리터럴이므로 String Pool에 저장된다.
[ String Pool ] [ 일반 Heap ]
"ab" 객체 ← a "ab" 객체 ← s
"a" 객체 ← b
"b" 객체
b + "b" 연산 결과인 "ab"는 String Pool에 이미 같은 값이 있더라도 무조건 일반 Heap에 새 객체를 생성한다. 변수가 포함된 연산은 컴파일 타임에 값을 확정할 수 없기 때문이다.
System.out.println(a == s); // false — 다른 객체
System.out.println(a.equals(s)); // true — 내용은 같음
정리하면:
new 또는 변수 연산 결과 → 일반 Heap (무조건 새 객체)String a = new String("아구몬"); // 일반 Heap 객체
String b = a.intern(); // String Pool에 등록하고 그 참조 반환
String c = "아구몬"; // String Pool 객체
System.out.println(b == c); // true
intern()은 일반 Heap에 있는 String을 String Pool로 강제 등록한다.
String이 불변이라는 사실은 안정성을 주지만, 대가도 따른다.
String s = "";
for (int i = 0; i < 1000; i++) {
s += i; // 반복마다 새로운 String 객체 생성 — 기존 객체는 GC 대상
}
성능과 메모리 모두 비효율적이다. 그래서 나온 것이 StringBuilder다.
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
String result = sb.toString();
String → 불변, 값 객체StringBuilder → 가변, 문자열 조작용 버퍼문자열을 많이 붙이는 상황에서는 StringBuilder를 사용하는 것이 설계 의도에 맞다.
StringBuilder sb = new StringBuilder();
sb.append("아구몬"); // 뒤에 추가
sb.insert(0, "진화: "); // 특정 위치에 삽입
sb.delete(0, 4); // 범위 삭제
sb.reverse(); // 뒤집기
sb.toString(); // String으로 변환
sb.length(); // 현재 문자열 길이
sb.capacity(); // 내부 버퍼 용량 (기본 16)
코테에서 reverse()는 팰린드롬 문제에서 바로 활용 가능하다.
String a = "hello";
String b = "hello";
String c = new String("hello");
a == b → true — 같은 String Pool 객체a == c → false — c는 일반 Heap 객체, 참조값이 다름a.equals(c) → true — 내용 비교==는 참조 비교, equals()는 내용 비교다.
String이 불변이기 때문에 내용 비교 결과를 신뢰할 수 있고, 그래서 equals()가 의미를 가진다.
| 개념 | 핵심 |
|---|---|
| String | 불변 객체 — 변경처럼 보이는 모든 연산은 새 객체 생성 |
| String Pool | 리터럴을 공유하는 특별한 Heap 영역 |
| 리터럴 vs 연산 결과 | 리터럴 → String Pool, 연산 결과 → 일반 Heap |
intern() | 일반 Heap의 String을 String Pool에 강제 등록 |
| StringBuilder | 가변 버퍼 — 반복 문자열 조작 시 사용 |
== vs equals() | 참조 비교 vs 내용 비교 — 문자열은 항상 equals() |