자바에서 문자열 데이터를 편리하게 사용하기 위해 String 클래스를 지원하는데, 유의할 점이 몇 가지 있는 특별한 자료형이므로 이 참에 정리를 확실하게 해보려 한다.
먼저 String class는 기본형처럼 보이지만 객체이며 참조형이며 힙 영역에 생성된다.
String 생성 방법
- new String() String s1 = new String("hello");
- 리터럴 String s2 = "hello";
String 객체는 생성 방법에 따라 메모리 구조가 달라진다.
public class Test {
public static void main(String[] args) {
String s1 = new String("hello");
String s2 = new String("hello");
String s3 = "hello";
String s4 = "hello";
System.out.println("1, 2 동일성 비교 : " + (s1 == s2));
System.out.println("1, 2 동등성 비교 : " + (s1.equals(s2)));
System.out.println("3, 4 동일성 비교 : " + (s3 == s4));
System.out.println("3, 4 동등성 비교 : " + (s3.equals(s4)));
}
}
출력 결과
1, 2 동일성 비교 : false 1, 2 동등성 비교 : true 3, 4 동일성 비교 : true 3, 4 동등성 비교 : true
분명 String은 참조형 변수이므로 == 비교시 참조값이 달라 false가 나와야 하는데, new 로 생성한 String 간의 비교는 false가 나오고 리터럴로 생성한 두 변수간의 비교는 true가 나온다.
이러한 이유는 메모리 구조에 차이가 있기 때문이다.
메모리 구조
- new로 생성한 s1, s2 변수의 경우.
- → 힙 영역에 각각 인스턴스가 생성된다. (x001, x002)
- 리터럴로 생성한 s3, s4 변수의 경우
- 메모리의 효율을 위해 리터럴은 문자열 상수 풀에 String 인스턴스를 미리 생성하고 이를 재사용한다.
- 문자열 상수 풀 역시 힙 영역에 존재 → 풀 내부의 문자열 객체도 가비지 컬렉션의 대상.
- 리터럴로 String 생성 시 내부적으로 intern() 메서드를 호출 → 리터럴이 문자열 풀에 있는지 확인 후 있다면 이를 재사용, 없다면 문자열 풀에 인스턴스 생성 후 주소를 반환

