Skip to main content

Command Palette

Search for a command to run...

Java String 내부 구조 — Compact Strings, String Pool, 그리고 G1 String Deduplication

Updated
15 min read

매일 쓰는 String을 다시 들여다보고 싶은 백엔드 개발자를 위해 썼어요. 보통 힙 메모리의 20~30%를 차지하는 이 작은 클래스 안에서, JDK 9 이후로 어떤 일이 벌어지고 있는지 — byte[]로 바뀐 내부 표현, native StringTable, invokedynamic으로 풀린 + 연산자, GC가 똑같은 문자열을 알아서 공유해 주는 메커니즘까지 — 한 흐름으로 정리해 봐요.

1. 왜 String을 다시 봐야 할까

자바 애플리케이션의 힙을 덤프 떠서 분석해 보면, String과 그 백킹 배열이 차지하는 비중이 보통 가장 큽니다. Oracle이 JEP 254 배경에서 인용한 측정값으로는 평균적인 애플리케이션 힙의 약 25%가 String 인스턴스에 묶여 있어요. 캐시 키, JSON 페이로드, 로그 메시지, 데이터베이스 컬럼 — 거의 모든 길에서 String이 등장하니까 어찌 보면 당연한 결과예요.

그런데 같은 사양의 서버에서도 JDK 9를 기점으로 같은 워크로드의 String 메모리가 절반으로 줄어든 사례가 흔합니다. 왜냐고요? String의 내부 표현이 통째로 바뀌었거든요. 거기에 더해 +로 문자열을 잇는 코드가 컴파일되는 방식이 달라졌고, GC가 동일한 문자열을 자동으로 합쳐 주는 기능까지 들어왔어요.

이 글은 그 변화를 네 가지 축으로 나눠서 따라가요.

  1. JDK 9 이전과 이후의 String 내부 표현 — char[]에서 byte[] + coder로 (JEP 254)
  2. native StringTable과 intern()이 실제로 하는 일
  3. + 연산자가 invokedynamic으로 풀려나가는 방식 (JEP 280)
  4. G1을 시작으로 ParallelGC, ZGC까지 확장된 String Deduplication (JEP 192)

2. JDK 9 이전 — char[]로 살던 시절

JDK 8까지 String은 단순했어요. 내부에 char[] 하나를 들고 있고, 각 char는 UTF-16 코드 유닛 2바이트를 차지했죠.

// JDK 8 java.lang.String (단순화)
public final class String {
    private final char value[];
    private int hash;
}

왜 UTF-16이었을까요. Java가 처음 등장한 1995년에는 Unicode 표준이 BMP(Basic Multilingual Plane, U+0000~U+FFFF) 안에 모든 문자가 들어맞도록 설계돼 있었어요. 그래서 한 문자를 16비트 코드 유닛 하나로 표현하면 충분했고, 고정 길이 인코딩이라서 인덱싱이 단순했어요. 그 시절에 char를 16비트로 정의한 결정이 그대로 String 내부까지 이어진 거예요.

문제는 시간이 지나면서 드러났어요. 영어권 운영 서버에서 힙을 덤프해 보면 떠 있는 String의 80~90%가 모든 문자가 Latin-1(ISO-8859-1) 범위에 들어가는 문자열이었거든요. 영어 알파벳, 숫자, 흔한 특수문자 정도예요. 한 문자가 한 바이트면 충분한데도 매번 2바이트씩 잡혀 있었던 거예요. 즉 절반의 바이트가 매번 0x00으로 낭비되고 있었던 셈이에요.

JDK 6에는 이미 비슷한 시도가 있었어요. -XX:+UseCompressedStrings라는 옵션을 켜면 ASCII 전용 문자열은 byte[]에, 나머지는 char[]에 저장했어요. 백킹 배열의 타입이 Object 레퍼런스로 추상화돼 있어서, 메서드마다 instanceof로 분기해야 했죠. 코드가 복잡해진 데다, 두 분기 사이에서 JIT가 일관되게 인라이닝을 못 해서 성능 이득도 들쭉날쭉했어요. 결국 이 옵션은 JDK 7에서 제거됐어요.

