devseop08 님의 블로그

[API] StringBuffer, StringBuilder, StringJoiner 본문

Language/Java

[API] StringBuffer, StringBuilder, StringJoiner

devseop08 2025. 9. 15. 23:45

StringBuffer: mutable(가변) 문자열을 다루는 고전적인 클래스

기본 개념

  • 패키지: java.lang
  • 역할: 문자열을 수정 가능한 형태로 저장하고, 추가/삭제/수정 연산을 지원
  • 특징
    • 스레드 안전(Thread-safe): 주요 메서드들이 synchronized로 구현되어 있어서 멀티스레드 환경에서도 안전하게 사용 가능
    • 불변 객체인 String과 달리 StringBuffer는 객체 내에서 문자열을 수정하므로 String 객체의 + 연산 반복 시 생기는 임시 객체 생성 오버헤드를 피할 수 있다.

주요 생성자

StringBuffer()                // 초기 용량(capacity) 16
StringBuffer(int capacity)    // 지정 용량으로 생성
StringBuffer(String str)      // 초기 문자열을 지정
StringBuffer(CharSequence cs) // CharSequence 기반 생성

주요 메서드

메서드 설명
append(...) 문자열, 숫자, 객체 등을 이어 붙임
insert(int offset, ...) 지정 위치에 문자열 삽입
delete(int start, int end) 구간 문자열 삭제
deleteCharAt(int index) 특정 인덱스 문자 삭제
replace(int start, int end, String str) 구간을 다른 문자열로 치환
reverse() 문자열 뒤집기
capacity() 현재 버퍼 용량 반환
ensureCapacity(int minCapacity) 최소 용량 확보
charAt(int index) 특정 인덱스 문자 반환
setCharAt(int index, char ch) 특정 인덱스 문자 수정
toString() 최종 문자열 반환

사용 예시

public class Main {
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer("Hello");

        sb.append(" World");  
        System.out.println(sb); // Hello World

        sb.insert(5, ",");     
        System.out.println(sb); // Hello, World

        sb.replace(6, 11, "Java");
        System.out.println(sb); // Hello, Java

        sb.delete(5, 10);
        System.out.println(sb); // Hello

        sb.reverse();
        System.out.println(sb); // olleH
    }
}

성능과 활용

  • StringBuffer는 스레드 안전하지만 상대적으로 느리다.
  • 단일 스레드 환경에서는 StringBuilder 사용이 권장된다.(StringBuilder는 동기화하지 않으므로 더 빠르다)
  • 문자열이 자주 변하는 경우(삽입/수정/삭제) String 대신 StringBuffer 또는 StringBuilder를 쓰는 것이 효율적
클래스 불변 여부 스레드 안전성 성능
String 불변(immutable) 안전(읽기 전용이라) 가장 느림 (수정 시 객체 재생성)
StringBuffer 가변(mutable) 스레드 안전 (synchronized) StringBuilder보다 느림
StringBuilder 가변(mutable) 스레드 안전하지 않음 가장 빠름

StringBuffer 객체 비교

  1. StringBufferequals 메서드

    • StringBufferequals 메서드를 오버라이드하지 않는다.
    • StringBufferequalsObjects.equals 그대로이기 때문에 객체의 참조 동일성(==)만 비교하고 객체의 내용은 비교하지 않는다.
    • 내용이 같더라도 서로 다른 인스턴스라면 equals 메서드의 반환값은 false이다.
  2. 왜 그럴까?

    • StringBuffer 클래스는 가변이고 내용이 바뀌면 객체의 해시/동등성의 의미가 불안정해진다.
    • 따라서 애초에 내용 동등성 계약을 제공하지 않고 아이덴티티(참조)만 비교하도록 두었다.
    • 이에 따라 해시도 마찬가지로 아이덴티티 기반의 Objects.hashCode를 그대로 쓴다. => Map의 키 값 타입으로 사용하면 위험하다.
  3. 올바른 StringBuffer 객체 내용 비교 방법

  • toString() 후 비교: 새 String 객체를 만들기 때문에 대량/고빈도 비교에서는 할당 비용이 크다.
StringBuffer sb1 = new StringBuffer("abc");
StringBuffer sb2 = new StringBuffer("abc");
boolean same = sb1.toString().equals(sb2.toString()); // true
  • String 클래스의 contentEquals(CharSequence) 활용(권장): contentEquals는 인자가 CharSequence면 문자 단위로 직접 순회 비교하기 때문에 "이미 String이 한 쪽에 있는 상황"에서 추가 할당을 막을 수 있다.
