본문 바로가기

개발

Java에서의 문자열

자바에서의 문자열

자바에서 모든 문자열(string)은 String 클래스를 통해 표현된다. 그리고 String 클래스는 아래를 통해 확인할 수 있듯이, 문자열을 final한 byte 배열에 나누어 저장하기 때문에 변경될 수 없어 immutable하다고 불린다.

 

image

 

그러나 String 클래스의 객체를 생성해야만 문자열을 만들 수 있는 것은 아니다. "" 를 통해 문자열 리터럴(String literal)을 생성할 수 있다. 그럼 String 객체와 문자열 리터럴의 차이는 무엇일까?

 

String 객체

new 키워드를 통해 String 객체를 생성하면, Heap 영역 내에 해당 문자열을 가진 새로운 객체가 생성된다. 이후, 똑같은 문자열을 갖고 다시 new 키워드를 통해 다른 객체를 생성하면 역시 문자열은 같지만, 다른 메모리 주소값을 가진 새로운 객체가 생성된다.

 

String literal

그러나 문자열 리터럴을 통해 문자열을 생성하면, String constant pool(이하 pool) 덕분에 조금은 다른 일이 벌어진다. pool은 Heap 영역 내에 별도로 존재하는 문자열 상수를 위한 공간이다. 만약, 문자열 리터럴을 통해 한 문자열을 생성하면, 자바는 pool 내에 해당 문자열이 이미 존재하는지 확인한다. 만약 해당 문자열이 존재하지 않으면, pool 내에 해당 문자열을 새롭게 등록시킨다. 그러나 그 반대의 경우에는 중복 등록을 하는 것이 아니라, 이미 존재하는 문자열 리터럴을 참고하여 재사용하도록 한다.

 

String 객체와 String literal의 메모리 구조

 

String a = "JavaChip";
String b = "JavaChip";
String c = new String("JavaChip");
String d = c.intern();

 

먼저, 위 코드에서 각각 a와 b는 "JavaChip"이라는 문자열 리터럴을, c는 String 객체를, d는 c의 intern() 메서드를 통해 문자열을 생성하였다. 그리고 네 개 문자열의 메모리 구조는 아래의 사진과 같다.

 

 

a와 b는 동일한 메모리 주소값을 가진다. c는 a, b와 동일한 문자열을 갖는 것처럼 보이지만, 문자열 리터럴이 아닌 new 키워드를 통한 문자열 객체를 생성했기 때문에 독립된 메모리 주소값을 가진다. 그러나 d는 c 객체를 통해 만들어졌음에도 불구하고 a, b와 동일한 메모리 주소값을 갖는다. 어떻게 이런 일이 벌어질 수 있을까?

 

String intern() 메서드

그 이유는 intern() 메서드가 가진 특징 때문이다.

 

아래는 String 클래스에서 구현하고 있는 intern() 코드이다. intern() 메소드는 호출한 문자열 객체의 문자열이 pool에 이미 등록되어 있다면, 해당 문자열 리터럴의 주소값을 반환한다. 그 반대의 경우에는 해당 문자열을 pool에 새롭게 등록하고 해당 주소값을 반환한다. 이 덕분에 문자열 리터럴인 a, b와 문자열 객체의 intern() 메서드를 통해 만든 d의 메모리 주소값이 같을 수 있게 된 것이다.

 

추가적으로 사실, 문자열 리터럴로 초기화된 String은 String interning 과정을 거치게 된다. 즉, intern() 메서드를 통해 해당 문자열 리터럴을 String pool에 캐싱하고, 그 값은 불변성을 갖게 된다.

 

생각보다 큰 intern() 메서드 호출 비용

결과만 보면, 캐싱을 통해 메모리를 아낄 수 있기 때문에 intern() 메서드를 적극 사용하는 것이 옳아보인다. 그러나 이 역시 부작용이 있을 수 있다. 메서드 호출 비용이 크기 때문이다. intern() 메서드의 동작 원리를 생각해보면, 먼저 String 객체 생성이 이뤄져야 한다. 그리고 String constant pool 내에 존재하는 문자열 리터럴을 찾아서 String.equals() 메서드를 통해 비교해야 한다. 그러니 속도가 느릴 수 밖에 없다. 그러므로 메모리를 아낄 수 있다는 생각에 매몰되어 퍼포먼스를 낮추는 일을 저지르지 말자.

 