JDK 9에서는 다른 접근을 택했어요. 백킹 배열은 항상 byte[] 하나로 통일하고, 인코딩 정보를 별도 필드로 들고 다니자는 거였어요. 분기를 instanceof가 아니라 단순한 int 비교로 바꾸면 JIT가 분기 예측을 한 방향으로 고정시킬 수 있고, 결과적으로 성능 손해 없이 메모리만 줄일 수 있다는 발상이었죠. 그게 JEP 254 Compact Strings예요.

3. Compact Strings — byte[] + coder

JDK 9부터 String의 필드는 이렇게 바뀌었어요.

// JDK 9+ java.lang.String (단순화)
public final class String {
    private final byte[] value;
    private final byte coder;
    private int hash;
    private boolean hashIsZero;
    static final boolean COMPACT_STRINGS;

    static final byte LATIN1 = 0;
    static final byte UTF16  = 1;
}

핵심은 coder 한 바이트예요. 이 값이 LATIN1이면 value의 길이가 문자 수와 같고, 각 바이트가 하나의 문자(U+0000~U+00FF)를 그대로 담아요. UTF16이면 value는 빅 엔디안 또는 리틀 엔디안 UTF-16 코드 유닛이 나열된 형태고, 길이는 항상 문자 수의 두 배예요.

문자열이 만들어질 때 모든 문자가 U+00FF 이하인지 한 번 검사해서 둘 중 하나를 결정해요. 검사 후에는 coder가 final처럼 굳어지고, 이후 모든 메서드가 이 값으로 분기해요.

// indexOf의 분기 (단순화)
public int indexOf(int ch, int fromIndex) {
    return isLatin1()
        ? StringLatin1.indexOf(value, ch, fromIndex)
        : StringUTF16.indexOf(value, ch, fromIndex);
}

내부 분기는 StringLatin1StringUTF16이라는 패키지 프라이빗 헬퍼 클래스 둘로 갈라져 있어요. 두 클래스 모두 static 메서드만 가지고 있고, byte[]를 받아서 자기네 인코딩 규칙으로 해석해요. JIT는 coder가 사실상 final이라는 정보를 활용해서 한쪽 분기로 호출을 인라이닝합니다.

두 클래스가 같은 메서드를 구현하지만, 안에서 하는 일은 인코딩 차이만큼 달라요. 예를 들어 charAt을 보면 이래요.

// StringLatin1.charAt — 한 바이트가 곧 한 문자
public static char charAt(byte[] value, int index) {
    if (index < 0 || index >= value.length) {
        throw new StringIndexOutOfBoundsException(index);
    }
    return (char) (value[index] & 0xff);
}

// StringUTF16.charAt — 두 바이트를 합쳐 한 문자로
public static char charAt(byte[] value, int index) {
    checkIndex(index, value);
    return getChar(value, index);
}

StringLatin1은 인덱스 계산이 그대로 바이트 오프셋이에요. StringUTF16은 인덱스에 2를 곱해서 바이트 오프셋을 만들고, 두 바이트를 little-endian으로 합쳐서 char를 만들어요. 엔디안은 플랫폼에 따라 다르지만, HotSpot에서는 모든 플랫폼에서 little-endian으로 통일해서 직렬화 문제를 피해요.

3.1 ASCII 문자열의 메모리 절감

길이 10짜리 문자열 "hello world"를 예로 들어 볼게요. JDK 8에서는 char[10]이 필요하니까 객체 헤더 12바이트 + 길이 4바이트 + char × 10바이트 = 헤더 패딩까지 포함해 32바이트 가까이 차지했어요. JDK 9 이후로는 byte[10]이라서 헤더 12바이트 + 길이 4바이트 + byte × 10바이트 = 24바이트 안쪽이에요. 한 인스턴스 기준으로는 8바이트지만, 힙에 백만 개가 있으면 8MB가 됩니다.

다만 Compact Strings는 인코딩이 LATIN1로 인식될 때만 절약돼요. 한글 문자가 하나라도 섞이면 coderUTF16이 되고, 결국 모든 문자가 2바이트로 인코딩되니까 절약 효과가 없어요. 한국어 비중이 높은 애플리케이션에서는 기대만큼 줄어들지 않는다는 사실을 미리 알아두면 좋아요.