StringBuilder a = new StringBuilder("hello");
StringBuffer  b = new StringBuffer("hello");

boolean same = "hello".contentEquals(a); // true
boolean same2 = a.toString().contentEquals(b); // true
  • 공통 유틸로 CharSequence끼리 비교 => 무할당, 새 String 객체를 만들지 않고 CharSequence 인터페이스로 직접 비교
static boolean contentEquals(CharSequence x, CharSequence y) {
    if (x == y) return true;
    if (x == null || y == null) return false;
    int n = x.length();
    if (n != y.length()) return false;
    for (int i = 0; i < n; i++) {
        if (x.charAt(i) != y.charAt(i)) return false; // UTF-16 코드 유닛 기준
    }
    return true;
}

불변성: StringBuffer는 불변 클래스가 아닌 가변 클래스다.

  1. 불변(Immutable) 클래스의 특징
    • 한 번 생성된 객체의 상태(내부 값)가 절대 변하지 않는다.
    • 대표 예시: String, Integer, LocalDateTime
String s = "Hello";
s.concat("World");
System.out.println(s); // 여전히 "Hello"

concat이 새로운 "HelloWorld" 객체를 만들고 반환할 뿐, 기존 "Hello"는 변하지 않음.

  1. StringBuffer 객체 상태 특징
    • 내부적으로 byte[] 배열을 관리하고 문자열 수정 시 배열 내용 자체를 변경한다.
    • 따라서 동일 객체 안에서 값이 바뀐다. -> 가변(mutable)
StringBuffer sb = new StringBuffer("Hello");
sb.append("World");
System.out.println(sb); // HelloWorld

-> 새로운 객체가 만들어진 게 아니라, 기존 sb의 내부 배열에 "World"가 추가된 것이다.

  • StringBuffer는 멀티스레드 환경에서 안전하게 문자열을 수정할 수 있도록 synchronized가 붙은 가변 클래스다.

StringBuffer가 가변 클래스임에도 final로 선언된 이유

public final class StringBuffer  
    extends AbstractStringBuilder  
    implements Appendable, Serializable, Comparable<StringBuffer>, CharSequence  
{
    ...
}
  • 스레드 안전 계약 유지, 구현/최적화의 자유 보장, 그리고 보안/호환성
  1. 스레드 안전 계약 보호
    • StringBuffer의 핵심 가치는 모든 주요 연산이 동기화되어 있어 멀티스레드에서도 안전한다는 점이다.
    • 만약 누군가가 하위 클래스로 append, insert 등을 동기화 없이 재정의하면, 외부에서는 여전히 “StringBuffer니까 thread-safe겠지”라고 믿고 쓰다가 계약(Contract) 위반이 발생한다.
    • final은 이런 “계약 파괴형 상속” 자체를 원천 차단한다.
  2. JIT 최적화·가정 유지
    • 핫스팟 JIT은 StringBuffer 연산이 항상 동기화된다는 전제하에 인라이닝/탈가상화 같은 최적화를 적용하기 쉽다.
    • 하위 클래스가 생기면 오버라이드 가능성 때문에 호출 지점이 다양해져(다형성) 최적화가 제한되거나, 동작 가정을 깰 위험이 생긴다. final은 이 위험을 없앤다.
  3. 구현 세부 변경의 자유(후방 호환)
    • StringBufferAbstractStringBuilder를 기반으로 내부 표현(버퍼, 용량 증가 전략 등)이 자바 버전에 따라 최적화될 수 있다.
    • 상속을 허용하면 하위 클래스가 내부 동작에 의존하게되고, JDK가 내부를 바꾸기 어려워집니다. final내부 바꿔도 API 계약만 지키면 OK라는 자유를 준다.
  4. 보안·안전성 강화
    • 하위 클래스가 “부분적으로만 동기화”하거나, 예외 시 일관성 깨진 상태를 외부에 노출하게 만들 수도 있다.
    • 또한 직렬화/역직렬화·toString() 캐시 등 섬세한 경계에서 하위 클래스가 정보 노출/무결성 훼손을 만들 수 있다. final은 이런 공격면을 줄인다.
  5. 일관성: StringBuilderfinal
    • 비동기화 버전인 StringBuilderfinal
    • 둘 다 “설계 의도(동기화 있음/없음) + 성능 특성”을 상속으로 바꿀 수 없게 고정해, 사용자가 클래스 이름만 보고도 동작을 확실히 추론하게 한다.
  • final class로 상속 금지 여부를 결정하는 것은 객체가 불변이든 가변이든 상속을 막을 이유가 충분하다면 final로 설계된다.
  • StringBufferfinal로 막은 건 불변성 때문이 아니라 스레드 안전 계약 보존, JIT 최적화, 내부 구현 변화의 자유, 보안/호환성을 위한 설계를 하기 위한 선택이다.

