Background
백준 10869, 10926번을 풀고 보니 문자열을 합칠 때 '+' 연산자만 썼는데 '+' 연산자를 사용하면 String 객체가 계속 추가된다고 들었던 기억이 있어서 문자열을 합치는 방법과 성능에 대해 알아보고자 한다.
백준 10869번 문제풀이
2023.10.24 - [Baekjoon(JAVA)/Algorithm] - <Baekjoon(JAVA) - Algorithm> 10869번: 사칙연산
백준 10926번 문제풀이
2023.10.24 - [Baekjoon(JAVA)/Algorithm] - <Baekjoon(JAVA) - Algorithm> 10926번: ??!
String
String 클래스는 immutable(불변)하다는 특성이 있다.
String 클래스의 문자열을 저장하는 char[] 보면 final 로 선언되어 있다는 것을 확인할 수 있다.
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
...
}
+
그로 인해 한 번 할당한 문자열을 변경하는 것은 불가능하며 '+' 연산자를 사용하면 새로운 객체가 생성되어 재할당된다.
String s = "hello";
System.out.println(s.hashCode()); // 99162322
s += " world";
System.out.println(s.hashCode()); // 1794106052
즉, String 객체 "hello"에 " world"를 추가하면 새 String 객체가 생성되고 "hello world"가 할당된다.
원래 문자열인 "hello"는 변경되지 않는다.
반복적으로 문자열을 이어 붙이면 Heap 영역에 참조를 잃은 문자열 객체가 계속 쌓이게 된다.
나중에 GC에 의해 수거가 되지만, 메모리 관리 측면에서 이러한 코드는 결코 좋다고 할 수 없다.
또한, 계속해서 객체를 생성하기 때문에 연산 속도 측면에서도 뒤떨어진다.
이러한 String의 성능 이슈를 개선하기 위해 JDK 1.5 이상에서는 컴파일 단계에서 내부적으로 StringBuilder로 변경되어 동작된다.
JDK 1.5 이하에서는 concat을 이용하는 방식과 같다.
concat
String s1 = "hello";
System.out.println(s1.hashCode()); // 99162322
s1.concat(" world");
System.out.println(s1.hashCode()); // 1794106052
concat 명령어도 '+' 연산자를 사용했을 때와 같이 문자열을 붙일 때마다 문자열 객체가 계속 생성된다.
StringBuilder
StringBuilder 클래스는 mutable(가변)하다는 특성이 있다.
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence {
...
}
상속 받고 있는 AbstractStringBuilder 클래스의 내부를 보면 변경 가능하도록 선언되어 있다.
abstract class AbstractStringBuilder implements Appendable, CharSequence {
/**
* The value is used for character storage.
*/
char[] value;
...
}
append() 메소드를 호출하면 char[] 배열의 길이를 늘리고 같은 객체에 문자열을 더한다.
아래 코드를 보면 append() 호출 이후에도 StringBuilder 객체에 변함이 없음을 확인할 수 있다.
StringBuilder sb = new StringBuilder("hello");
System.out.println(sb.hashCode()); // 460141958
sb.append(" world");
System.out.println(sb.hashCode()); // 460141958
StringBuffer
synchronized가 적용되어 멀티스레드 환경에서 Thread-safe하게 동작할 수 있다.
즉, 동기화를 지원하는 StringBuilder라고 볼 수 있다.
아래 코드는 StringBuffer 클래스의 append() 메소드이다.
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
StringBuilder와 마찬가지로 append() 메소드를 호출하면 char[] 배열의 길이를 늘리고 같은 객체에 문자열을 더한다.
아래 코드를 보면 append() 호출 이후에도 StringBuilder 객체에 변함이 없음을 확인할 수 있다
StringBuffer sb = new StringBuffer("hello");
System.out.println(sb.hashCode()); // 1163157884
sb2.append(" world");
System.out.println(sb.hashCode()); // 1163157884
String vs StringBuilder
성능 비교
문자열을 합칠 때 자주 쓰이는 String '+' 연산자와 StringBuilder 클래스 사용 시의 두 성능을 비교해보자.
+
public class stringPlus {
public static void main(String[] args) {
String strPlus = "str";
int inputCnt[] = new int[]{
10000, 20000, 30000, 40000, 50000,
60000, 70000, 80000, 90000, 100000
};
for(int n : inputCnt) {
double startTime = System.nanoTime();
stringPlus(n, "", strPlus);
double endTime = System.nanoTime();
double duration = (endTime - startTime) / 1000000000;
String seconds = String.format("%.2f", duration);
System.out.println("n = " + n + ", seconds : " + seconds);
}
}
public static void stringPlus(int n, String str, String strPlus) {
for(int i = 1; i <= n; i++) {
str += strPlus;
}
}
}
/*
n = 10000, seconds : 0.50
n = 20000, seconds : 0.95
n = 30000, seconds : 1.34
n = 40000, seconds : 2.49
n = 50000, seconds : 1.16
n = 60000, seconds : 1.40
n = 70000, seconds : 1.74
n = 80000, seconds : 2.32
n = 90000, seconds : 2.51
n = 100000, seconds : 3.07
*/
StringBuilder
public class stringBuilderAppend {
public static void main(String[] args) {
String strAppend = "str";
int inputCnt[] = new int[]{
10000, 20000, 30000, 40000, 50000,
60000, 70000, 80000, 90000, 100000
};
for(int n : inputCnt) {
double startTime = System.nanoTime();
stringBuilderAppend(n, new StringBuilder(""), strAppend);
double endTime = System.nanoTime();
double duration = (endTime - startTime) / 1000000000;
String seconds = String.format("%.2f", duration);
System.out.println("n = " + n + ", seconds : " + seconds);
}
}
public static void stringBuilderAppend(int n, StringBuilder str, String strAppend) {
for(int i = 1; i <= n; i++) {
str.append(strAppend);
}
}
}
/*
n = 10000, seconds : 0.0019
n = 20000, seconds : 0.0027
n = 30000, seconds : 0.0018
n = 40000, seconds : 0.0019
n = 50000, seconds : 0.0037
n = 60000, seconds : 0.0040
n = 70000, seconds : 0.0037
n = 80000, seconds : 0.0023
n = 90000, seconds : 0.0030
n = 100000, seconds : 0.0040
*/
실행 시간
입력(n) | 1만 | 2만 | 3만 | 4만 | 5만 | 6만 | 7만 | 8만 | 9만 | 10만 |
+ | 0.50 | 0.95 | 1.34 | 2.49 | 1.16 | 1.4 | 1.74 | 2.32 | 2.51 | 3.07 |
StringBuilder | 0.0019 | 0.0027 | 0.0018 | 0.0019 | 0.0037 | 0.0040 | 0.0037 | 0.0023 | 0.0030 | 0.0040 |
(입력값이 많을수록 시간이 더 걸리기는 하는데, 내 노트북에서 중간에 속도가 조금 줄어드는지 모르겠다.)
성능 비교 결과
위의 그림으로 알 수 있듯이 입력이 많아질수록 문자열을 '+' 연산자로 더하는 방법 보다는 StringBuilder 클래스의 append() 메소드로 더하는 방법이 더 효율적이다.
즉, String 클래스보다 StringBuilder 클래스가 성능이 더 좋다.
JDK 1.5 이하, 이상
String의 성능 이슈를 개선하기 위해 JDK 1.5 이상에서는 컴파일 단계에서 내부적으로 StringBuilder로 변경되어 동작된다.
예를 들어, 아래와 같이 '+' 연산자를 이용한 코드는
String strPlus = str1 + str2 + str3;
내부적으로 아래와 같이 동작하게 된다.
String strPlusIn = new StringBuilder(String.valueOf(str1)).append(str2).append(str3).toString();
'+' 연산과 append() 메소드의 실질적인 성능 차이는 거의 없지만, 아래와 같은 코드는 매 loop마다 StringBuilder 객체가 생성되므로 JDK 1.5 이하 String의 '+' 연산자를 사용했을 때 처럼 불필요한 객체가 쌓이게 되고 성능에 악영향을 미친다.
for(int i = 0; i < 10000; i++) {
str += value;
}
결국 JDK 1.5 이상이더라도 StringBuilder 클래스를 직접적으로 사용하는 것이 나을 것 같다.
장단점 비교 (+, StringBuilder, StringBuffer)
+ | StringBuilder | StringBuffer | |
장점 | 가독성이 좋다. | 단일 스레드 환경에서 성능이 좋다. | 멀티 스레드 환경에서 성능이 좋다. (동기화 지원) |
단점 | 성능이 좋지 않다. | 가독성이 떨어질 수 있다. | 단일 스레드 환경에서 성능이 좋지 않다. |
결론
성능만 생각한다면 StringBuilder, StringBuffer, String 순이지만,
사용 편의성, 단일/멀티스레드 환경 등 여러가지 고려할 요인들이 있으므로 때에 따라 적합한 문자열 클래스를 사용하면 될 것 같다.
References
https://velog.io/@dnjscksdn98/Java-String-vs-StringBuilder-vs-StringBuffer
https://dev.to/this-is-learning/performance-benchmarking-string-and-string-builder-3bid
https://inseok9068.github.io/java/java-string-concat/