3.2 끄는 방법과 끄면 안 되는 이유

Compact Strings는 기본으로 켜져 있고, -XX:-CompactStrings로 끌 수 있어요. 끄면 모든 String이 항상 UTF16 coder로 만들어져요. JIT가 분기 예측을 더 잘 맞출 수도 있어서 미세하게 빠르다는 보고가 종종 나오지만, 메모리를 크게 더 쓰는 대가가 따라요. 특별한 이유 없이 끄지 않는 게 일반적이에요.

JDK 25 시점에서 OpenJDK는 한 발 더 나갔어요. JEP draft 8371379 "Deprecate the UTF-16-only String Representation"이 등장해서, 언젠가는 LATIN1 표현이 의무화될 가능성을 시사하고 있어요. 즉 -XX:-CompactStrings로 끄던 옵션 자체가 사라질 수 있다는 뜻이에요.

3.3 hashCode 캐싱과 hashIsZero

String에는 hashhashIsZero라는 두 필드가 있어요. 한 번 hashCode()를 계산하면 그 결과를 hash에 저장해 두고, 다음 호출부터는 곱셈 없이 그대로 돌려줘요.

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;
}

hashIsZero는 "실제로 계산했더니 0이 나왔다"는 사실을 기억하기 위한 boolean이에요. hash == 0만으로는 "아직 계산 안 했음"과 "계산 결과가 0임"을 구분할 수 없거든요. JDK 12 이전에는 이 boolean이 없어서 빈 문자열처럼 hash가 0이 되는 경우 매번 재계산하던 비용이 있었어요.

4. String Pool — StringTable의 진실

String.intern()은 자바 입문서에서 한 번씩은 만나는 메서드예요. "같은 내용의 문자열을 하나로 통일해서 메모리를 아끼는 기능"이라고들 설명하죠. 표면적으로는 맞아요. 그런데 실제로 어디에 보관되는지, 어떻게 검색하는지, 왜 너무 많이 호출하면 위험한지를 알려면 HotSpot의 native StringTable을 들여다봐야 해요.

4.1 StringTable은 자바 객체가 아니에요

흔한 오해 중 하나가 "String Pool은 PermGen이나 Metaspace에 있다"는 말이에요. 사실 PermGen에 있었던 건 JDK 6까지였고, JDK 7부터는 인터닝된 String 객체 자체는 일반 힙에 있어요. 다만 그 객체들을 검색할 수 있도록 이름→객체 매핑을 들고 있는 native 해시 테이블이 따로 있는데, 그게 HotSpot의 StringTable이에요.

StringTable은 C++로 구현된 chained hash table이에요. JDK 11부터는 lock-free read를 지원하는 ConcurrentHashTable 템플릿 기반으로 다시 작성됐고, 자바 힙 안의 String 객체를 가리키는 weak 참조를 OopStorage라는 native 메커니즘으로 관리해요.

flowchart LR
    HEAP[Java Heap]:::heap
    NATIVE[Native Memory]:::native
    subgraph HEAP
        S1[String hello]
        S2[String world]
    end
    subgraph NATIVE
        ST[StringTable buckets]
        OS[OopStorage weak handles]
    end
    ST -- bucket entry --> OS
    OS -. weak ref .-> S1
    OS -. weak ref .-> S2

    classDef heap fill:#eef,stroke:#446
    classDef native fill:#fee,stroke:#a44

4.2 intern()이 실제로 하는 일

intern()은 native 메서드예요. 코드 흐름은 대략 이래요.

  1. 호출자의 String 객체에서 해시값을 계산해요. Compact Strings 이후에는 coder에 따라 LATIN1 또는 UTF16 해시 함수가 호출돼요.
  2. 그 해시로 StringTable의 버킷을 찾아 들어가요.
  3. 버킷의 linked list를 훑으면서 동일한 내용(String.equals와 동일한 비교 기준)을 가진 엔트리가 있는지 봐요.
  4. 있으면 그 엔트리가 가리키는 기존 String 객체를 반환해요.
  5. 없으면 호출자가 넘긴 String 객체를 새 엔트리로 만들어서 테이블에 등록하고 그 객체를 반환해요.

여기서 흥미로운 점이 두 가지 있어요.

