HwangHub

자바의 String 객체 관리 비법 본문

DEV-STUDY/Java

자바의 String 객체 관리 비법

HwangJerry 2024. 1. 16. 13:50

문자열은 자바에서 유일하게 new 키워드를 사용하지 않고 생성할 수 있는 객체이다. ""를 리터럴이라고 하는데, "" 리터럴로 선언해두면 컴파일 단계에서 문자열 객체를 생성할 수 있도록 자바 내부적으로 구현되어 있다. 자바에서 유독 문자열 객체에 대하여 생성 방법을 리터럴로 둔 것을 단순히 선언의 편안함 때문만은 아닐 것이라는 감이 오는 분도 있을 것이다. 도대체 new String("")이랑 무슨 차이가 있길래 "" 리터럴 생성이라는 방법을 별도로 두어 구분해 둔 것일까.

String s1 = "이건 literal pool에 저장되는 문자열입니다.";  
String s2 = "이건 literal pool에 저장되는 문자열입니다.";  
String s3 = new String("이건 literal pool에 저장되는 문자열입니다."); 

생성자와 리터럴, 무엇이 다른가?

모든 객체가 그러하듯, 문자열 또한 new 연산자를 사용하면 항상 힙에 메모리를 할당하여 새로운 문자열 객체를 등록하게 된다. 문제는 동일한 내용일지라도 항상 "새로운" 객체로 할당한다는 것이다.

String s1 = new String("안녕 나는 문자열이야");
String s2 = new String("안녕 나는 문자열이야");
System.out.println(s1==s2); // false

문자열이라는 데이터는 일반적으로 동일 내용의 문자열을 자주 재사용한다는 특징이 있는데, 이렇게 매번 메모리를 할당하게 되면 비효율적으로 메모리를 활용하게 될 확률이 높다. 즉, 문자열은 new 연산자를 사용하여 생성하면 메모리를 매우 비효율적으로 사용하게 되므로, 자고로 개발자라면 이 부분에서 문제가 있을 것이라는 감이 올 것이다.

자바가 문자열 메모리를 관리하는 비결

자바는 ""리터럴을 이용하여 객체를 생성하면 컴파일 단계에서 문자열 객체 생성에 그치지 않고 interning이라는 단계를 거친다. 이는 heap 영역에 마련되어 있는 string pool에 문자열 객체를 저장하는 것을 말한다. interning과정에서 기존에 동일한 내용의 문자열 객체가 있었는지를 확인하고, 만약 있다면 그 객체를 반환해주며, 없다면 새로 문자열 객체를 생성하여 string pool에 등록한 뒤에 그것을 반환해준다. 즉, 이미 동일한 내용의 객체가 있다면 이를 활용하여 문자열을 사용할 수 있게 구현해 둔 것이다. 이는 아래 코드에서 증명된다.

String s1 = "이건 literal pool에 저장되는 문자열입니다.";  
String s2 = "이건 literal pool에 저장되는 문자열입니다.";  
String s3 = new String("이건 literal pool에 저장되는 문자열입니다.");  

System.out.println(s1==s2); // true  
System.out.println(s1==s3); // false  

자바는 pass by value 방식으로 동작하므로, 자바의 == 연산은 해당 변수가 갖는 "값"이 동일한지를 비교하게 된다. 하지만 자바에서 참조형 변수는 힙에 저장된 데이터 번지와 매핑되는 해시코드를 갖는다. (쉽게 말해서는 힙 메모리 주소라고 이해해도 무방하다.) 따라서 위 문자열 비교는 위와 같은 결과를 얻게 되는 것이다. 즉, 같은 문자열 내용일지라도 두 문자열이 저장된 위치가 다르므로 s1과 s3는 다르다는 결과를 얻게 된다.

String s1 = "이건 literal pool에 저장되는 문자열입니다."; // string constant pool에 저장 
String s2 = "이건 literal pool에 저장되는 문자열입니다."; // string constant pool에 저장
String s3 = new String("이건 literal pool에 저장되는 문자열입니다."); // heap 영역에 저장

System.out.println(s1==s2); // true  
System.out.println(s1==s3); // false  
s3 = s3.intern();  
System.out.println(s1==s3); // true