image

 

자바는 왜 String constant pool을 만들었을까?

String은 다른 자료형과 달리, 한 번 사용되면 재사용될 확률이 높다. 그래서 한 번 사용된 값을 저장하고, 이를 재사용할 수 있도록 캐싱을 적용한 결과가 String constant pool이다. 그 덕분에 문자열 리터럴을 사용할 때 메모리 할당을 최적화할 수 있게 되었다.

 

초기에는 String constant pool가 Permanent 영역에 존재했었다. 이 영역은 고정된 사이즈이고, GC 대상에서 제외된 영역으로, 객체의 생명주기가 영구적일 것으로 생각되는 객체를 주로 저장하는 곳이었다. 그래서 interning 되는 문자열이 많아지면, Permanent 영역이 가득 차 OutOfMemory Error(OOM)가 발생할 수도 있었다.

 

그러나 Java 8 이후부터는 pool이 Heap 영역에 저장됨으로써, OOM 에러를 만날 확률이 줄어듦과 동시에 GC 대상이 되어 메모리 관리에서 더 많은 이점을 얻게 되었다.

 

new String(...)을 사용할 때는 언제일까?

String 객체의 값 immutable하기 때문에 thread-safe하다. 그리고 문자열 리터럴 역시 String 문자열이기에 해당 장점을 그대로 갖고, 중복된 값을 저장하지 않아 메모리를 낭비하지 않는다는 장점까지 존재한다. 그에 반해, String 객체는 중복된 값을 가진 객체를 계속 생성할 수 있다.

 

많은 고민을 했으나, immutable한 값을 가진 객체를 중복 생성하여 사용할 상황을 생각해낼 수 없었다. 심지어 Effective Java 3rd edition - ITEM 6 : 불필요한 객체 생성을 피하라에서도 아래와 같은 예를 절대 따라하지 말 것! 이라고 표현하였다.

 

String s = new String("JavaChip");

 

그 이유는 "JavaChip" 문자열 리터럴이 string interning 과정을 거치고, s 객체를 생성하여 객체 내부의 값에 "JavaChip" 문자열 리터럴의 값을 넣기 때문이다. 즉, String 객체와 String literal 두 개를 만드는 셈이다.

 

그러나 더 찾아보니, String 생성자의 인자로 문자열 리터럴 대신 String 객체를 넘겨준다면, 상당히 유용하게 사용할 수 있는 사례가 있었다.

 