실제로 위에서 String 클래스의 인스턴스 s1, s2, s3, s4에 참조값을 출력해보면 다음과 같다.
System.out.println("s1의 메모리 주소 : " + Integer.toHexString(System.identityHashCode(s1)));
System.out.println("s2의 메모리 주소 : " + Integer.toHexString(System.identityHashCode(s2)));
System.out.println("s3의 메모리 주소 : " + Integer.toHexString(System.identityHashCode(s3)));
System.out.println("s4의 메모리 주소 : " + Integer.toHexString(System.identityHashCode(s4)))
출력 결과
s1의 메모리 주소 : 3f99bd52
s2의 메모리 주소 : 4f023edb
s3의 메모리 주소 : 3a71f4dd
s4의 메모리 주소 : 3a71f4dd
s1과 s2는 서로 다른 참조 값이 나온 것에 비해 s3, s4는 서로 같은 참조값을 지니고 있으며 따라서 s3와 s4는 == 비교 시에도 true가 나오는 것이다.
불변
String으로 생성된 객체는 불변이다. → 변경 시에 기존 값이 변경되는 것이 아닌 새로운 String을 만들어서 반환
String은 왜 불변으로 설계되었을까?
위에서 말한대로 자바는 문자열 상수 풀에 String 인스턴스를 생성해놓고 이를 공유한다. 즉 만약 내부의 값이 변경된다면 s3를 변경했음에도 불구하고 s4까지 같이 변경되는 사이드 이펙트가 생길 수 있다.
→ 불변으로 설계하여 이를 방지!
String 에서 + 연산
String은 자바에서 특별히 참조형인데도 불구하고 + 연산을 지원한다.
public class Test {
public static void main(String[] args) {
String s1 = "hello " + "world";
System.out.println("더하기 전 : " + Integer.toHexString(System.identityHashCode(s1)));
s1 += "world";
System.out.println(s1);
System.out.println("더한 후 : " + Integer.toHexString(System.identityHashCode(s1)));
}
}
출력 결과
더하기 전 : a09ee92
hello world
더한 후 : 452b3a41
hello 에다가 + 연산으로 문자열이 합쳐진 것을 알 수 있고, 위에서 말했다시피 연산 결과 후에는 불변이라 새로운 인스턴스가 생성되어 참조값이 바뀐 것을 볼 수 있다.
- 리터럴 간의 + 연산 최적화
public class Test {
public static void main(String[] args) {
String s1 = "hello " + "world";
String s2 = "hello " + "world";
System.out.println("s1 더하기 전 : " + Integer.toHexString(System.identityHashCode(s1)));
System.out.println("s2 더하기 전 : " + Integer.toHexString(System.identityHashCode(s2)));
System.out.println(s1);
System.out.println(s2);
System.out.println("s1 더한 후 : " + Integer.toHexString(System.identityHashCode(s1)));
System.out.println("s2 더한 후 : " + Integer.toHexString(System.identityHashCode(s2)));
}
}
출력 결과
s1 더하기 전 : a09ee92
s2 더하기 전 : a09ee92
hello world
hello world
s1 더한 후 : a09ee92
s2 더한 후 : a09ee92
자바는 리터럴 간의 문자열 연산은 컴파일 시점에 확인하여 자동으로 합쳐준다.
즉 위 경우에서 s1은 컴파일러가 확인해서 “hello world” 라는 문자열로 합쳐주고 이를 문자열 상수 풀에 생성한다. s2는 문자열 상수 풀에 이미 생성되어 있는 “hello world”를 같이 참조를 하게 되어 둘의 참조값을 같은 값이 나오게 된다.
- String 변수 간의 + 연산
public class Test {
public static void main(String[] args) {
String s1 = "hello ";
String s2 = "world";
String result = s1 + s2;
System.out.println("s1 더하기 전 : " + Integer.toHexString(System.identityHashCode(s1)));
System.out.println("s2 더하기 전 : " + Integer.toHexString(System.identityHashCode(s2)));
System.out.println("result 더하기 전 : " + Integer.toHexString(System.identityHashCode(result)));
System.out.println(result);
System.out.println("s1 더한 후 : " + Integer.toHexString(System.identityHashCode(s1)));
System.out.println("s2 더한 후 : " + Integer.toHexString(System.identityHashCode(s2)));
System.out.println("result 더한 후 : " + Integer.toHexString(System.identityHashCode(result)));
}
}
출력 결과
s1 더하기 전 : a09ee92
s2 더하기 전 : 30f39991
result 더하기 전 : 452b3a41
hello world
s1 더한 후 : a09ee92
s2 더한 후 : 30f39991
result 더한 후 : 452b3a41
리터럴 간 연산과는 다르게 문자열 변수 간 + 연산의 경우에는 참조 값이 모두 다른 것을 확인할 수 있다.
이는 문자열 변수의 경우 컴파일 시점에 어느 값이 들어있는지 알 수가 없기 때문이고 따라서 hello, world, hello world가 모두 따로 생겼다.
문자열 간의 + 연산에 대해서 더 자세히 알아보면 다음과 같이 동작한다.
String result = new StringBuilder().append(s1).append(s2).toString();
즉 가변 String인 StringBuilder로 연산을 모두 진행 후 이를 불변인 String 타입으로 반환하는 것이다.
따라서 반복문에서 String 간 + 연산을 하게 될 경우 위와 같은 과정(반복 횟수 만큼 String 객체가 생성)이 반복해서 일어나므로 메모리 낭비와 성능 저하가 생기게 된다.
public class Test {
public static void main(String[] args) {
String s1 = "hello ";
String s2 = "world";
String result = "";
long start = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
result += s1 + s2;
}
long end = System.currentTimeMillis();
System.out.println("걸린 시간 : " + (end - start) + "ms");
}
}
출력 결과
걸린 시간 : 2692ms
→ 반복문에서 + 연산은 가변 StringBuilder를 사용하도록 할 것!
가변 StringBuilder
String과의 차이점
- 불변이 아닌 가변 String으로 값 변경 시에 새로운 객체가 생성되지 않는다. (→ 사이드 이펙트에 유의)
- 내부 구조
- String → private final byte[] value final로 정의된 바이트 배열을 가지고 있음
- StringBuilder → byte[] value 상위 AbstractStringBuilder 내부에 byte[] value을 가지고 있음
'JAVA' 카테고리의 다른 글
| [자바] Enum Class (0) | 2024.12.24 |
|---|---|
| [자바] Wrapper 클래스 (0) | 2024.12.24 |
| [자바] 오버로딩, 오버라이딩 (0) | 2024.12.24 |
| [자바] 상속, 추상클래스, 인터페이스 (0) | 2024.12.24 |
| [자바] 상수와 리터럴 (0) | 2024.12.24 |