본문 바로가기
개발/Java

자바 기본 - 문자열

by 컴쏘 2024. 11. 5.

 

Java에서 String은 reference type(참조형 변수)이다. 

 

불변(Immutable)

Java에서 String 객체의 값은 변경할 수 없다.

  • 변경할 수 없다는 것은 heap 영역에서 해당 객체가 가리키고 있는 데이터 자체가 변화할 수 없다는 것을 의미 
String a = "Hello";
a = a + " World";

출처블로그

 

여기서 a는 Hello World가 출력되겠지만, heap에는 Hello와 Hello World 2개가 있다. 

  • a는 단순히 새로운 String 객체를 참조한 것이다. (hashCode() 를 통해 살펴보면 다르다는 것을 알 수 있다.) 
  • 이는 문자열이 불변이라 변경할 수 없기 때문새로운 문자열 데이터 객체를 대입하는 식으로 값을 대체하기 때문이다. 
왜 불변인가?
보안, 캐싱, 복사가 필요없는 빠른 재사용성, 동기화 때문

1. 보안 : String은 민감한 정보를 저장하기 위해 사용된다.

2. 해시코드 캐싱 : String은 HashMap, HashTable과 같은 해시 구현에도 사용된다. hashCode()로 정수 값을 받아서 키 값으로 이용하도록 컬렉션들이 설계되어 있다.

3. 복사가 필요없는 재사용성 : String을 String Constant Pool에서 관리하려 Heap 영역의 메모리를 절약할 수 있다. 같은 값에 대해서는 String 객체를 다시 만들지 않고, 이미 존재하는 객체를 참조할 수 있기 때문이다.

4. 동기화 : 불변 객체는 값이 바뀌지 않기 때문에 멀티스레드 환경에서 Thread-safe 하다는 장점이 있다. 스레드가 값을 변경하면 동일한 String을 수정하는 대신 String Constant Pool에 새 문자열이 생성되기 때문이다.

참고

 

자바 String 주소 할당 방식

  1. 리터럴을 사용한 방식 
  2. new 연산자를 사용한 방식 

저장 영역

 

두 방식은 JVM 메모리 내부적인 측면에서 큰 차이가 존재한다. (메모리에 적재되는 형태가 다름) 

  1. 리터럴을 사용한 방식 : String Constant Pool 영역(Heap 내부에 있음)에 존재 
  2. new 연산자를 사용한 방식 : Heap 영역에 존재 

이러한 차이 때문에,

String은 literal로 생성하면 이 영역에 저장되어 재사용할 수 있고, 

new 연산자로 생성하면 일반적인 Heap 영역에 생성하여 재사용할 수 없다. 

 

왜 이러한 차이가 발생할까? 자세히 알아보자. 

String Interning 

String을 literal로 초기화 한 경우 내부적으로 String.intern() 메서드가 호출된다. 

  • JVM이 String Pool에서 리터럴 문자열이 존재하는지 확인하고, 존재하면 해당 pool에 있는 리터럴의 주소값을 반환하고, 없다면 리터럴을 pool에 집어 넣고 새로운 pool 주소값을 반환한다. 
  • pool에 값이 있든 없든 무조건 pool에 값이 생성된다. 
  • intern()을 이용(수동으로 호출 가능)하면 equals() 없이 문자열 비교가 가능하다. 

위와 같은 과정을 Interning이라고 하며, 이를 통해 문자열에 할당되는 메모리를 최적화 할 수 있다. 

 

String Constant Pool

JVM은 Heap에 String Constant Pool을 가지고 있다.

 

Pool은 HashMap으로 구현되어,

  • String literal을 생성하면 - JVM은 Pool에 객체를 생성하고 - 해당 참조를 스택에 저장한다. 
  • 각 String Constant를 hashing하고 해당 데이터를 key로 value를 찾는다. (성능이 어느 정도 보장됨)

다른 특징도 살펴보면 다음과 같다. 

  • String Constant Pool의 Heap Size는 조절이 가능하다. 
  • String Constant Pool에 있는 문자열도 GC의 대상이기 때문에 효율적인 메모리 관리가 가능하다. 

 

위의 내용을 가지고 지난번에 공부했던 동일성과 동등성 에 대해서 다시 살펴보면 다음과 같다. 

  • == 연산자는 두 개의 대상의 주소값을 비교
  • equals() 메소드는 두 개의 대상의 값을 비교 
String str1 = "Hello"; 
String str2 = "Hello";

String str3 = new String("Hello"); 
String str4 = new String("Hello");

System.out.println(str1 == str2);
System.out.println(str3 == str4);

 

따라서, literal 문자열 비교는 String Constant Pool에 있는 객체 값을 바라보기 때문에, 참조하고 있는 주소 값이 같아 true가 된다. 

 

반면에, new 연산자 문자열 비교는 heap 메모리에서 서로 다른 메모리 영역에 만들어졌기 때문에 주소 값이 달라 false가 된다. 

  • 따라서, 이때 값을 비교하기 위해 equals()를 사용하는 것이다. 

 

