문자열 상수와 String
문자열의 본질은 문자 배열이며, 문자열은 인코딩 규칙에 따라 저장된다.
자바에 경우 네이티브 코드와 달리 다국어 지원을 기본으로 처리하여 문자열 관리가 용이하다.
문자열 상수는 불변 객체(Immutable Object)로, 읽기 전용(Read-Only) 문자 배열로 표현된다. 배열은 고정된 크기를 가지지만, 자바에서는 `" "` 안의 문자열을 가변적으로 사용 가능하다. 따라서 문자열 크기가 변하는 과정에서 `overflow` 문제가 발생할 수 있다.
문자열 저장 방식의 변화
JDK 8 이전에는 문자열 데이터를 `char[]` 배열로 관리되었다. 모든 문자를 `UTF-16`으로 저장하여, 문자당 2바이트가 할당되었다. 예를 들어 알파벳이나 숫자처럼 1바이트로 표현한 문자도 2바이트로 저장되었고, 나머지 1바이트는 의미 없는 `0`으로 할당되었다.
JDK 9 버전 이후에는 `Compact Strings`구조형태로 내부적으로 `byte[]`배열로 관리하도록 변경되었다. 그래서 ASCII 문자처럼 1바이트로 표현 가능한 경우, 메모리를 절반으로 줄일 수 있게 되었다.
문자열 리터럴과 메모리 동작
String hello = "hello";
다음은 `"hello"`라는 문자열을 만들기 위해 `hello`라는 변수에 문자열 리터럴로 선언한 코드이다. 간단한 코드이지만 메모리 관점에서 다음과 같은 코드는 매우 중요한 의미를 가지게 된다.
해당 코드를 JVM관점에서 본다면, 다음과 같은 동작 과정이 진행된다.
- `"hello"` 리터럴 확인
- JVM은 String Pool에서 `"hello"`라는 문자열 리터럴이 존재하는지 확인한다.
- 문자열 리터럴은 상수 풀로 `Method Area`에 저장된다.
- 상수 풀에 문자열이 없는 경우
- `"hello"`문자열을 String Pool에 새로 생성하고 저장한다.
- 변수에 참조 할당
- `hello`변수는 String Pool에 저장된 `"hello"`객체를 참조한다.
위와 같은 문자열 선언에 대해선 메모리 이슈는 크게 없다. 다음은 문자열 덧셈 연산인 `+` 연산에 대해 확인해 보자.
String hello = "hello";
hello = hello + " world";
이 코드에 답이 `"hello world"`라는 것은 쉽게 유추가 가능하다. 하지만 메모리적으로 접근하면 생각보단 복잡한 과정과 연산이 진행되게 된다.
String은 불변 객체이다. 기존 문자열 "hello"와 " world"는 수정되지 않고, 새로운 문자열 객체 "hello world"가 생성된다. 이 새 객체는 JVM 메모리에 익명 객체(Anonymous Object)상태로 존재한다.
새로 생성된 객체가 참조되지 않으면, 바로 Garbage Collector(GC) 대상이 된다.
이 과정을 CPU 및 메모리 관점에서 본다면 문자열 연결 연산은 새로운 객체 생성과 GC를 포함하므로, CPU와 메모리 사용량이 증가하며, 대규모 문자열 연산이 반복되면 성능 저하가 발생할 수 있게 된다.
String 성능?
위에 문자열 덧셈(`+`)연산 과정을 자세히 보면 다음과 같이 진행이 된다.
`+`연산자를 사용하면, 컴파일러 내부적으로 `StringBuilder`를 사용하여 문자열을 연결한다.
StringBuilder sb = new StringBuilder(hello);
sb.append(world);
String result = sb.toString();
최종 결과로 `toString()`통해 만든 새로운 `String`객체가 만들어지게 된다.
`sb.toString()`이 반환한 문자열 객체가 익명 객체가 되며, 이 객체를 메모리에 생성 후, 누군가 참조하지 않는다면 가비지 컬렉션(GC) 대상이 된다.
여기서 컴파일러 최적화(Constant Folding)라는 게 있다.
String result = "hello" + "world"; // 컴파일 시 "helloworld"로 변환
문자열 리터널로 초기화하는 경우 컴파일러가 읽는 도중 문자열 상수를 `Method Area`에 저장한다고 했다. 위 코드와 같이 `리터널 + 리터널` 형태로 선언된 경우도 컴파일 도중 상수풀에 그냥 넣어 `+`연산을 미리 처리하는 형태로 동작하게 된다.
이런 경우 이미 컴파일과정에서 상수화가 되었으므로 런타임 과정에선 특별한 처리를 하지 않아 성능에 도움이 된다.
JDK 5 버전 이후부턴 컴파일 시 문자열 덧셈 연산을 StringBuilder 자동 변환되어 사용된다고 한다. 이후 JDK 9 버전에서 한번 더 변경이 있었는데 `StringConcatFactor`라는 걸 이용하여 변환처리를 진행하게 된다.
`StringConcatFactor` 결국 문자열 덧셈을 효율적으로 처리하기 위한 방법정도로 이해하면 될 것 같다.
하지만 어려움이 존재한다?
`String`클래스 덧셈은 `StringBulider`로 변환되므로 간단한 문자열 처리에 경우 문자열 덧셈 연산(`+`)으로도 충분하다. 하지만 문자열 루프 같이 대규모로 연산처리가 일어나는 경우는 다르다.
package lang.string.builder;
public class LoopStringMain {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
String result = "";
for (int i = 0; i < 100000; i++) {
result += "Hello Java ";
}
long endTime = System.currentTimeMillis();
System.out.println("result = " + result);
System.out.println("time = " + (endTime - startTime) + "ms");
}
}
위 코드를 아래와 같이 변환되어 처리된다고 생각하면 된다.
String result = "";
for (int i = 0; i < 100000; i++) {
result = new StringBuilder().append(result).append("Hello Java").toString();
}
`StringBuilder`를 이용한 최적화가 되는 것처럼 보이지만, 반복 횟수만큼 객체가 생성되는 구조이다. 반복문 내 문자열 연결은 런타임에 연결할 문자열의 개수와 내용이 결정된다. 그렇기 때문에 컴파일러에선 얼마나 많은 반복과 각 반복 안에서 어떻게 변할지 예측이 불가능하다. 따라서, 이런 경우 최적화가 어렵게 된다.
결과도 마찬가지로 2초 이상이 걸린 것을 알 수 있다.
결과
result = Hello Java Hello Java ....
time = 2490ms
StringBuilder와 StringBuffer
위 예시를 `StringBuilder`로 처리해 보자.
package lang.string.builder;
public class LoopStringBuilderMain {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append("Hello Java ");
}
String result = sb.toString();
long endTime = System.currentTimeMillis();
System.out.println("result = " + result);
System.out.println("time = " + (endTime - startTime) + "ms");
}
}
실행 결과
result = Hello Java Hello Java ....
time = 3ms
실행결과를 보면 `+`연산에 비해 최적화가 된 것을 확인할 수 있다.
StringBuilder
`StringBuilder`는 `String`클래스와 다르게 가변 `String`이 존재하는 개념이다. 가변은 내부의 값을 바로 변경할 수 있으므로 새로운 객체 생성 없이 사용한다. 따라서 성능과 메모리 사용면에서 불변보다 더 효율적이다.
`StringBuilder`는 추가(`append()`), 특정 위치 삽입(`insert()`), 삭제(`delete()`)등의 메서드를 제공하며 가변객체에서 발생할 수 있는 사이드 이펙트를 방지하기 위해 `StringBuilder` 의 결과를 기반으로 `String` 을 생성하는 `toString()`메서드도 제공한다.
StringBuilder를 직접 사용하는 것이 더 좋은 경우
- 반복문에서 반복해서 문자를 연결할 때
- 조건문을 통해 동적으로 문자열을 조합할 때
- 복잡한 문자열의 특정 부분을 변경해야 할 때
- 매우 긴 대용량 문자열을 다룰 때
StringBuilder와 StringBuffer 비교
- StringBuffer
- 문자열을 동적으로 생성 및 수정할 수 있는 클래스이다.
- 동기화 지원: 멀티 스레드 환경에서 안전하게 동작한다.
- 단점: 동기화 처리로 인해 속도가 느리다.
- StringBuilder
- StringBuffer와 동일한 기능을 수행한다.
- 동기화 미지원: 멀티 스레드 환경에서는 안전하지 않다.
- 장점: 동기화 오버헤드가 없어서 속도가 더 빠르다.
👉 요약
- 멀티 스레드 환경: StringBuffer 사용
- 단일 스레드 환경: StringBuilder 사용
동기화 개념은 멀티 스레드 학습 후에 더 깊게 이해할 수 있으니, 지금은 성능 차이와 사용 환경의 차이만 기억해두면 된다.
참조
김영한의 실전 자바 - 중급 1편 강의 | 김영한 - 인프런
김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을
www.inflearn.com
기초 탄탄! 독하게 시작하는 Java - Part 2 : OOP와 JVM 강의 | 널널한 개발자 - 인프런
널널한 개발자 | 딱 두 가지를 '제대로' 다룹니다. 바로 객체지향 프로그래밍과 JVM! 거기에 연결 리스트 기반 선형 자료구조도 덤으로 드립니다., 문법이요? 중요합니다. 하지만 그 전에 OOP의 본
www.inflearn.com
'Language > Java' 카테고리의 다른 글
[Java] 클래스 로더 (Class Loader)와 로딩 과정 (1) | 2025.01.29 |
---|---|
[Java] Wrapper 클래스와 성능(오토 박싱 & 오토 언박싱) (0) | 2025.01.23 |
[Java] 불변객체(Immutable Object) (0) | 2025.01.22 |
[Java] 자바의 동등성과 동일성 (==, equals(), hashcode()) (0) | 2025.01.17 |
[Java] 자바의 Object 클래스는 왜 있는걸까? (0) | 2025.01.17 |