첫째, intern()새 String 객체를 만들지 않아요. 이미 만들어진 객체를 그대로 등록하거나, 기존 객체로 바꿔치기해 줄 뿐이에요. 그래서 "abc".intern()처럼 컴파일러가 만들어 둔 리터럴은 클래스 로딩 시점에 이미 테이블에 들어가 있어요.

둘째, 테이블은 weak 참조로 객체를 들고 있어요. 즉 자바 힙에서 그 String을 더 이상 가리키는 strong 참조가 없으면 GC가 회수하고, StringTable에서도 빠져나가요. 이 정리는 Concurrent Cleanup 백그라운드 스레드가 처리해요.

4.3 너무 많이 부르면 일어나는 일

StringTable은 chained hash table이라서, 엔트리가 늘어나면 한 버킷의 체인이 길어져요. 체인이 길어지면 검색 비용이 O(체인 길이)에 가까워지고, 그러면 intern() 자체가 느려져요.

기본 버킷 수는 JDK 25 기준 약 65,536개예요. -XX:StringTableSize=N으로 늘릴 수 있고, JDK 11 이후로는 동적으로 grow되기도 해요. 그래도 수백만 개를 인턴하면 충돌이 늘어나서 throughput이 눈에 띄게 떨어집니다.

또 다른 함정은 호출 스레드가 STW를 거치는 일이 없는데도 intern 자체가 native 락을 잠깐 잡는다는 점이에요. 락이 짧긴 하지만, 다중 스레드에서 동시에 한 번도 본 적 없는 새 문자열을 마구 인턴하면 lock contention이 생겨요. 실무에서는 사용자 입력 같은 카디널리티가 무한한 값을 intern()에 넣으면 안 된다고 봐요.

이런 패턴은 의도하지 않게 새어 들어가곤 해요. 예를 들어 아래 같은 코드는 위험해요.

public Set<String> uniqueUserIds(List<Event> events) {
    return events.stream()
        .map(Event::getUserId)
        .map(String::intern)
        .collect(Collectors.toSet());
}

Set을 만들 때 intern()을 끼워 넣으면 같은 값에서 동일성 비교를 더 빠르게 할 수는 있지만, 사용자 수가 수십만에서 수천만으로 늘어나면 StringTable이 통제 불능으로 커져요. 게다가 인터닝된 객체는 strong 참조가 끊기기 전까지 weak로만 들고 있어도 결국 일반 힙을 차지하니까, 의도와 반대로 메모리가 더 늘어나기도 해요. 카디널리티가 정해져 있는 enum-like 값(상태 코드, 토픽 이름 등)에만 intern()을 쓰는 게 안전해요.

4.4 CDS 아카이브 안의 인터닝된 String

JEP 250 Store Interned Strings in CDS Archives는 JDK 9에 추가된 기능이에요. CDS(Class Data Sharing) 덤프를 만들 때 JDK 표준 클래스가 사용한 인터닝된 String 객체와 그 백킹 배열을 아카이브에 그대로 저장해 두고, 모든 JVM 프로세스가 이 아카이브를 mmap해서 공유해요.

flowchart LR
    DUMP[CDS Dump Time]:::dump
    PROC1[JVM Process A]:::proc
    PROC2[JVM Process B]:::proc
    ARCHIVE[Shared CDS Archive]:::ar

    DUMP -- writes shared strings --> ARCHIVE
    ARCHIVE -- mmap --> PROC1
    ARCHIVE -- mmap --> PROC2

    classDef dump fill:#efe,stroke:#484
    classDef proc fill:#eef,stroke:#446
    classDef ar   fill:#ffe,stroke:#aa4

여러 JVM이 한 서버에 떠 있는 환경에서는 같은 표준 String을 프로세스마다 따로 들고 있을 필요가 없어지니까 메모리가 더 줄어요. 다만 제약이 있어요. 64비트 플랫폼에서 compressed oop이 켜져 있어야 하고, 처음에는 G1 GC만 지원했어요. 지금은 다른 GC도 일부 지원하지만, 여전히 CDS 자체가 켜져 있어야 동작해요.

5. + 연산자가 풀려나가는 방식 — JEP 280