문자열을 리터럴로 생성하겠다고 선언해두면, 컴파일 단계에서 문자열 객체를 생성한 뒤 interning을 거쳐서 효율적으로 문자열 객체를 관리하게 된다. 물론 매뉴얼로도 이렇게 사용할 수 있도록 String 클래스에서는 intern()이라는 메서드를 지원하고 있다. 이 메서드는 interning 과정을 수동으로 수행하게 하는 메서드이다. 이를 사용하면 참조형 변수가 리터럴 풀 번지와 매핑되는 해시코드를 가질 수 있도록 리턴값을 해당 변수로 받아줘야 한다. 이렇게 하면 s1 == s3를 수행해도 true라는 값을 얻어낼 수 있다. (하지만 이럴거면 리터럴로 생성하는 게 맞고, 내부적으로 리터럴 생성을 의도적으로 사용하지 않는 경우가 있는데, 그 경우의 로직에 의도적으로 intern()을 과하게 쓸 경우 오히려 메모리 누수가 발생할 수 있다. 따라서 결론은, 우리가 직접 intern()메서드를 쓸 일은 없다.)

다시 말해서, 자바에서 문자열을 "" 리터럴로 생성할 수 있는 이유는 내부적으로 intern()을 사용하기 때문이다. 이렇게 동작하기 때문에 우리는 "" 리터럴만으로 문자열을 효율적으로 관리할 수 있으며, 동일한 문자열을 받게 하면 동일한 String 객체를 참조하게 되는 것이다.

 

여담이지만, 기본적으로는 문자열과 같은 참조형 객체의 비교는 ==가 아니라 .equals() 메서드를 활용하는 게 맞다. Object에서 정의하고 있는 equals()메서드를 String 클래스에서는 힙에 있는 객체의 value 비교를 하게끔 오버라이드하고 있다. 하지만 문자열 비교 연산 속도를 극한으로 끌어올리기 위해 힙 접근을 최소화하고 싶다면 intern() 메서드를 적절하게 사용하는 것도 방법이 '될 순 있을 것 같다'고 생각할 수 있다. 하지만 자바를 계속 사용하면 느껴지겠지만, 자바는 시간의 정확성이나 빠른 실행시간을 목적으로 하는 언어가 아니다. 자바는 "객체지향"이라는 패러다임을 추구하는 만큼, 유지보수성을 주된 목적으로 추구하는 언어이다. 따라서 다시 한번 말하지만 우리가 intern()을 쓸 일은 없다.

String Pool은 JVM 메모리 구조 중 어디에 있나요?

자바는 (과거에는) 이를 Heap 내의 permanent area의 constant pool(상수 풀)이라는 영역을 관리하였으며, 아울러 string literal pool(또는 string pool)이라는 곳을 그것 내에 별도로 두어서 이 곳에 불변 객체인 문자열을 저장해두었다.  문자열 관리를 permanent generation에서 했던 이유는, 문자열은 그 데이터 특성상 한번만 쓰이지 않고 자주 쓰일 것이라는 생각 때문이었다. 근데 이 영역은 Heap 영역 안에 있긴 했지만 가득 찼을 때 GC가 도는데 그 데이터들 특성상 쉬이 정리가 되지 않아 빈번하게  OutOfMemoty Error가 발생하게 되었고, 이 불편함을 해소하기 위해 클래스와 메서드 메타데이터를 OS가 관리하는 native 영역에 meta-space라는 공간을 정의하여 별도로 관리하고, String 리터럴은 java 7부터는 Heap 영역에 string pool을 별도로 두어 관리하게 수정하였다. 이를 통해 string pool 내에 있는 문자열이 더욱 적극적으로 GC의 대상이 될 수 있어 메모리 누수의 위험을 최소화할 수 있었고, heap 내 string pool 의 hashtable size도 수정할 수 있도록 지원하고 있다.

마무리

추가로 더 알아본 바에 의하면, web application 상에서 runtime 중 request 나 response를 위해 사용하는 문자열 객체들을 내부적으로 new 연산을 사용한다고 한다. 이는 아무래도 프로그램에 쓰여진 코드 상의 문자열들과는 달리, request json과 같은 문자열들은 재사용 확률이 낮은 데이터인데 반하여 string pool에 할당하게 되면 오히려 더 메모리 누수와 같은 현상을 초래하는 원인이 되기 때문인 것으로 보인다. 따라서 이러한 문자열 객체들은 new 키워드를 통해 일반적인 인스턴스로 생성하여 적극적으로 GC의 대상이 되도록 관리하기 위해 이처럼 구현된 것으로 보인다.

 

자바에 대하여 알면 알수록 그 철학이 제대로 보이는 것 같아서 재밌다.

 

main reference: https://www.baeldung.com/java-string-pool

 

Comments