StringBuilder

기본 개념

  • 패키지: java.lang
  • 역할: StringBuffer와 거의 동일하게 가변(mutable) 문자열을 다루는 클래스
  • 차이점: StringBuffer는 모든 메서드가 synchronized -> 스레드 안전하지만 상대적으로 느리다. StringBuilder는 동기화를 제거해서 스레드 안전하지 않지만 더 빠르다.
  • 추가된 시기: Java 5 (JDK 1.5)

생성자

StringBuilder()                // 초기 용량(capacity) 16
StringBuilder(int capacity)    // 지정 용량으로 생성
StringBuilder(String str)      // 초기 문자열 지정
StringBuilder(CharSequence cs) // CharSequence 기반 생성

주요 메서드

  • StringBuilderStringBuffer와 마찬가지로 equals 메서드를 오버라이드하지 않는다.
메서드 설명
append(...) 문자열, 숫자, 객체 등을 이어 붙임
insert(int offset, ...) 지정 위치에 문자열 삽입
delete(int start, int end) 구간 삭제
deleteCharAt(int index) 특정 인덱스 문자 삭제
replace(int start, int end, String str) 구간을 다른 문자열로 치환
reverse() 문자열 뒤집기
capacity() 버퍼 용량 반환
ensureCapacity(int minCapacity) 최소 용량 확보
setCharAt(int index, char ch) 특정 인덱스 문자 변경
toString() 최종 문자열 생성

사용 예시

public class Main {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder("Hello");

        sb.append(" World");  
        System.out.println(sb); // Hello World

        sb.insert(5, ",");
        System.out.println(sb); // Hello, World

        sb.replace(7, 12, "Java");
        System.out.println(sb); // Hello, Java

        sb.delete(5, 11);
        System.out.println(sb); // Hello

        sb.reverse();
        System.out.println(sb); // olleH
    }
}

컴파일 시의 String 객체 덧셈 연산 최적화: StringBuilder 이용

  • StringBuilder는 내부적으로 가변적인 char[] 버퍼를 가지고 있기 때문에 append()할 때마다 새로운 객체를 만들 필요가 없다.
StringBuilder sb = new StringBuilder();
sb.append("a");
sb.append("b");
sb.append(variable);
String result = sb.toString();
  • StringBuilder 객체 하나만 생성되고, 내부 char[]에 직접 이어 붙이는 식으로 처리된다.
  • 최종 toString()에서 단 한 번 String 객체를 만들 뿐이다.
  • StringBuilder가 도입된 Java 5부터 javac 자바 컴파일러가 문자열 연결 최적화를 StringBuilder를 기반으로 수행하기 시작했다.
  • StringBuilder는 동기화(synchronized)가 없는, 가벼운 가변 문자열 버퍼
  • Java 5부터 javac는 컴파일 시 문자열의 + 연산을 전부 StringBuilder의 append 체인으로 변경한다.

StringJoiner

String 문자열 변환

  1. String 클래스 valueOf 메서드
// String.java

public static String valueOf(boolean b) {  
    return b ? "true" : "false";  
}

public static String valueOf(char c) {  
    if (COMPACT_STRINGS && StringLatin1.canEncode(c)) {  
        return new String(StringLatin1.toBytes(c), LATIN1);  
    }  
    return new String(StringUTF16.toBytes(c), UTF16); // 방어적 복사본 반환
}

public static String valueOf(int i) {  
    return Integer.toString(i);  
}

public static String valueOf(long l) {  
    return Long.toString(l);  
}

public static String valueOf(float f) {  
    return Float.toString(f);  
}

public static String valueOf(double d) {  
    return Double.toString(d);  
}
// Integer.java