String a = "Hello, " + name + "!" 같은 코드는 한 번도 안 써본 사람이 없을 거예요. 컴파일러가 이걸 어떻게 처리하는지는 JDK 8과 9 이상에서 완전히 달라요.

5.1 JDK 8 — StringBuilder dance

JDK 8에서 javac는 + 연산자를 만나면 StringBuilder로 풀어서 바이트코드를 만들었어요.

String s = "Hello, " + name + "!";
// 위 코드는 사실상 아래로 컴파일돼요
String s = new StringBuilder("Hello, ")
        .append(name)
        .append("!")
        .toString();

문제는 두 가지였어요. 첫째, 컴파일러가 결정한 전략이 바이트코드에 고정돼서, JDK 라이브러리가 더 빠른 방법을 찾아도 사용자가 재컴파일하지 않으면 적용되지 않아요. 둘째, StringBuilder가 내부 버퍼를 잘못 추정하면 여러 번 resize가 일어나서 불필요한 복사 비용이 생겨요.

5.2 JDK 9+ — invokedynamic + StringConcatFactory

JEP 280 "Indify String Concatenation"이 JDK 9에 들어와서 그 방식을 바꿨어요. javac는 이제 + 연산자를 한 줄의 invokedynamic 명령어로 컴파일해요.

// 바이트코드 단편
ldc           "Hello, "
aload         name
ldc           "!"
invokedynamic makeConcatWithConstants
              (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)
              Ljava/lang/String;
              recipe: ""

invokedynamic이 실행되면 첫 호출에만 부트스트랩 메서드 StringConcatFactory.makeConcatWithConstants가 한 번 불려요. 이 부트스트랩이 호출 시점에 인자 타입을 보고 가장 적합한 concat 전략을 골라서 MethodHandle을 만들어 돌려줘요. 이후 호출은 그 MethodHandle에 직접 진입해요.

5.3 recipe — 정적 부분과 동적 부분을 가르는 문법

makeConcatWithConstants의 핵심 인자가 recipe 문자열이에요. 두 가지 특수 문자를 사용해요.

  •  (SOH) — 호출 시점에 들어오는 동적 인자 한 자리
  •  (STX) — 부트스트랩 시점에 같이 넘어오는 상수 한 자리

위 예시의 recipe ""은 "동적 인자 셋이 차례로 들어온다"는 뜻이에요. 부트스트랩은 이 recipe와 인자 타입을 보고, 각 인자를 어떻게 LATIN1/UTF16로 변환해서 미리 크기를 계산하고, 단일 byte[]에 직접 써넣는 코드를 생성해요.

이 전략은 내부적으로 여러 개가 있는데, JDK 9~14에서는 -Djava.lang.invoke.stringConcat으로 고를 수 있었어요. 후보로 BC_SB, BC_SB_SIZED, BC_SB_SIZED_EXACT, MH_SB_SIZED, MH_SB_SIZED_EXACT, MH_INLINE_SIZED_EXACT 등이 있었고, 그중 MH_INLINE_SIZED_EXACT가 가장 빠른 것으로 알려졌어요. JDK 15부터는 그냥 항상 가장 빠른 전략을 쓰도록 단일화되어서 옵션이 사실상 의미가 없어졌어요.

5.4 인덱스 캐싱과 인라이닝

MH_INLINE_SIZED_EXACT 전략은 결과 String 길이를 미리 정확히 계산해요. 그 후 단일 byte[]를 한 번 alloc하고, 각 인자를 곧바로 그 배열의 특정 오프셋에 써넣어요. StringBuilder.append가 내부에서 했던 길이 추정·resize·복사가 사라지니까, 같은 코드가 JDK 8 대비 3~4배 빠르다는 보고가 일반적이에요.

JIT 입장에서도 invokedynamic 호출 사이트가 callsite-stable한 MethodHandle로 연결돼 있으니까, hot path에서는 MethodHandle이 전부 인라이닝돼서 한 덩어리의 머신 코드가 돼요. 결국 + 연산자는 "컴파일 타임 결정"에서 "JVM이 런타임에 골라 인라이닝하는 핸들 호출"로 무게중심이 옮겨진 거예요.

