devseop08 님의 블로그

[API] String 본문

Language/Java

[API] String

devseop08 2025. 9. 6. 12:18

문자열 상수(리터럴)와 String

  • 문자열의 본질은 문자 배열이며 문자열은 인코딩 규칙에 영향을 받는다.
    • char[], String
    • 문자배열은 겹따옴표를 이용한 리터럴 표기 가능
    • 자바 9 이후 char[]에서 byte[]로 변경
  • String 클래스는 불변 클래스이며 논리적 의미로 기본 형식에 속하는 특성을 보인다.
    • 덧셈 연산의 결과로 임시 객체가 생기는 문제 발생
    • 큰 문자열을 다룰 경우 효율이 더 떨어짐.

String 객체 덧셈 연산 시 보이지 않는 임시 객체

보이지 않는 임시 객체: 연산 도중에 생성되어 식별자가 별도로 존재하지 않는 익명의 임시 객체

  • 클래스가 함수의 반환 자료형인 경우 식별자가 없는 임시 객체가 생성된다.
  • String 타입의 객체를 덧셈 연산 시, 덧셈 연산마다 매번 각각의 보이지 않는 임시 객체를 생성한다. => 비효율의 직접적인 원인. 큰 문자열일수록 효율 급감
String variable = "A";
String result = variable + "B" + "C"; 
// 보이지 않는 임시 객체 "AB"와 보이지 않는 임시 객체 "ABC"가 생성 
// "A", "B", "C", "AB", "ABC"가 서로 다른 String 객체로 힙에 존재

String 객체 덧셈 연산 시 임시 객체 생성의 비효율성

  • String 클래스는 불변이기 때문에 String 객체는 한 번 생성되면 내부의 char[]를 바꿀 수 없다.(고정 크기)
  • "a" + variable을 단순히 String끼리 이어붙이는 방식으로 구현하면, 연산할 때마다 새로운 String 객체와 새로운 char[] 배열을 할당해야 한다. => 성능 저하와 메모리 낭비, GC 부담
String s1 = "a";
String s2 = s1 + "b";   // 새로운 "ab" 생성 (기존 "a"는 그대로)
String s3 = s2 + "c";   // 또 새로운 "abc" 생성

JVM이 문자열을 관리하는 구조: 모든 문자열은 상수 풀로 관리

  • C/C++로 개발된 PE 파일과 유사한 구조
    • .exe 파일의 내부에 문자열이 포함
    • 실행 코드가 저장되는 정적 메모리 영역에 문자열 상수 저장
    • 같은 문자열 상수에 대한 포인터의 주소는 모두 동일
  • 코드 상 존재하는 모든 문자열 상수(리터럴)는 Class가 로딩될 때 메서드 영역에 존재하는 Runtime constant pool에 등록된 후 힙 영역에 존재하는 String constant pool에도 추가

두 개의 상수 풀: Runtime constant pool과 String constant pool

  • 문자열 상수(string literal)는 항상 String 객체이다
    • .class 파일 Constant pool에 저장(컴파일)
    • Runtime constant pool로 이동(로딩)
    • String constant pool로 이동(실행)

String 클래스의 intern 메서드: 두 개의 상수 풀에 등록되지 않은 문자열 상수를 String constant pool에 등록

public class Main {
    String s1 = "Hello"; // 두 개의 상수 풀 모두에 "Hello" 객체를 저장
    String s2 = "Hello";
    System.out.println(s1 == s2); // true 
    // s1과 s2 모두 String constant pool에 저장된 동일한 하나의 "Hello" 객체 참조

    String s3 = new String("World"); 
    // "World"는 두 개의 상수 풀에 저장되지 않는다. 
    // "World"는 힙 영역 어딘가에 존재한다.
    String s4 = s3.intern(); 
    // 힙 영역 어딘가에 저장된 "World"를 String constant pool에도 저장 후
    // String constant pool에 저장된 "World"에 대한 참조값 반환
    System.out.println(s3 == s4); // false
    System.out.println("World" == s3); // false
    System.out.println("World" == s4); // true
}
  • String 객체가 불변 객체여야만 하는 이유 중 하나는 문자열 상수(리터럴)가 두 개의 상수 풀에 저장되어 공유되기 때문이다.