public static String toString(int i) {  
    int size = stringSize(i);  
    if (COMPACT_STRINGS) {  
        byte[] buf = new byte[size];  
        StringLatin1.getChars(i, size, buf);  
        return new String(buf, LATIN1);  
    } else {  
        byte[] buf = new byte[size * 2];  
        StringUTF16.getChars(i, size, buf);  
        return new String(buf, UTF16);  // 방어적 복사본 반환
    }  
}
// Long.java

public static String toString(long i) {  
    int size = stringSize(i);  
    if (COMPACT_STRINGS) {  
        byte[] buf = new byte[size];  
        StringLatin1.getChars(i, size, buf);  
        return new String(buf, LATIN1);  
    } else {  
        byte[] buf = new byte[size * 2];  
        StringUTF16.getChars(i, size, buf);  
        return new String(buf, UTF16);  // 방어적 복사본 반환
    }  
}
  • 사용 예시: 숫자를 문자열로 변환하기
int i = 100;
String str1 = i + "";
String str2 = String.valueOf(i);
  1. String 클래스 join 메서드
// String.java

public static String join(CharSequence delimiter, CharSequence... elements) {  
    var delim = delimiter.toString();  
    var elems = new String[elements.length];  
    for (int i = 0; i < elements.length; i++) {  
        elems[i] = String.valueOf(elements[i]);  
    }  
    return join("", "", delim, elems, elems.length);  
}

public static String join(CharSequence delimiter,  
        Iterable<? extends CharSequence> elements) {  
    Objects.requireNonNull(delimiter);  
    Objects.requireNonNull(elements);  
    var delim = delimiter.toString();  
    var elems = new String[8];  
    int size = 0;  
    for (CharSequence cs: elements) {  
        if (size >= elems.length) {  
            elems = Arrays.copyOf(elems, elems.length << 1);  
        }  
        elems[size++] = String.valueOf(cs);  
    }  
    return join("", "", delim, elems, size);  
}

    static String join(String prefix, String suffix, String delimiter, 
                            String[] elements, int size) {  
    int icoder = prefix.coder() | suffix.coder();  
    long len = (long) prefix.length() + suffix.length();  
    if (size > 1) { 
    // when there are more than one element, size - 1 delimiters will be emitted 
        len += (long) (size - 1) * delimiter.length();  
        icoder |= delimiter.coder();  
    }  
    // assert len > 0L; // max: (long) Integer.MAX_VALUE << 32  
    // following loop will add max: 
    // (long) Integer.MAX_VALUE * Integer.MAX_VALUE to len   
    // so len can overflow at most once    
    for (int i = 0; i < size; i++) {  
        var el = elements[i];  
        len += el.length();  
        icoder |= el.coder();  
    }  
    byte coder = (byte) icoder;  
    // long len overflow check, char -> byte length, int len overflow check  
    if (len < 0L || (len <<= coder) != (int) len) {  
        throw new OutOfMemoryError("Requested string length exceeds VM limit");  
    }  
    byte[] value = StringConcatHelper.newArray(len);  

    int off = 0;  
    prefix.getBytes(value, off, coder); off += prefix.length();  
    if (size > 0) {  
        var el = elements[0];  
        el.getBytes(value, off, coder); off += el.length();  
        for (int i = 1; i < size; i++) {  
            delimiter.getBytes(value, off, coder); off += delimiter.length();  
            el = elements[i];  
            el.getBytes(value, off, coder); off += el.length();  
        }  
    }  
    suffix.getBytes(value, off, coder);  
    // assert off + suffix.length() == value.length >> coder;  

    return new String(value, coder);  // 방어적 복사본 반환
}
  • 사용 예시: 여러 문자열 사이에 구분자를 넣어서 결합
String animals = "dog, cat, bear";
String[] arr = animals.split(","); ["dog", "cat", "bear"]
String str = String.join("-", arr);
System.out.println(str); dog-cat-bear
  1. Integer 클래스 valueOf, parseInt 메서드
// Integer.java

public static Integer valueOf(String s, int radix) throws NumberFormatException {  
    return Integer.valueOf(parseInt(s,radix));  
}

public static Integer valueOf(int i) {  
    if (i >= IntegerCache.low && i <= IntegerCache.high)  
        return IntegerCache.cache[i + (-IntegerCache.low)];  
    return new Integer(i);  
}