flowchart TB
    SRC[Java source: a + b + c]:::src
    JAVAC8[javac JDK 8]:::tool
    JAVAC9[javac JDK 9+]:::tool
    BC8[StringBuilder bytecode]:::bc
    BC9[invokedynamic bytecode]:::bc
    RT[StringConcatFactory bootstrap]:::rt
    MH[Sized MethodHandle]:::mh
    OUT[Single byte allocation]:::out

    SRC --> JAVAC8 --> BC8 --> SB[Runtime StringBuilder]:::sb --> OUT
    SRC --> JAVAC9 --> BC9 -- first call --> RT --> MH --> OUT
    BC9 -- subsequent calls --> MH

    classDef src fill:#efe,stroke:#484
    classDef tool fill:#eef,stroke:#446
    classDef bc fill:#ffe,stroke:#aa4
    classDef rt fill:#fee,stroke:#a44
    classDef mh fill:#fde,stroke:#a48
    classDef sb fill:#dee,stroke:#488
    classDef out fill:#dfe,stroke:#4a8

이 그림이 보여 주는 두 가지가 있어요. 첫째, JDK 9 이상에서는 결과로 만들어지는 byte[] 할당이 한 번이에요. 둘째, MethodHandle은 첫 호출에만 만들어지고 이후 호출은 그걸 재사용해요. 두 점이 합쳐져서 + 연산자의 비용이 크게 떨어졌어요.

6. G1 String Deduplication — JEP 192

지금까지의 절약은 모두 새로 만들어지는 String에 대한 거였어요. 그런데 이미 동일한 내용의 String이 힙에 수만 개 떠 있다면 어떻게 해야 할까요. 자바 개발자가 일일이 intern()을 호출할 수도 없고, 그러면 StringTable이 터져 버려요.

JEP 192 "String Deduplication in G1"이 해결책이에요. GC가 마킹을 도는 김에 동일한 백킹 배열을 가진 String을 찾아 자동으로 합쳐 주는 기능이에요.

6.1 동작 흐름

flowchart LR
    MARK[G1 Marking Phase]:::stw
    CAND[Candidate Found]:::cand
    QUEUE[Per-Worker Queue]:::queue
    BG[Background Dedup Thread]:::bg
    TABLE[Dedup Hashtable]:::tbl
    HIT[Match Found]:::hit
    SHARE[Share byte array]:::share

    MARK --> CAND --> QUEUE --> BG
    BG --> TABLE
    TABLE -- hash hit --> HIT --> SHARE
    TABLE -- hash miss --> NEW[Insert new entry]:::new

    classDef stw fill:#fee,stroke:#a44
    classDef cand fill:#eff,stroke:#488
    classDef queue fill:#efe,stroke:#484
    classDef bg fill:#eef,stroke:#446
    classDef tbl fill:#ffe,stroke:#aa4
    classDef hit fill:#fde,stroke:#a48
    classDef share fill:#dfe,stroke:#4a8
    classDef new fill:#ede,stroke:#a4a

흐름을 자세히 풀면 이래요.

  1. G1이 마킹 단계에서 live 객체를 훑으면서, 각 String이 deduplication 후보인지 검사해요. 후보 조건은 충분히 오래 살아남았는지(여러 마킹 사이클을 견뎠는지)예요.
  2. 후보인 String 참조를 GC 워커 스레드별 큐에 넣어요. 큐가 워커별로 분리돼 있어서 마킹 STW 동안 lock 없이 enqueue할 수 있어요.
  3. 마킹이 끝나면 별도의 백그라운드 dedup 스레드가 큐를 소비해요. 큐에서 꺼낸 각 String의 byte[] 해시값으로 dedup hashtable을 검색해요.
  4. 같은 해시를 가진 엔트리가 있으면 내용을 비교해요. 같으면 새 String의 value 필드를 기존 byte[]로 바꿔치기해요. 두 String 객체는 그대로 살아 있지만, 백킹 배열만 공유돼요.
  5. 같은 해시가 없으면 이 String의 byte[]를 hashtable에 새 엔트리로 등록해요.