String 클래스 불변성

  • private final 멤버
// String.java
private final byte[] value;

private final byte coder;  

private static final long serialVersionUID = -6849794470754667710L;

static final boolean COMPACT_STRINGS;  

static {  
    COMPACT_STRINGS = true;  
}

private static final ObjectStreamField[] serialPersistentFields =  
    new ObjectStreamField[0];
  • final 선언되지 않은 멤버 : hashhashIsZero
    • hash/hashIsZerofinal이 아니어도, 이는 내용(문자 시퀀스) 과는 무관한 “캐시용 파생 상태(derived state)”라서 불변성을 깨지 않는다.
    • hashhashIsZerofinal이 아니어도 String이 불변인 이유
      • hashhashCode()처음 호출될 때 계산해서 저장하는 지연 캐시다(처음엔 0). 내용이 바뀌지 않으니 한 번 계산한 값은 항상 동일하고, 이 필드는 성능 최적화용일 뿐 논리적 상태가 아니다.
      • 실제 해시 값이 0인 문자열(예: 일부 패턴)에서 “0이면 미계산으로 간주”되는 비효율을 막으려고 JDK 13에 hashIsZero가 추가되었다. 이것도 캐시의 일종으로, 불변성에 영향이 없다.
      • 이 캐시 필드들은 동기화 없이 갱신될 수 있지만, 계산 결과가 내용에서 유도되는 “같은 값” 이라 benign data race(무해한 경쟁) 로 간주된다. 잘못된 결과를 만들지 않고, 최악의 경우 중복 계산만 발생한다.
    • 불변성 판단 기준은 “외부에서 관찰 가능한 의미 있는 상태가 변하느냐”다
private int hash; // Default to 0  

private boolean hashIsZero; // Default to false;
  • 명시적 복사 생성자: 방어적 복사를 하지 않는다.
    • 이 생성자는 original의 내부 버퍼를 그대로 공유
    • 즉 새 객체는 만들어지지만, 내용 바이트 배열(value)과 인코딩 플래그(coder)를 복사하지 않고 그대로 참조
    • 계산돼 있던 hash 값만 함께 가져온다.
// String.java
public String(String original) {  
    this.value = original.value;  
    this.coder = original.coder;  
    this.hash = original.hash;  
    this.hashIsZero = original.hashIsZero;  
}
  • 방어적 복사 생성자
// String.java
public String(char[] value) {  
    this(value, 0, value.length, null);  
}

public String(char[] value, int offset, int count) {  
    this(value, offset, count, rangeCheck(value, offset, count));  
}

private String(char[] value, int off, int len, Void sig) {  
    if (len == 0) {  
        this.value = "".value;  
        this.coder = "".coder;  
        return;  
    }  
    if (COMPACT_STRINGS) {  
        byte[] val = StringUTF16.compress(value, off, len);  
        this.coder = StringUTF16.coderFromArrayLen(val, len);  
        this.value = val;  
        return;  
    }  
    this.coder = UTF16;  
    this.value = StringUTF16.toBytes(value, off, len);  
}
// StringUTF16.java
public static byte[] compress(final char[] val, final int off, final int count) {  
    byte[] latin1 = new byte[count]; // 방어적 복사
    int ndx = compress(val, off, latin1, 0, count);  
    if (ndx != count) {  
        // Switch to UTF16  
        byte[] utf16 = toBytes(val, off, count);  
        if (getChar(utf16, ndx) > 0xff  
                || compress(utf16, 0, latin1, 0, count) != count) {  
            return utf16;  
        }  
    }  
    return latin1;     // latin1 success, 방어적 복사본 반환
}