파일을 통해 읽어온 엄청나게 큰 크기의 문자열을 가진 String 객체를 substring() 할 경우였다. substring() 메서드가 내부적으로 원본 String 객체의 문자열 값이 담긴 배열 레퍼런스를 계속 참조하도록 하여, 큰 크기의 문자열이 GC되지 않아 문제가 생기는 것을 막을 수 있는 사례였다.
(https://stackoverflow.com/questions/390703/what-is-the-purpose-of-the-expression-new-string-in-java/390854#390854)

 

그러나 이 사례는 Java가 업데이트 되면서 substring() 메서드가 더 이상 원본 문자열의 레퍼런스를 참조하지 않고, 잘라진 새로운 배열을 반환하게 되면서 더 이상 쓸모가 없게 되었다.

 

결국, String 객체를 사용할 상황이 거의 존재하지 않는다. 만약 사용되어야 한다면, String literal의 장점과 String Object의 단점을 모두 무시해야 할 상황이어야 할 것이다.

 

자바는 왜 String을 immutable하게 만들어서 불편한 부수효과를 만들어냈을까?

자바를 개발한 제임스 고슬링에 의하면, 캐싱, 보안, 복사가 필요없는 빠른 재사용성, 동기화 성능 때문이라고 한다.
(https://ellerymoon.tistory.com/104)

  • 보안 이슈: 만약 네트워크 통신을 통해서 받은 ID와 password String이 mutable하다면 validation을 거친 이후에도 받아온 String의 안전을 보장할 수 없다.
  • 동기화 이슈: immutable하다면 여러 쓰레드에서 접근해도 thread-safe하다.
  • String.hashCode()를 이용한 캐싱: String 문자열이 immutable하므로, 이를 키값으로 사용한 해싱 컬렉션의 퍼포먼스가 향상된다. 이 방식은 String pool에서도 사용되어 기존에 캐싱된 문자열의 키값이 있는지 빠르게 조회할 수 있다.

 

더불어 Effective Java 3rd edition - ITEM 17 : 변경 가능성을 최소화하라에서도 immutable 클래스는 아래와 같은 이유로 적극 권장하였다.

  • 가변 클래스보다 설계, 구현, 사용이 쉽고 오류가 생일 여지도 적다.
  • 불변 객체는 단순하다. 값이 절대 바뀌지 않기 때문에 믿고 사용할 수 있다.
  • 불변하기에 근본적으로 thread-safe하다. 그래서 안심하고 공유할 수 있다.
  • 불변 객체끼리는 내부 데이터를 공유할 수 있다. 어차피 바뀌지 않기 때문이다.
  • failure atomicity를 보장한다. 그래서 불일치 상태에 빠질 일이 없다.

 

String.hashcode()가 문자열에 대한 고유값을 가질 수 있을까?

앞서 immutable한 문자열을 key로 사용하면, 해싱 퍼포먼스가 올라가고, String Constant pool에서도 내부적으로 HashTable 구조를 갖고 있어 성능이 보장된다고 한다.

 

그러나 해시는 중복된 키를 사용할 경우, 버킷에 이미 레코드가 존재하여 해시 충돌이 일어나고, 최적 성능을 보장받지 못하는 상황이 벌어지기도 한다. 그래서 문자열의 해시코드는 유일한 고유값을 가질 수 있는 것인지 궁금증이 생겼다.

 

먼저, 자바에서 String의 해시코드를 구하는 방법 은 ASCII 코드값을 이용해 비트 연산의 합으로 해시코드를 만들기 때문에 중복이 생길 수 있다. 예를 들어서, "FB""Ea"의 해시코드는 같다. 게다가 String.hashcode()의 반환 타입은 int이다. int는 4byte의 정수공간만을 담을 수 있지만, String은 최대 2^31 - 1의 길이를 가질 수 있으므로 문자열 해시코드의 유일성은 보장받을 수 없다.

 

String + String은 속도가 느리다?

결론 먼저 말하자면, 이제는 더 이상 느리지 않다.

 

String 문자열은 immutable하다. 그래서 두 String의 덧셈을 할 경우에는 기존 두 문자열은 그대로 존재하고, 두 개가 더해진 새로운 문자열 인스턴스가 생성된다. 이러한 과정 때문에 String 문자열의 덧셈은 느렸다.

 

String str = "Java" + "Chip";

 

위의 경우라면, String constant pool에 "Java""Chip"은 그대로 존재하고, "JavaChip"이라는 새로운 문자열 리터럴이 추가된다. 이후에는 str 레퍼런스 변수가 더 이상 "Java""Chip"을 가르키지 않기 때문에 두 문자열 리터럴은 GC 대상이 된다.

그러나 JDK 1.5 이후부터는 컴파일 단계에서 String 문자열의 덧셈이 StringBuiler로 연산되도록 변경되었다. 그래서 더 이상 성능상의 큰 이슈는 존재하지 않는다.

 

StringBuilder와 StringBuffer

StringBuilder가 무엇이길래 성능상의 이슈가 없다고 하는 것일까? StringBuilder는 immutable한 String과 달리 가변의 문자열을 다룰 수 있다. 그래서 문자열이 변경되어도 새로운 객체를 만들지 않아도 되기 때문에 속도가 빠르다.

 

StringBuilder 외에도 StringBuffer가 존재하는데, 이 역시 가변의 문자열을 다룰 수 있어 속도 면에서 우수하다.

 

이 둘이 String과 다르게 가변 문자열을 다룰 수 있는 이유는 AbstractStringBuiler를 상속받아 구현된 클래스이기 때문이다. 아래의 내부 코드를 보면, AbstractStringBuiler는 String과 달리 문자열 값을 final 키워드가 없이 일반 byte 배열에 담고 있다.

 

image

 

사실 StringBuilder와 StringBuffer 클래스가 제공하는 메서드는 같다. 두 클래스의 차이는 StringBuffer가 각 메서드에 synchronized 키워드가 붙여져 있어 동기화를 보장한다는 점에 있다. 그래서 성능 상으로 동기화를 지원하지 않는 StringBuilder가 더 빠르다.