public static int parseInt(String s) throws NumberFormatException {  
    return parseInt(s, 10);  
}

public static int parseInt(String s, int radix)  
            throws NumberFormatException {

    if (s == null) {  
    throw new NumberFormatException("Cannot parse null string");  
    }  

    if (radix < Character.MIN_RADIX) {  
        throw new NumberFormatException(String.format(  
            "radix %s less than Character.MIN_RADIX", radix));  
    }  

    if (radix > Character.MAX_RADIX) {  
        throw new NumberFormatException(String.format(  
            "radix %s greater than Character.MAX_RADIX", radix));  
    }  

    int len = s.length();  
    if (len == 0) {  
        throw NumberFormatException.forInputString("", radix);  
    }  
    int digit = ~0xFF;  
    int i = 0;  
    char firstChar = s.charAt(i++);  
    if (firstChar != '-' && firstChar != '+') {  
        digit = digit(firstChar, radix);  
    }  
    if (digit >= 0 || digit == ~0xFF && len > 1) {  
        int limit = firstChar != '-' ? MIN_VALUE + 1 : MIN_VALUE;  
        int multmin = limit / radix;  
        int result = -(digit & 0xFF);  
        boolean inRange = true;  
        /* Accumulating negatively avoids surprises near MAX_VALUE */  
        while (i < len && (digit = digit(s.charAt(i++), radix)) >= 0  
                && (inRange = result > multmin  
                    || result == multmin && digit <= radix * multmin - limit)) {  
            result = radix * result - digit;  
        }  
        if (inRange && i == len && digit >= 0) {  
            return firstChar != '-' ? -result : result;  
        }  
    }  
    throw NumberFormatException.forInputString(s, radix);            
}
  • 사용 예시: 문자열을 숫자로 변환
int i = Integer.parseInt("100");
int i2 = Integer.valueOf("100");
Integer i3 = Integer.valueOf("100");
  1. Long 클래스 valueOf, parseLong 메서드
// Long.java

public static Long valueOf(String s, int radix) throws NumberFormatException {  
    return Long.valueOf(parseLong(s, radix));  
}

public static Long valueOf(long l) {  
    final int offset = 128;  
    if (l >= -128 && l <= 127) { // will cache  
        return LongCache.cache[(int)l + offset];  
    }  
    return new Long(l);  
}

public static long parseLong(String s) throws NumberFormatException {  
    return parseLong(s, 10);  
}

public static long parseLong(String s, int radix)  
            throws NumberFormatException {  
    if (s == null) {  
        throw new NumberFormatException("Cannot parse null string");  
    }  

    if (radix < Character.MIN_RADIX) {  
        throw new NumberFormatException(String.format(  
            "radix %s less than Character.MIN_RADIX", radix));  
    }  

    if (radix > Character.MAX_RADIX) {  
        throw new NumberFormatException(String.format(  
            "radix %s greater than Character.MAX_RADIX", radix));  
    }  

    int len = s.length();  
    if (len == 0) {  
        throw NumberFormatException.forInputString("", radix);  
    }  
    int digit = ~0xFF;  
    int i = 0;  
    char firstChar = s.charAt(i++);  
    if (firstChar != '-' && firstChar != '+') {  
        digit = digit(firstChar, radix);  
    }  
    if (digit >= 0 || digit == ~0xFF && len > 1) {  
        long limit = firstChar != '-' ? MIN_VALUE + 1 : MIN_VALUE;  
        long multmin = limit / radix;  
        long result = -(digit & 0xFF);  
        boolean inRange = true;  
        /* Accumulating negatively avoids surprises near MAX_VALUE */  
        while (i < len && (digit = digit(s.charAt(i++), radix)) >= 0  
                && (inRange = result > multmin  
                    || result == multmin && digit <= (int) (radix * multmin - 
                              limit))) {  
            result = radix * result - digit;  
        }  
        if (inRange && i == len && digit >= 0) {  
            return firstChar != '-' ? -result : result;  
        }  
    }  
    throw NumberFormatException.forInputString(s, radix);  
}

StringJoiner 클래스

  • StringJoiner는 Java 8부터 추가된 문자열 결합 유틸리티 클래스이다.
  • 여러 문자열을 연결하면서 구분자(delimeter), 접두사(prefix), 접미사(suffix)를 지정할 수 있기 때문에 반복문에서 문자열을 더하거나 StringBuilder를 직접 다루는 것보다 훨씬 깔끔하게 문자열을 조합할 수 있게 도와준다.