static byte coderFromArrayLen(byte[] value, int len) {  
    return (byte) ((len - value.length) >>> Integer.SIZE - 1);  
}
  • 공식 Javadoc도 “문자열은 불변이므로 이 생성자는 특별히 _명시적 복사_가 필요한 경우가 아니면 쓸 필요가 없다”고 못박는다. (즉, 일반적 방어 복사용이 아님) Oracle Docs
  • JDK 9부터 StringCompact Strings(JEP 254)로 byte[] value + byte coder 구조이며, 위 “공유” 동작은 이 표현 위에서 그대로 이뤄진다. openjdk.org
  • 반면, char[]byte[]를 받는 생성자들은 Javadoc에 “내용을 복사한다”고 명시되어 있어 진짜 방어 복사가 된다. (원본 배열을 이후에 바꿔도 새 String은 영향 없음) cr.openjdk.org
  • 역사적으로 substring()큰 원본의 버퍼를 공유해 메모리 보유 문제가 있었지만, JDK 7u6부터 이 동작이 바뀌어 부분 문자열도 자체 버퍼를 갖도록 복사하게 되었다. mail.openjdk.org
  • String 타입 객체 생성 tip
    • new String(someString) = 방어 복사 아님. 새 객체만 만들지, 내부 버퍼는 공유합니다. 성능·메모리 면에서 보통 불필요
    • 진짜 복사가 필요하면(특수 보안/생명주기 분리 목적 등) new String(s.toCharArray())처럼 배열 기반 생성자를 사용한다. 이 경로는 내용을 복사한다.

String 클래스 Thread-Safety

  1. 객체 자체는 완전 불변이기 때문에 스레드 안전하지만 “참조 갱신”은 별개
  • String 필드를 여러 스레드가 갱신(새 값을 대입)한다면, 그 참조 업데이트의 가시성/원자성은 보장되지 않는다.
class Box {
    String s = "";
    void add(String x) { s = s + x; } // 읽기-수정-쓰기(=경합). 레이스 가능
}
  • 해결: volatile/synchronized/AtomicReference<String>참조 갱신을 동기화한다.
  • 또는 애초에 구조 자체를 바꿔 StringBuilder(ThreadLocal)/StringBuffer/ConcurrentLinkedQueue<String> 등으로 설계
  1. 불변 + final-field 덕분에 안전한 공개(Safe Publication)
  • String의 핵심 상태는 final 필드에 담겨 있어, 한 번 정상적으로 만들어진 후 다른 스레드에 공유되면 그 내용이 안전하게 보인다.
  • 다만 “생성 도중 this-escape” 같은 잘못된 공개는 피해야 한다(일반적 사용에선 신경 쓸 일 거의 없음).
  1. 해시 캐시(hash) 필드의 ‘무해한 데이터 레이스’
  • hashCode()는 내부 캐시를 지연 계산해서 저장하지만, 값 자체는 결정적이므로 경합이 생겨도 정합성은 유지된다(드물게 중복 계산만 발생).
  1. 문자열 결합은 스레드 안전 아님
  • s += x 같은 읽고-붙이고-다시대입 패턴은 공유 필드에서 경쟁이 일어난다.
  • 멀티스레드 결합이 필요하면:
    • 스레드별로 모아서 → 단일 스레드에서 합치기
    • StringBuffer 사용(동기화됨)
    • 스트림이라면 Collectors.joining() (병렬 스트림에서도 프레임워크가 안전하게 부분 결과를 합칩니다)
  1. 참조 가시성
  • “값”은 불변이라 안전하지만, 새 참조를 다른 스레드가 ‘언제’ 보느냐는 메모리 모델 이슈이다.
    • 최신 값을 보장하려면 volatile 필드, 락, 원자 클래스 등으로 발행/가시성을 확보해야 한다..

문자열 비교

  • equals 메서드
