Java의 String.intern() 살펴보기

며칠 전에 String 클래스 문서를 정리하면서 intern 메서드에 관해 짧게 다룬 적이 있었습니다.

그 후에 실제로 intern 메서드가 어떻게 동작하는 지 궁금해서 짧게나마 찾아본 내용을 정리해봅니다.

intern이란?

JVM에서 관리하는 문자열 풀에서 해당 문자열을 조회하여 존재하는 경우 반환, 아닌 경우 풀에 문자열을 등록하고 해당 문자열을 반환하는 메서드이면서 이 과정 자체를 이르기도 합니다. 참고

String 객체는 불변 객체이기 때문에 동일한 객체가 공유될 수 있는 특징을 가지고 있습니다.

이 특징을 잘 활용하기 위해서는 동일한 문자열을 가지는 String 객체가 단 하나만 존재하도록 유지할 필요가 있습니다.

intern 메서드가 이러한 목표를 달성하기 위해 제공되는 메서드입니다.

JVM 내부에서 초기에는 비어있는, 문자열 객체를 관리하는 풀(pool)을 생성합니다.

이후, String 객체의 intern 메서드가 호출되면 이 풀에 해당 문자열과 같은(String.equals() 반환값이 true인) String 객체가 존재하는 경우 해당 객체를 반환하고, 존재하지 않는 경우 해당 객체를 풀에 추가하고 해당 객체를 반환하게 됩니다.

코드, 코드를 보자

Oracle JDK 라이센스가 바뀌게 되면서 윈도우 환경에서도 OpenJDK를 사용하기 시작했는데, 가장 큰 장점 중 하나는 JDK 코드를 직접 볼 수 있게 되었다는 점인 것 같습니다.

단순히 호기심 때문에, intern 메서드가 실제로 어떻게 작성되었는지 살펴보게 되었는데, 그 내용을 간단히 정리해봅니다.

String.intern()

String.java

String 클래스에서 intern 메서드는 다음과 같이 정의되어 있습니다.

public native String intern();

native 키워드는 Java 프로그램에서 다른 언어(C, C++, 어셈블리 등)로 작성된 코드를 실행할 수 있는 JNI(Java Native Interface) 키워드입니다.

in native

intern 메서드의 실제 구현은 다른 언어로 되어 있는 것 같아서 더 찾아보던 중,

다음과 같은 코드를 찾게 되었습니다.

jdk9/jdk/src/java.base/share/native/libjava/String.c

// ...
JNIEXPORT jobject JNICALL
Java_java_lang_String_intern(JNIEnv *env, jobject this)
{
    return JVM_InternString(env, this);
}
// ...

코드에서 jvm.h 파일을 include하고 있어 더 찾아봤습니다.

jdk9/hotspot/src/share/vm/prims/jvm.cpp

// ...
JVM_ENTRY(jstring, JVM_InternString(JNIEnv *env, jstring str))
  JVMWrapper("JVM_InternString");
  JvmtiVMObjectAllocEventCollector oam;
  if (str == NULL) return NULL;
  oop string = JNIHandles::resolve_non_null(str);
  oop result = StringTable::intern(string, CHECK_NULL);
  return (jstring) JNIHandles::make_local(env, result);
JVM_END
// ...

JVM_ENTRYJVM_END는 네이티브 메서드 정의를 위한 매크로들입니다. 글의 주제와 관련성은 적으니 넘어가도록 하겠습니다.

위의 코드 중 눈에 띄는 줄이 보입니다.

oop result = StringTable::intern(string, CHECK_NULL);

CHECK_NULLNullPointerExecption을 발생하기 위해 널 체크를 하는 매크로로 보입니다.

위 코드로 호출되는 메서드는 다음과 같습니다.

jdk9/hotspot/src/share/vm/classfile/stringTable.cpp

// ...
oop StringTable::intern(oop string, TRAPS)
{
  if (string == NULL) return NULL;
  ResourceMark rm(THREAD);
  int length;
  Handle h_string (THREAD, string);
  jchar* chars = java_lang_String::as_unicode_string(string, length, CHECK_NULL);
  oop result = intern(h_string, chars, length, CHECK_NULL);
  return result;
}
// ...

같은 파일에서 위 코드에서 호출한 intern 메서드를 찾을 수 있었습니다.