기본 개념

  • 패키지: java.util
  • 핵심 역할: 지정한 구분자와 선택적인 접두사/접미사를 사용해 문자열을 효율적으로 결합한다.

생성자

StringJoiner(CharSequence delimeter)
StringJoiner(CharSequence delimeter, CharSequence prefix, CharSequence suffix)
  • delimiter: 요소 간에 넣을 구분자
  • prefix: 결과 문자열 앞에 붙일 접두사
  • suffix: 결과 문자열 뒤에 붙일 접미사주요 메서드
메서드 설명
add(CharSequence newElement) 새로운 요소를 추가
merge(StringJoiner other) 다른 StringJoiner 내용을 현재 객체에 병합
setEmptyValue(CharSequence emptyValue) 요소가 없을 경우 반환할 기본 문자열을 지정
toString() 최종 문자열 생성

사용 예시

  • 단순 구분자 사용
StringJoiner sj = new StringJoiner(", ");
sj.add("Java").add("Kotlin").add("Scala");
System.out.println(sj.toString()); // Java, Kotlin, Scala
  • 접두사/ 접미사 포함
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("A").add("B").add("C");
System.out.println(sj.toString()); // [A, B, C]
  • 요소가 없을 때 기본값 지정
StringJoiner sj = new StringJoiner(", ");
sj.setEmptyValue("EMPTY");
System.out.println(sj.toString()); // EMPTY
  • merge 사용
StringJoiner sj1 = new StringJoiner(", ");
sj1.add("One").add("Two");

StringJoiner sj2 = new StringJoiner(", ");
sj2.add("Three").add("Four");

sj1.merge(sj2);
System.out.println(sj1.toString()); // One, Two, Three, Four

관련 클래스와 비교

  • StringBuilder: 더 낮은 수준에서 문자열 조작 -> 유연하지만 구분자, 접두/접미사 처리는 직접 해야한다.
  • String.join(): 간단히 배열이나 컬렉션을 결합할 때 편리 -> 접두/접미사 기능은 없다.
  • Collertors.joining()(Stream API): 스트림 요소를 조합할 때 권장되는 방식이다. 내부적으로 StringJoiner를 활용한다.
public static Collector<CharSequence, ?, String> joining(CharSequence delimiter,  
                                                         CharSequence prefix,  
                                                         CharSequence suffix) {  
    return new CollectorImpl<>(  
            () -> new StringJoiner(delimiter, prefix, suffix),  
            StringJoiner::add, StringJoiner::merge,  
            StringJoiner::toString, CH_NOID);          
    // Collector 인터페이스 구현체 반환
}
List<String> list = Arrays.asList("a", "b", "c");
String result = list.stream().collect(Collectors.joining(", ", "{", "}"));
System.out.println(result); // {a, b, c}
  • StringJoiner는 단순 문자열 더하기보다 가독성, 구조적 제어(구분자/접두/접미사/빈 값 처리), 성능까지 고려된 고수준 API라고 볼 수 있다.불변성: StringJoiner는 불변 클래스가 아닌 가변 클래스이다.
  1. 왜 불변이 아닌가?
    • StringJoiner는 내부적으로 String[] 버퍼를 사용한다.
    • add(), merge()를 호출할 때마다 String[] 버퍼가 직접 변경된다.
    • 즉, 같은 StringJoiner 객체에 계속 요소를 추가하면 그 객체의 상태가 변경된다.
public final class StringJoiner {  
    private static final String[] EMPTY_STRING_ARRAY = new String[0];  

    private final String prefix;  
    private final String delimiter;  
    private final String suffix;  

    /** Contains all the string components added so far. */  
    private String[] elts; // 내부 버퍼: final이 아니다.

    /** The number of string components added so far. */  
    private int size;  

    /** Total length in chars so far, excluding prefix and suffix. */  
    private int len;  