// String.java
public boolean equals(Object anObject) {  
    if (this == anObject) {  
        return true;  
    }  
    return (anObject instanceof String aString)  
            && (!COMPACT_STRINGS || this.coder == aString.coder)  
            && StringLatin1.equals(value, aString.value);  
}
// StringLatin1.java
public static boolean equals(byte[] value, byte[] other) {  
    if (value.length == other.length) {   // 길이 비교해서 다르면 바로 false 반환환
        for (int i = 0; i < value.length; i++) {  
            if (value[i] != other[i]) {  
                return false;  
            }  
        }  
        return true;  
    }  
    return false;  
}
  • COMPACT_STRINGS 플래그 상수
    • COMPACT_STRINGSjava.lang.String 안에 있는 static final boolean 플래그
    • JVM이 “Compact Strings” 최적화를 켰는지(기본 on) 확인할 때 내부 코드 경로를 가르기 위해 쓰인다.
    • JDK 9에서 도입된 Compact Strings는 String의 내부 표현을 char[]byte[] + coder(LATIN1/UTF16) 로 바꿔, 라틴-1로 표현 가능한 문자열은 1바이트/문자로 저장해 메모리를 절약한다. OpenJDK
    • 역할
      • COMPACT_STRINGS == true면 “Compact Strings 활성화”를 뜻하고, String의 생성자/메서드들이 라틴-1 경로 vs UTF-16 경로를 선택할 때 이 플래그와 coder 값을 함께 사용한다. (예: charAt/생성자 분기)
      • 어디에 있나: 실제로 OpenJDK 내부 발표 자료에 class String { static final boolean COMPACT_STRINGS = ...; }와 네이티브 초기화 스텁이 등장한다. (JVM이 부팅 시 값을 결정)
    • 켜고 끄기
      • 기본값: HotSpot에서는 기본 활성화이다. 필요하면 -XX:-CompactStrings 로 끌 수 있다. (JDK 9+; OpenJ9은 JDK 8에서도 옵션 제공) Oracle Docs+1
      • 확인 방법(런타임): java -XX:+PrintFlagsFinal -version | grep -i CompactStrings 같은 플래그 출력으로 현재 JVM의 설정을 확인할 수 있다. (공식 문서에 비활성화 플래그 기재) Oracle Docs
    • 내부 동작
      • 필드 레이아웃: private final byte[] value; private final byte coder; coderLATIN1(0) 또는 UTF16(1)이며, 연산 시 isLatin1() 같은 체크로 분기한다.
      • 효과: 라틴-1 비중이 높은 워크로드에서 메모리·GC 압력 감소와 함께 대체로 성능 이득을 준다. 다만 거의 모든 문자열이 비라틴-1(UTF-16)인 특수 워크로드에서는 이점이 적거나 아주 미묘한 오버헤드가 있을 수 있다
// String.java
...

static final boolean COMPACT_STRINGS;  

static {  
    COMPACT_STRINGS = true;  
}
  • hashCode 메서드
// String.java
public int hashCode() {  
   int h = hash;  
    if (h == 0 && !hashIsZero) {  
        h = isLatin1() ? StringLatin1.hashCode(value)  
                       : StringUTF16.hashCode(value);  
        if (h == 0) {  
            hashIsZero = true;  
        } else {  
            hash = h;  
        }  
    }  
    return h;  
}

boolean isLatin1() {  
    return COMPACT_STRINGS && coder == LATIN1;  
}
// StringLatin1.java
public static int hashCode(byte[] value) {  
    return ArraysSupport.hashCodeOfUnsigned(value, 0, value.length, 0);  
}
// StringUTF16.java
public static int hashCode(byte[] value) {  
    return ArraysSupport.hashCodeOfUTF16(value, 0, value.length >> 1, 0);  
}
// ArraySuppor.java
public static int hashCodeOfUnsigned(byte[] a, int fromIndex, int length, int initialValue) {  
    return switch (length) {  
        case 0 -> initialValue;  
        case 1 -> 31 * initialValue + Byte.toUnsignedInt(a[fromIndex]);  
        default -> 
        vectorizedHashCode(a, fromIndex, length, initialValue, T_BOOLEAN);  
    };  
}

