devseop08 님의 블로그
[API] String 본문
문자열 상수(리터럴)와 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 선언되지 않은 멤버 :
hash와hashIsZerohash/hashIsZero가final이 아니어도, 이는 내용(문자 시퀀스) 과는 무관한 “캐시용 파생 상태(derived state)”라서 불변성을 깨지 않는다.hash와hashIsZero가final이 아니어도 String이 불변인 이유hash는hashCode()가 처음 호출될 때 계산해서 저장하는 지연 캐시다(처음엔 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부터
String은 Compact 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
- 객체 자체는 완전 불변이기 때문에 스레드 안전하지만 “참조 갱신”은 별개
String필드를 여러 스레드가 갱신(새 값을 대입)한다면, 그 참조 업데이트의 가시성/원자성은 보장되지 않는다.
class Box {
String s = "";
void add(String x) { s = s + x; } // 읽기-수정-쓰기(=경합). 레이스 가능
}- 해결:
volatile/synchronized/AtomicReference<String>로 참조 갱신을 동기화한다. - 또는 애초에 구조 자체를 바꿔
StringBuilder(ThreadLocal)/StringBuffer/ConcurrentLinkedQueue<String>등으로 설계
- 불변 + final-field 덕분에 안전한 공개(Safe Publication)
String의 핵심 상태는final필드에 담겨 있어, 한 번 정상적으로 만들어진 후 다른 스레드에 공유되면 그 내용이 안전하게 보인다.- 다만 “생성 도중 this-escape” 같은 잘못된 공개는 피해야 한다(일반적 사용에선 신경 쓸 일 거의 없음).
- 해시 캐시(hash) 필드의 ‘무해한 데이터 레이스’
hashCode()는 내부 캐시를 지연 계산해서 저장하지만, 값 자체는 결정적이므로 경합이 생겨도 정합성은 유지된다(드물게 중복 계산만 발생).
- 문자열 결합은 스레드 안전 아님
s += x같은 읽고-붙이고-다시대입 패턴은 공유 필드에서 경쟁이 일어난다.- 멀티스레드 결합이 필요하면:
- 스레드별로 모아서 → 단일 스레드에서 합치기
StringBuffer사용(동기화됨)- 스트림이라면
Collectors.joining()(병렬 스트림에서도 프레임워크가 안전하게 부분 결과를 합칩니다)
- 참조 가시성
- “값”은 불변이라 안전하지만, 새 참조를 다른 스레드가 ‘언제’ 보느냐는 메모리 모델 이슈이다.
- 최신 값을 보장하려면
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_STRINGS는java.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
- 기본값: HotSpot에서는 기본 활성화이다. 필요하면
- 내부 동작
- 필드 레이아웃:
private final byte[] value; private final byte coder;coder는LATIN1(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;
}'Language > Java' 카테고리의 다른 글
| [API] System, Math, Number, Wrapper, BigInteger, BigDecimal (0) | 2025.09.16 |
|---|---|
| [API] StringBuffer, StringBuilder, StringJoiner (0) | 2025.09.15 |
| [OOP] 불변 클래스 (0) | 2025.09.05 |
| [API] Object 클래스와 Class 클래스 (3) | 2025.08.05 |
| [API] 람다와 스트림: Mordern Java - 12. 새로운 날짜와 시간 API (2) | 2025.07.30 |