본문 바로가기
Programming/Interview

String vs StringBuffer vs StringBuilder

by JKROH 2024. 1. 19.
반응형

 사실 String vs StringBuffer / StringBuilder라고 적는 편이 더 나을지도 모른다. String과 나머지 두 클래스 사이에는 큰 차이가 있지만, Buffer와 Builder사이에는 큰 차이가 없다.

 

 String은 불변 객체고, 나머지 두 객체는 가변 객체다. 여기서 두 객체 사이의 큰 차이가 만들어진다. 문자열을 더하는 단순한 연산을 생각해보자.

String a = "Hello";
a += " World";

System.out.println(a); // "Hello World"

 

 불변 객체는 말 그대로 한 번 선언되면 변하지 않는다. 예를 들어 final int num = 1;에 ++연산을 하면 수행되지 않는다. final키워드를 통해 불변으로 선언했기 때문이다. 그런데 불변 객체인 String에는 더하기 연산이 수행 가능하다. 그렇다고 a 인스턴스가 수정되는 것은 아니다. 대신, 새로운 String 인스턴스를 생성해 a에 할당한다. 다시 말해, "Hello"를 가리키던 인스턴스 대신, "Hello World"를 가리키는 인스턴스가 새로 생기는 것이다. 당연히 기존의 "Hello"는 GC대상이 된다. 여기서도 이미 손해가 발생한다.

 

Java 9 이전에는 String에 + 연산을 하면 컴파일러에서 내부적으로 StringBuilder 인스턴스를 하나 생성해 기존의 값에 append하고 String 객체로 바꿔 반환했다. 즉, 아래의 코드처럼 연산했다.

String a = "Hello";

a = new StringBuilder(a).append(" World").toString();

 

  당연히 String + 연산을 N 번 수행하면, N개의 StringBuilder 인스턴스가 생성되어야한다. 즉, N 번의 GC가 발생하는 것이다. 따라서 한 번 +하는 것은 비용면에서 큰 손해가 없을 수 있으나, 여러 번의 연산이 발생하면 그만큼 비용이 늘어난다.

 

 Java 9부터는 해당 방법 대신 StringConcatFactory의 makeConcatWithConstants()메서드를 이용해 String + 연산을 수행한다. StringBuilder를 계속 만들고 append하는 방식에서 정적 팩터리 메서드를 사용해 한 번에 처리하는 방식으로 전환되며 성능적으로 엄청난 개선을 이뤄냈다. 불필요한 인스턴스 생성을 줄였으니 당연한 결과다.

public static CallSite makeConcatWithConstants(MethodHandles.Lookup lookup,
                                               String name,
                                               MethodType concatType,
                                               String recipe,
                                               Object... constants) throws StringConcatException {
    if (DEBUG) {
        System.out.println("StringConcatFactory " + STRATEGY + " is here for " + concatType + ", {" + recipe + "}, " + Arrays.toString(constants));
    }

    return doStringConcat(lookup, name, concatType, false, recipe, constants);
}

 

 그럼에도 여전히 String에 + 연산을 그냥 사용하는 것보다는 StringBuilder#append연산을 하는 것이 훨씬 빠르다.

 

long startTime = System.currentTimeMillis();
String a = "Hello";
for(int i = 0 ; i< 10000;i++){
    a += i;
}
long endTime = System.currentTimeMillis();
System.out.println("use + operator : " + (endTime - startTime));

startTime = System.currentTimeMillis();
StringBuilder stringBuilder = new StringBuilder("Hello");
for(int i = 0 ; i< 10000;i++){
    stringBuilder.append(i);
}
endTime = System.currentTimeMillis();
System.out.println("use StringBuilder#append() : " + (endTime - startTime));

/////////////////////////

use + operator : 196
use StringBuilder#append() : 2

 

 불변 객체이기 때문에 새로운 객체를 생성한다는 차이에서 오는 연산 비용의 차이는 꽤 크다. 1만번밖에 수행하지 않았음에도 100배 가까운 차이가 났다.

 

 그럼 StringBuffer와 StringBuilder사이에는 무슨 차이가 있을까? 둘은 쓰레드 안정성에서 차이를 보인다. 예를 들어, 아래의 메서드는 어느 클래스에 정의된 메서드일까.

@Override
public synchronized StringBuffer append(Object obj) {
    toStringCache = null;
    super.append(String.valueOf(obj));
    return this;
}

 

 핵심은 synchronized 키워드에 있다. 해당 메서드는 쓰레드 동기화를 지원하는 메서드다. 위 메서드는 StringBuffer에 구현된 append이다. 반면 StringBuilder의 append는 어떨까.

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

 

 StringBuilder의 append는 synchronized 키워드를 사용하지 않는다. 즉, 멀티 쓰레드 환경에서 StringBuilder를 사용하면 race condition 문제가 발생할 수 있다.

 

 둘을 성능면에서 비교하면 당연히 StringBuilder가 앞선다. synchronized 메서드는 동기화를 위해 lock을 거는 연산이 추가적으로 필요하기 때문이다.

 

 이렇게 String과 StringBuffer / StringBuilder의 차이를 알아봤다. 정리하자면 다음과 같겠다.

  • 문자열을 수정하는 연산이 여러 번 발생하는 상황이라면 String을 나이브하게 사용하지 말자.
  • 만일 멀티쓰레드 환경에서 race condition의 발생이 우려된다면 StringBuffer를 사용해 쓰레드 안전하게 문자열 연산을 진행하자.
  • race condition이 발생하지 않는 경우라면 StringBuilder를 사용해 성능적인 이점을 취하도록 하자.
반응형

댓글