(feat. 메모리, 스레드, 해시코드)
Java를 공부하면서 가장 먼저 마주한 흥미로운 사실이 있었다. 바로 String
클래스가 불변(immutable) 이라는 점이다.
분명히 나는 str = str + " World";
와 같은 코드로 문자열을 변경했다고 생각했는데, Java는 이를 "변경"이 아니라 "새로 생성"이라고 말한다.
1. String
은 변경되는 것이 아니라, 새로 만들어진다
먼저 의문이 들었다. "str = str + 'World'
했는데, 왜 불변이라고 하지?"
그래서 실제로 참조가 바뀌는지 직접 확인해봤다.
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
String str = "Hello";
System.out.println("str 초기 객체 해시코드: " + System.identityHashCode(str));
str = str + " World";
System.out.println("str 변경 후 객체 해시코드: " + System.identityHashCode(str));
System.out.println("--------------------------------");
String[] strArray = {"Hello"};
System.out.println("배열 초기 객체 해시코드: " + System.identityHashCode(strArray));
strArray = Arrays.copyOf(strArray, strArray.length + 1);
System.out.println("배열 변경 후 객체 해시코드: " + System.identityHashCode(strArray));
}
}
결과:
str 초기 객체 해시코드: 1163157884
str 변경 후 객체 해시코드: 1956725890
--------------------------------
배열 초기 객체 해시코드: 356573597
배열 변경 후 객체 해시코드: 1735600054
해시코드가 바뀌었다.
즉, String
은 내부 값을 수정하는 것이 아니라, 새로운 객체를 생성하고 참조를 바꾸는 방식으로 동작하고 있었다.
이는 배열의 참조가 바뀌는 방식과도 유사했다.
2. 왜 불변이어야 했을까? (설계의 핵심 이유 3가지)
단순히 "새 객체를 만든다"는 사실보다 더 궁금했던 건 이것이다.
왜 불변으로 만들어야 했을까?
이를 세 가지 관점에서 추적해봤다.
2-1. 스레드 안전성(Thread Safety)을 보장해야 했다
String
은 다양한 곳에서 공유된다. 특히 URL, SQL, JSON, Key 값 등… 여러 스레드가 같은 문자열을 동시에 사용할 수밖에 없다.
그런데 만약 String
이 가변이었다면, 하나의 스레드가 내용을 바꾸는 순간 다른 스레드는 예기치 못한 값을 읽게 됐을 것이다.
이를 막기 위해 synchronized
같은 락을 걸어야 했겠지만, 성능 저하와 복잡성이 문제였을 것이다.
그래서 아예 내용을 못 바꾸게 만들었다.
읽기만 가능한 객체라면, 여러 스레드에서 공유해도 아무 문제가 없기 때문이다.
public class StringThreadSafeExample {
public static void main(String[] args) {
String shared = "https://example.com";
Runnable task = () -> {
System.out.println(Thread.currentThread().getName() + " reads: " + shared);
};
new Thread(task).start();
new Thread(task).start();
}
}
이 코드는 어떤 환경에서도 항상 안전하게 작동한다.
String이 불변이라서 가능한 일이다.
2-2. 메모리 절약을 위해 캐싱(String Pool)이 필요했다
Java에서는 문자열 리터럴이 중복으로 생성되지 않는다.
동일한 리터럴은 String Constant Pool이라는 곳에 한번만 저장되고, 나중에 다시 쓰면 기존 객체를 참조한다.
String str1 = "Hello";
String str2 = "Hello";
System.out.println(str1 == str2); // true
이 캐싱 구조가 동작하려면 전제가 필요했다.
그 객체가 절대로 바뀌지 않아야 한다.
만약 str1이 "Hello"
였는데 내부 값을 변경할 수 있다면, str2도 영향을 받게 되고, 의도치 않은 버그가 생긴다.
그래서 아예 변경을 못 하게 만들었다.
불변성이 있어야 String Pool이 성립한다.
2-3. 해시코드가 변하지 않아야 컬렉션에서 안전했다
HashMap
, HashSet
등의 해시 기반 자료구조에서는 hashCode()
값이 매우 중요하다.
키의 해시코드가 바뀌면 데이터를 찾을 수 없게 된다.
그런데 String
은 불변이기 때문에 한 번 정해진 해시코드는 절대 바뀌지 않는다.
게다가 JVM은 이 값을 캐싱까지 해준다.
String str = "immutable";
System.out.println(str.hashCode() == str.hashCode()); // true
만약 String
이 가변이었다면, 내용 변경 → 해시코드 변경 → HashMap
에서 검색 실패 → 데이터 유실.
그야말로 대참사다.
그래서 해시 기반 자료구조에서 안전하게 쓰기 위해서도 String
은 불변이어야 했다.
결론
처음에는 불변이 불편해 보였다.
매번 새로운 객체를 만든다고 하니 성능도 걱정됐다.
하지만 실제로는 그 반대였다.
- 스레드 안정성 확보
- 메모리 재사용 최적화(String Pool)
- 해시 기반 자료구조에서 신뢰성 확보
이 모든 것이 가능했던 이유는 바로 String
이 불변이라서였다.
불변성은 단점이 아니라, Java의 성능과 안정성을 지탱하는 기반이었다.String
의 철학을 이해하면, Java가 왜 그렇게 만들어졌는지 한층 깊이 이해할 수 있다.
'프로그래밍 언어 > java' 카테고리의 다른 글
[Java] 도메인 계층 vs 서비스 계층 (0) | 2024.08.24 |
---|---|
[Java ] 3-Tier 아키텍처와 DTO: (0) | 2024.08.24 |
[Java] equals overriding 과 hash code (0) | 2024.08.11 |
[Java] final과 static: '불변'과 '공유'의 미학 (feat. JVM 메모리 구조) (0) | 2024.07.28 |
[Java] JVM 메모리 구조 파헤치기 (0) | 2024.07.28 |