// ...
oop StringTable::intern(Handle string_or_null, jchar* name,
                        int len, TRAPS) {
  // shared table always uses java_lang_String::hash_code
  unsigned int hashValue = java_lang_String::hash_code(name, len);
  oop found_string = lookup_shared(name, len, hashValue);
  if (found_string != NULL) {
    return found_string;
  }
  if (use_alternate_hashcode()) {
    hashValue = alt_hash_string(name, len);
  }
  int index = the_table()->hash_to_index(hashValue);
  found_string = the_table()->lookup_in_main_table(index, name, len, hashValue);

  // Found
  if (found_string != NULL) {
    if (found_string != string_or_null()) {
      ensure_string_alive(found_string);
    }
    return found_string;
  }

  debug_only(StableMemoryChecker smc(name, len * sizeof(name[0])));
  assert(!Universe::heap()->is_in_reserved(name),
         "proposed name of symbol must be stable");

  Handle string;
  // try to reuse the string if possible
  if (!string_or_null.is_null()) {
    string = string_or_null;
  } else {
    string = java_lang_String::create_from_unicode(name, len, CHECK_NULL);
  }

#if INCLUDE_ALL_GCS
  if (G1StringDedup::is_enabled()) {
    // Deduplicate the string before it is interned. Note that we should never
    // deduplicate a string after it has been interned. Doing so will counteract
    // compiler optimizations done on e.g. interned string literals.
    G1StringDedup::deduplicate(string());
  }
#endif

  // Grab the StringTable_lock before getting the_table() because it could
  // change at safepoint.
  oop added_or_found;
  {
    MutexLocker ml(StringTable_lock, THREAD);
    // Otherwise, add to symbol to table
    added_or_found = the_table()->basic_add(index, string, name, len,
                                  hashValue, CHECK_NULL);
  }

  if (added_or_found != string()) {
    ensure_string_alive(added_or_found);
  }

  return added_or_found;
}
// ...

StringTable 클래스는 RehashableHashTable 클래스을 상속받는 클래스입니다.

이름과 코드에서 추측해보면, 해시 테이블의 균형이 맞지 않지 않을 때(특정 버킷에 데이터가 집중될 때) 해시 알고리즘을 변경해서 데이터가 테이블 전체에 고루 퍼지도록 할 수 있는 해시 테이블로 보입니다.

또한 StringTable 클래스는 내부에 정적 변수로 StringTable_the_tableCompactHashTable_shared_table 필드를 가지고 있습니다.

_the_table을 정적 필드로 선언하고 the_table() 함수로 접근하는 것으로 미루어보아 StringTable 객체를 싱글턴으로 사용하기 위한 것으로 보입니다.

CompactHashTable은 Java의 CDS라는 기능을 위해 사용되는 해시 테이블입니다. CDS는 여러 JVM 프로세스가 공용으로 사용하는 메모리 공간에 로드된 클래스들을 모아놓고 공유하기 위한 목적으로 활용하는 공간입니다.

자세한 설명은 Oracle DocsIBM Knowledge Center에서 다루고 있습니다.

즉, 실질적으로 shared_tablethe_table이라는 두 개의 테이블을 참조하고 있으며, intern 메서드가 호출되면 shared_table에서 먼저 검색해서 찾으면 반환합니다.

찾지 못한 경우에는 RehashableHashTablethe_table에서 검색해보고 여기서도 찾지 못하면 the_table에 문자열을 추가한 뒤에 이를 반환하게 됩니다.

결론

문자열이 intern되는 과정을 정리해보면 다음과 같이 요약할 수 있겠습니다.

  1. intern을 위한 JVM의 네이티브 함수를 호출한다.
  2. StringTable 클래스에서 이후의 intern 과정을 처리한다.
  3. StringTable에서는 shared_tablethe_table을 정적 필드로 관리한다.
  4. shared_table에서 문자열을 검색하여 찾으면 반환한다.
  5. shared_table에서 찾지 못한 경우 the_table에서 검색한다.
  6. the_table에서도 찾지 못하면 문자열을 the_table에 추가하고 이를 반환한다.

문자열을 문자열의 해시값을 키로 하는 해시 테이블을 사용한다는 점과 해시 테이블의 데이터가 고르지 않게 분포하는 경우에 해시 알고리즘을 변경할 수 있다는 점을 알 수 있었습니다.

또한, 여러 JVM 프로세스에서 공통으로 사용하는 공간에 별도의 테이블을 만들어 관리함으로써 같은 문자열 객체를 공유할 수 있도록 한다는 점도 알아봤습니다.

목록으로