본문 바로가기
  • Jetpack 알아보기
Java & Kotlin/ʕ•ᴥ•ʔ

[Java] String에 대한 고찰 : StringBuilder, StringBuffer, String Pool

by 새우버거♬ 2021. 9. 8.

Java에서는 문자열을 저장하는 자료형으로 String을 제공합니다. String 클래스에는 문자열 관련 작업을 할 때 유용한 다양한 메소드를 제공합니다. 대표적인 특징은 다음과 같습니다.

 

1. String은 불변 객체입니다. 인스턴스가 한 번 생성되면 그 값을 읽기만 가능하고, 수정이 불가능합니다.

2. String은 final class이기 때문에 상속받을 수 없습니다.

 

이러한 특징 때문에 Java는 문자열을 가변적으로 수정할 수 있도록 StringBuilder와 StringBuffer 클래스를 제공합니다. 이외에도 계속 생성되는 인스턴스로 인한 메모리 이슈를 생각하여 String Pool이라는 것도 고안했습니다.

 

이제부터 StringBuilder, StringBuffer 그리고 String Pool을 통해 String 인스턴스가 어떻게 저장되고, 효율적으로 가져올 수 있는지 알아보겠습니다.


Immutable Object

String는 불변 객체입니다. 우리는 보통 문자열을 초기화할 때 아래와 같이 코드를 작성합니다.

String str = "a";
str += "b";

Java의 메모리 구조에는 Stack과 Heap이 있습니다. 위의 코드를 실행하면 str 참조 변수는 Stack에 저장되고, "a" 라는 값은 Heap의 String Pool이라는 영역에 저장됩니다. 여기에 "b"를 더하면 str 변수가 참조하고 있는 "a"에 문자열 "b"를 더해 "ab"로 변경될 것이라고 생각할 수 있습니다.

 

하지만 실제로는 str은 "ab"라는 값을 저장한 새로운 메모리 영역을 참조하게 됩니다. String은 불변 객체이기 때문에 수정이 불가능하여 수정 대신에 새로운 인스턴스를 생성하는 것입니다. 따라서 기존의 "a" 데이터는 참조하는 변수가 없기 때문에(Unreachable) GC의 대상이 됩니다. 

String은 왜 불변 객체일까?
1. 캐싱 기능
2. 보안 기능
3. 스레드 안정성

StringBuilder & StringBuffer

String은 불변 객체이기 때문에 수정이 불가능합니다.  따라서 변하지 않는 문자열을 자주 읽을 경우에는 효율이 좋지만 문자열을 추가, 삭제하는 연산이 자주 일어날 경우에는 메모리에 가비지가 계속 쌓여 성능에 영향을 끼칠 수 있습니다.

 

이를 해결하기 위해 Java는 문자열을 수정, 삭제할 수 있는 StringBuilder, StringBuffer 클래스를 제공합니다. 클래스가 제공하는 append() 와 delete() 메소드를 사용하면 동일 객체 내에서 수정이 가능합니다.

StringBuilder builder = new StringBuilder();
builder.append("a");
builder.append("b");

 

StringBuilder와 StringBuffer는 같은 기능을 수행하지만 동기화 유무의 차이가 있습니다. StringBuffer는 동기화 키워드(synchronized)를 제공하기 때문에 멀티 스레드 환경에서 안전할 수 있습니다.

// StringBuilder
@Override
@HotSpotIntrinsicCandidate
public StringBuilder append(String str) {
    super.append(str);
    return this;
}

// StringBuffer
@Override
@HotSpotIntrinsicCandidate
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

 

즉, 특정 메소드 내부에서는 StringBuilder를 사용해도 괜찮습니다. 메소드 내부에서 생성되는 변수는 스택 공간에서 생성되고, 이는 여러 스레드 사이에서 공유되지 않습니다. 하지만 여러 스레드 사이에서 공유될 경우에는 StringBuffer를 사용해야 안전합니다.

String, StringBuffer, StringBuilder 비교


String Pool

String은 리터럴을 이용하거나 new 생성자를 통해 생성할 수 있다. new 생성자를 통해 생성할 경우 Heap 영역에 저장이 되고, 리터럴을 이용할 경우 String constant Pool 영역에 저장된다.

String s1 = "a"; // String pool에 저장
String s3 = new String("a"); // Heap에 저장

 

String Pool은 String 메모리를 효율적으로 사용하기 위한 별도의 영역으로 Heap 영역 안에 있습니다. 리터럴로 문자열을 생성하면 문자열 값과 그 참조 주소 값을 String Pool에 저장합니다. 따라서 그 다음에 똑같은 문자열을 생성하면 새롭게 만들어지지 않고, String Pool에 저장되어 있는 참조 주소 값을 가져올 수 있습니다.

String Pool

 

이것은 참조 주소 값을 비교하는 == 연산자를 통해 알 수 있습니다. s1과 s2는 동일하게 String Pool에 저장된 "a" 문자열의 참조 주소 값을 가지고 있으므로 true가 출력됩니다. 하지만 s3는 new 생성자를 통해 Heap 영역에 저장되므로 주소 값이 달라 false가 출력됩니다.

String s1 = "a";
String s2 = "a";
String s3 = new String("a");

System.out.print(s1 == s2); // true
System.out.print(s1 == s3); // false

 

intern() 메소드는 String Pool 안에 해당 리터럴이 있다면 그 값을 가져오고, 없다면 String Pool에 새로 추가하는 함수입니다. 따라서 이것은 리터럴와 new 생성자에서 차이가 나타납니다. 

String s1 = "a";
String _s1 = s1.intern(); // "a"는 String Pool에 있으므로 가져옴

String s2 = new String("b");
String _s2 = s2.intern(); // "b"는 String Pool에 없으므로 새로 추가

System.out.println(s1 == _s1); // true
System.out.println(s2 == _s2); // false

 

"a" 문자열은 이미 String Pool에 있으므로 같은 참조 주소 값을 가지게 됩니다. 하지만 "b" 문자열은 Heap 영역에 저장되어 있기 때문에 intern() 메소드는 "b"를 String Pool에 새롭게 생성합니다. 따라서 Heap 영역과 String Pool 영역의 참조 주소 값이 달라져 false를 출력합니다.


참고자료