그렇다면, 이제 자바에서 대표적으로 문자열을 다루는 자료형 클래스인 String, StringBuilder, StringBuffer에 대해 알아보자.

 

String

String은 위에서 살펴보았듯이, 불변이다. 

  • 따라서, trim이나 toUpperCase 같은 메소드를 사용하면 문자열이 변경되는 것이 아니다. 
  • 해당 메소드를 수행한 결과의 문자열을 heap에 생성하고 그 값을 반환하는 것이다. 

 

StringBuffer vs. StringBuilder

StringBuffer와 StringBuilder 클래스는 문자열을 연산할 때 주로 사용한다. 

 

StringBuffer 클래스는 내부적으로 buffer(데이터를 임시로 저장하는 메모리)라고 하는 독립적인 공간을 가지게 되어, 문자열을 바로 추가할 수 있어 공간의 낭비도 없으며 문자열 연산 속도도 매우 빠르다. 

 

StringBuilder는 StringBuffer와 비슷한 자료형으로 사용법은 StringBuffer와 동일하다. 

 

StringBuffer나 StringBuilder는 String과 다르게 가변적이다

  • 내부 Buffer에 문자열을 저장해두고 그 안에서 추가, 수정, 삭제 작업을 할 수 있도록 설계되어 있다. 
  • 가변성을 가지고 있기 때문에, .append(), .delete() 등의 API를 이용하여 동일 객체내에서 문자열 크기를 변경하는 것이 가능하다.
  • 문자열의 추가, 수정, 삭제가 빈번하게 발생할 경우라면 String 클래스가 아닌 StringBuffer / StringBuilder를 사용하는 것이 이상적이다. (내부에서 버퍼 크기를 확장하여 공간을 확보) 
    • String 객체일 경우, 매번 문자열이 업데이트 될 때마다 계속해서 메모리 블럭이 추가되게 되고, 기존의 문자열은 GC(Garbage Collector)의 제거 대상이 되어 빈번하게 Minor GC를 일으켜 Full GC(Major GC)를 일으킬 수 있는 원인이 된다. 
Minor GC? Major GC?
Minor GC는 Young Generation 영역(짧게 살아남는 메모리들이 존재하는 공간)에서 발생되는 GC이다. 새로 생성된 객체는 Young Generation의 Eden 영역에 할당된다. Eden이 가득차게 되면 Minor GC가 발생한다.

Minor GC가 발생했을 때, Eden 영역의 객체 중 살아남은 객체는 Young Generation의 Survivor 영역으로 이동한다. Young Gereration에 있는 객체가 여러 번의 GC를 거쳐 살아남아 Old Generation으로 이동하는 과정을 Promotion이라고 한다.

Major GC는 Old Generation 영역(Young Generation 에서 여러 번의 가비지 컬렉션을 거쳐 살아남은 객체가 이동하는 영역)에서 발생되는 GC이다. 이때는 Old Generation이 가득찰 때 발생하게 되며, 이때 메모리에서 사용되지 않는 객체들을 제거하여 메모리를 확보한다.

 

  • StringBuffer / StringBuilder 클래스는 String 객체와 달리 equals() 메서드를 오버라이딩하지 않아 == 비교한 것과 같은 결과를 가진다. 따라서, .toString()으로 String 객체로 변환을 하고 equals() 메서드를 사용해야 한다. 
  • 단순하게 읽는 조회 연산에서는 StringBuffer / StringBuilder 클래스가 String 클래스보다 느리다. (String 클래스는 크기가 고정되어 있기 때문) 

그렇다면, StringBuffer와 StringBuilder는 차이가 없을까? 

 

딱, 한가지가 있다고 한다. 

쓰레드 안정성 

  • StringBuffer 클래스는 쓰레드에서 안전하다. (thread safe)
    • 동기화를 지원하여 멀티쓰레드 환경에서 안전하게 동작 가능하다. (synchronized 키워드를 사용하기 때문)
    • web이나 소켓 환경과 같이 비동기로 동작하는 경우가 많을 때는 StringBuffer를 사용하는 것이 안전하다. 
  • StringBuilder 클래스는 쓰레드에서 안전하지 않다. (thread unsafe) 
    • 동기화를 지원하지 않는다.
    • 따라서, 기본 성능은 StringBuilder가 좋다. (뭔가를 덜 따지니..)
    • 싱글 쓰레드 환경이나 비동기 사용할 때 
synchronized ?
java에서 synchronized는 여러 개의 쓰레드가 한 개의 자원에 접근하려고 할 때, 현재 데이터를 사용하고 있는 쓰레드를 제외하고 나머지 쓰레드들이 데이터에 접근할 수 없도록 막는 역할을 수행한다.

한 쓰레드가 작업을 수행할 때, 다른 쓰레드들이 동시에 수행하지 못하도록 잠시 대기 시켜주고 순차적으로 실행하게 한다.

 

한 줄 요약 : 문자열 추가나 변경이 많다면 StringBuffer / StringBuilder를 문자열 변경 작업이 거의 없는 경우에는 String을 사용 

 

참고 1
참고 2
참고 3