핵심은 String 객체 자체를 합치는 게 아니라, 그 안의 byte[]만 공유한다는 거예요. 그래서 자바 코드에서 s1 == s2 같은 동일성 비교는 영향을 받지 않고, s1.equals(s2)는 원래대로 동작해요. 다만 둘이 같은 value를 가리키니까, s1.equals(s2)가 short-circuit으로 빨라질 수 있다는 보너스가 있어요.

6.2 큐와 hashtable도 weak

deduplication 큐와 hashtable 모두 weak 참조로 String을 들고 있어요. String이 어디서도 strong하게 참조되지 않게 되면 GC가 회수하고, 두 자료구조에서도 즉시 빠져요. 그래서 deduplication이 누수의 원인이 되지는 않아요.

6.3 활성화 방법

기본으로 꺼져 있고, JVM 옵션으로 켜요.

java -XX:+UseStringDeduplication \
     -XX:StringDeduplicationAgeThreshold=3 \
     -XX:+PrintStringDeduplicationStatistics \
     YourApp

StringDeduplicationAgeThreshold는 "몇 번의 young GC를 살아남은 String만 후보로 받겠다"는 임계값이에요. 기본 3이에요. 너무 낮추면 단명 String까지 검사 비용이 들고, 너무 높이면 효과가 줄어요.

6.4 다른 GC로의 확장

JEP 192의 원래 이름은 "String Deduplication in G1"이었어요. 하지만 이후 다른 GC에도 같은 기능이 단계적으로 확장됐어요.

GC 지원 시점 비고
G1 JDK 8u20 JEP 192 원조
Shenandoah JDK 11 concurrent로 동작
ZGC JDK 18 concurrent로 동작, JDK 17까지는 미지원
ParallelGC JDK 18 STW 단계에서 처리
SerialGC JDK 18 STW 단계에서 처리

JDK 18부터는 모든 표준 GC가 -XX:+UseStringDeduplication을 받아들여요. 기본값은 여전히 꺼짐이에요. 켜기 전에 워크로드의 String 카디널리티를 측정해 보는 게 좋아요. 중복이 거의 없는 워크로드에서는 dedup 자체의 오버헤드만 남고 이득이 없거든요.

6.5 Compact Strings와의 상호작용

dedup hashtable은 byte[] 하나를 기본 단위로 들고 있어요. 같은 내용이라도 coder가 다르면(예: 어떤 String은 LATIN1로, 어떤 String은 UTF16로 표현됐다면) 둘은 다른 byte[]를 가지니까 합쳐지지 않아요. 이건 한국어 비중이 높은 서비스에서 자주 등장하는 사각지대예요. 같은 한국어 문자열도 어떤 경로로 만들어졌느냐에 따라 둘 중 하나의 표현으로 굳어질 수 있는데, dedup이 그걸 가로질러 합치지는 못해요.

7. 정리

String의 진화는 한 가지 메시지를 반복해서 들려줘요. "같은 추상(java.lang.String) 뒤에서 끊임없이 내부 표현을 바꿔 왔다." 그리고 그 변화는 거의 모두 메모리와 처리량을 동시에 잡으려는 시도였어요.

  • JDK 9의 Compact Strings는 LATIN1 비중이 높은 영어권 서비스에서 String 메모리를 절반으로 줄여요.
  • 동일한 JDK 9의 JEP 280은 + 연산자를 컴파일 타임 결정에서 런타임 인라이닝으로 옮겨서, 같은 코드가 그대로 3~4배 빨라지는 효과를 줘요.
  • JDK 11의 native StringTable 재구현은 intern()의 동시성 한계를 풀었어요. 하지만 사용자 입력 같은 무제한 카디널리티는 여전히 위험해요.
  • JEP 192의 String Deduplication은 코드 한 줄 안 바꿔도 GC가 알아서 중복을 합쳐 주는 기능이에요. JDK 18부터는 어느 GC를 써도 켤 수 있어요.

각 기능을 개별적으로 켜고 끄는 것은 어렵지 않아요. 어려운 건 워크로드의 특성(언어 분포, 카디널리티, lifetime)을 먼저 측정해서 어떤 조합이 의미가 있는지 판단하는 일이에요. String 한 클래스 안에서도 이렇게 많은 일이 벌어진다는 걸 알고 나면, 다음 번에 힙 덤프를 들여다볼 때 보이는 것이 달라질 거예요.

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