    private String emptyValue;
public StringJoiner add(CharSequence newElement) {  
    final String elt = String.valueOf(newElement);  
    if (elts == null) {  
        elts = new String[8];  // 객체 상태 변경
    } else {  
        if (size == elts.length)  
            elts = Arrays.copyOf(elts, 2 * size);  // 객체 상태 변경
        len = checkAddLength(len, delimiter.length());  
    }  
    len = checkAddLength(len, elt.length());  
    elts[size++] = elt;  
    return this;  
}
String sj = new StringJoiner(", ");
sj.add("A");
System.out.println(sj); // A
sj.add("B");
System.out.println(sj); // A, B
  1. 불변 클래스와 비교
    • String은 불변 -> concat() 메서드를 호출해도 원래 객체는 그대로 두고 새로운 객체를 반환
    • StringJoiner는 가변 -> add() 호출 시 기존 객체 자체가 수정

스레드 안정성: StringJoiner는 스레드 안전하지 않다.

  • StringJoiner는 불변 클래스가 아니고 어떤 메서드도 synchronized가 아니기 때문에 동시에 여러 스레드가 같은 인스턴스에 add()/merge()를 호출하면 데이터 경합과 깨진 출력이 발생할 수 있다.
  • 스레드 안전하게 사용하는 방법
    • 같은 인스턴스 공유 금지
    • 필요하면 스레드별로 로컬 생성 후 합치기 또는 외부 동기화
    • 단일 스레드: StringBuilder가 가장 빠르다.
    • 멀티 스레드에서 공유가 필요한 경우: StringBuffer 사용을 고려
    • 스트림: Collectors.joining(delim, prefix, suffix)를 사용하면 파이프라인 수준에서 병렬 안전하게 동작하나 여전히 같은 StringJoiner 인스턴스를 직접 공유하면 안 된다.
  • 메모리 가시성
    • 조합이 끝난 뒤 toString()으로 얻은 String은 불변이라 여러 스레드가 읽어도 안전
    • 하지만 조합 중인 StringJoiner를 공유하면 가시성과 원자성 모두 보장되지 않는다.

StringBuilder 사용 버전(java8)과 String[] 버퍼 사용 버전(java9 이후) 비교

  • StringJoiner 클래스는 최초 버전인 Java 8 도입 당시 구현부터 내부 상태가 변경되는 가변 클래스였다.
  • JDK 8(초기 도입): 내부에 private StringBuilder value;를 두고 add()가 그 빌더에 계속 붙이는 구조 → 명백히 가변.
  • JDK 9 최적화: 빌더 대신 String[] elts, int size, int len 등을 두고, toString()에서 한 번에 결과를 조립하도록 변경(성능 개선 목적). 여전히 add()가 내부 배열과 길이 카운터를 갱신하므로 가변.
  • StringJoiner는 API 설계상 요소를 점진적으로 추가하는 용도이므로, 설계 자체가 가변을 전제한다.
  • 버전이 업그레이드되면서 바뀐 것은 “어떻게 저장/조립하느냐(StringBuilder → 배열/길이 누적)”이지, 가변성 여부가 아니다.
  • StringJoiner 클래스 JDK 8 버전 vs StringJoiner 클래스 JDK 9 버전
      1. 내부 상태
        • JDK 8: StringBuilder 하나에 계속 붙이는 구조
        • JDK 9: 요소들을 배열에 모아두고(elts, size, len) 최종 단계에서 한 번에 문자열 생성
      1. add(...) 동작
        • JDK 8: add(newElement)prepareBuilder().append(newElement) → (첫 원소면 prefix를 넣고, 그 이후엔 delimiter를 먼저 붙인 뒤 요소 append)
        • JDK 9: add(newElement) → 문자열로 바꿔 배열 elts에 저장, 길이 누계 len 갱신(첫 요소가 아니면 delimiter.length()도 더함). 실제 바이트/문자 복사는 안 함(최종 생성 때 한 번에).
      1. toString() 동작
        • JDK 8: 빈 상태면 emptyValue 반환. 아니면 value.append(suffix)로 결과를 만들고 다시 길이를 되돌려(suffix를 되빼서) 내부 버퍼 유지.
          (코드 흐름: value.append(suffix).toString();value.setLength(initialLength);)
        • JDK 9: 필요한 총 길이만큼 String[]를 만들고 prefix → elts[0..n-1] 사이사이에 delimiter → suffix한 번에 복사. 마지막엔 jla.newStringUnsafe(chars)String 생성.
  • 변경 목적: 불필요한 중간 문자열/버퍼 조작을 줄여 GC 압력과 복사 비용을 낮추기 위함이다. 이 변경은 JDK 9에 반영된 구현 최적화다.