public static int hashCodeOfUTF16(byte[] a, int fromIndex, int length, int initialValue) {  
    return switch (length) {  
        case 0 -> initialValue;  
        case 1 -> 31 * initialValue + JLA.getUTF16Char(a, fromIndex);  
        default -> 
        vectorizedHashCode(a, fromIndex, length, initialValue, T_CHAR);  
    };  
}

private static int vectorizedHashCode(Object array, int fromIndex, int length, int initialValue,  
                                      int basicType) {  
    return switch (basicType) {  
        case T_BOOLEAN -> 
        unsignedHashCode(initialValue, (byte[]) array, fromIndex, length);  
        case T_CHAR -> 
        array instanceof byte[]  
                ? utf16hashCode(initialValue, (byte[]) array, fromIndex, length) 
                : hashCode(initialValue, (char[]) array, fromIndex, length);  
        case T_BYTE -> 
            hashCode(initialValue, (byte[]) array, fromIndex, length);  
        case T_SHORT -> 
            hashCode(initialValue, (short[]) array, fromIndex, length);  
        case T_INT -> 
            hashCode(initialValue, (int[]) array, fromIndex, length);  
            default -> 
            throw new IllegalArgumentException("unrecognized basic type: " + 
            basicType);  
    };  
}

private static int unsignedHashCode(int result, byte[] a, 
    int fromIndex, int length) {  
    int end = fromIndex + length;  
    for (int i = fromIndex; i < end; i++) {  
        result = 31 * result + Byte.toUnsignedInt(a[i]);  
    }  
    return result;  
}  

private static int hashCode(int result, byte[] a, int fromIndex, int length) {  
    int end = fromIndex + length;  
    for (int i = fromIndex; i < end; i++) {  
        result = 31 * result + a[i];  
    }  
    return result;  
}  

private static int hashCode(int result, char[] a, int fromIndex, int length) {  
    int end = fromIndex + length;  
    for (int i = fromIndex; i < end; i++) {  
        result = 31 * result + a[i];  
    }  
    return result;  
}  

private static int hashCode(int result, short[] a, int fromIndex, int length) {  
    int end = fromIndex + length;  
    for (int i = fromIndex; i < end; i++) {  
        result = 31 * result + a[i];  
    }  
    return result;  
}  

private static int hashCode(int result, int[] a, int fromIndex, int length) {  
    int end = fromIndex + length;  
    for (int i = fromIndex; i < end; i++) {  
        result = 31 * result + a[i];  
    }  
    return result;  
}
  • compareTo 메서드
// String.java
public int compareTo(String anotherString) {  
    byte[] v1 = value;  
    byte[] v2 = anotherString.value;  
    byte coder = coder();  
    if (coder == anotherString.coder()) {  
        return coder == LATIN1 ? StringLatin1.compareTo(v1, v2)  
                               : StringUTF16.compareTo(v1, v2);  
    }  
    return coder == LATIN1 ? StringLatin1.compareToUTF16(v1, v2)  
                           : StringUTF16.compareToLatin1(v1, v2);  
}
// StringLatin1.java
public static int compareTo(byte[] value, byte[] other) {  
    int len1 = value.length;  
    int len2 = other.length;  
    return compareTo(value, other, len1, len2);  
}

public static int compareTo(byte[] value, byte[] other, int len1, int len2) {  
    int lim = Math.min(len1, len2);  
    int k = ArraysSupport.mismatch(value, other, lim);  
    return (k < 0) ? len1 - len2 : getChar(value, k) - getChar(other, k);  
}
// StringUTF16.java
public static int compareTo(byte[] value, byte[] other) {  
    int len1 = length(value);  
    int len2 = length(other);  
    return compareValues(value, other, len1, len2);  
}
// StringUTF16.java
private static int compareValues(byte[] value, byte[] other, int len1, int len2) {  
    int lim = Math.min(len1, len2);  
    for (int k = 0; k < lim; k++) {  
        char c1 = getChar(value, k);  
        char c2 = getChar(other, k);  
        if (c1 != c2) {  
            return c1 - c2;  
        }  
    }  
    return len1 - len2;  
}