Project Valhalla와 JDK 28: Java에 값 클래스 도입

Project Valhalla는 JEP 401: Value Classes and Objects를 통해 JDK 28에 도입됩니다. 이 업데이트를 통해 개발자는 "클래스처럼 코딩하지만 int처럼 동작하는" 클래스를 정의할 수 있게 되며, JVM은 데이터를 메모리에 밀집하게 저장하고 객체 정체성, 포인터, 객체 헤더와 관련된 오버헤드를 줄일 수 있습니다.

핵심 문제: "플러피" 메모리 레이아웃

Java의 근본 설계—거의 모든 것이 레퍼런스 타입이라는 점—은 "플러피" 메모리 레이아웃을 초래합니다. 프로그래머가 객체 배열을 만들면, JVM은 객체들을 연속적으로 저장하지 않고 힙 곳곳에 흩어져 있는 객체에 대한 포인터(레퍼런스) 배열을 저장합니다.

이 구조는 두 가지 주요 성능 병목을 일으킵니다:

  1. 포인터 간접 참조: 객체 필드에 접근할 때마다 포인터를 통해 "점프"해야 하므로 캐시 미스 위험이 증가합니다.
  2. 객체 오버헤드: 힙에 존재하는 모든 객체는 타입과 동기화를 위한 메타데이터가 포함된 헤더를 가지고 있어 상당한 메모리를 차지합니다.

현대 CPU는 메인 메모리보다 수십 배 빠르기 때문에 레퍼런스 지역성이 매우 중요합니다. 데이터가 밀집하게 저장되면 하나의 CPU 캐시 라인(보통 64바이트)으로 여러 값을 한 번에 로드할 수 있습니다. 현재 레퍼런스 중심 모델은 CPU가 메모리 가져오기를 기다리게 만들어 데이터 집약적인 애플리케이션의 성능을 크게 제한합니다.

JEP 401: Value Classes and Value Objects

JDK 28은 value 수식어를 도입해 값 클래스를 만들 수 있게 합니다. 이러한 클래스의 인스턴스는 값 객체이며, 고유한 정체성보다는 상태에 의해 정의됩니다.

값 클래스의 주요 특성

  • 정체성 없음: 동일한 필드 값을 가진 두 값 객체는 서로 교체 가능하다고 간주됩니다. 고유한 메모리 주소가 존재하지 않습니다.
  • 암묵적 final: 값 클래스의 모든 인스턴스 필드는 암묵적으로 final입니다.
  • 동기화 불가: 정체성이 없으므로 값 객체에 대해 synchronize 할 수 없으며, 시도하면 IdentityException이 발생합니다.
  • 레퍼런스 타입 상태: JDK 28에서는 값 클래스가 여전히 레퍼런스 타입이므로 null이 될 수 있습니다. 널이 허용되지 않는 값 타입은 향후 JEP에서 계획됩니다.

Equality(동등성) 변경 사항

== 연산자의 의미가 값 객체에 대해 바뀝니다. 메모리 주소(정체성)를 비교하던 것이 아니라 교체 가능성—즉, 두 값이 같은 클래스에 속하고 동일한 필드 값을 갖는지를 확인합니다. 비즈니스 로직에서의 동등성 검사는 여전히 equals()를 사용하는 것이 권장됩니다.

성능 메커니즘: 스칼라화와 플래트닝

객체 정체성 요구를 없애면서 JVM은 값 객체를 두 가지 주요 기법으로 최적화할 수 있습니다:

1. 스칼라화

JIT 컴파일러 최적화로 값 객체를 구성 필드로 "분해"합니다. 객체에 대한 포인터를 전달하는 대신, JVM은 원시 필드(예: Color 객체의 경우 3바이트)를 CPU 레지스터에 직접 전달합니다. 이는 객체 할당을 사실상 "무료"로 만들고 가비지 컬렉터(GC)의 작업을 감소시킵니다.

2. 힙 플래트닝

JVM은 값 객체의 데이터를 포인터 없이 필드나 배열 셀에 직접 인코딩할 수 있습니다. 이렇게 하면 값 객체 배열이 원시 배열(int[] 등)과 유사하게 연속적인 데이터 블록으로 저장되어 메모리 레이아웃이 밀집됩니다.

원자성 제약: 플래트닝이 이루어려면 데이터가 원자적으로 읽고 쓸 수 있어야 하며, 이는 동시 접근 시 "tear" 현상을 방지합니다. 현재 플랫폼에서는 일반적으로 64비트(널 플래그 포함) 이내에 들어가는 객체에만 플래트닝이 적용됩니다. 더 큰 객체는 별도의 객체로 힙에 남게 되며, 향후 128비트 인코딩이나 널 제한 타입이 도입될 경우 변경될 수 있습니다.

특화 제네릭으로 가는 길

JDK 28이 값 클래스를 도입했지만, 아직 제네릭의 타입 소거 문제는 해결되지 않았습니다. 현재 List<Point>는 런타임에 타입 매개변수 TObject로 소거되기 때문에 Point 객체에 대한 레퍼런스를 저장합니다. 따라서 값 객체가 힙에 물리화됩니다.

Project Valhalla의 장기 계획은 두 단계로 이루어집니다:

  • 범용 제네릭(Univeral Generics): 언어 차원에서 타입 변수에 값 타입을 포함하도록 변경하고, API를 특수화 준비를 위한 새로운 컴파일러 경고를 도입합니다.
  • 특화 제네릭(Specialized Generics): 향후 JVM 확장으로 구체적인 타입 인자를 위한 특수 클래스 레이아웃을 생성해 ArrayList<Point>Point[]만큼 효율적으로 동작하도록 합니다.

JDK 28 제공 내용 요약

Feature Status in JDK 28 Note
value modifier Preview 값 클래스/레코드 선언을 가능하게 함
Scalarization Preview 할당을 없애는 JIT 최적화
Heap Flattening Preview 작은 값 객체의 밀집 저장
Boxed Primitives Preview Integer, Long 등이 값 클래스로 변환
Non-nullable Types Not Included 향후 JEP에서 계획
Specialized Generics Not Included 향후 릴리즈에서 계획

커뮤니티 관점 및 기술적 트레이드오프

릴리즈와 관련된 기술 토론에서는 몇 가지 핵심 포인트가 강조됩니다:

"값 클래스에 대한 ==는 기본적으로 memcmp()와 같습니다. 이는 캡슐화를 깨뜨려 구현 세부 사항을 노출한다는 점에서 다소 안타깝습니다." — @layer8

"값 x가 java.lang.Object로 소거되는 함수를 가지고 있다면, 이전에는 null 여부를 확인하고 객체에 대해 동기화하는 것이 안전했습니다. 이제는 더 이상 안전하지 않으며, IdentityException이 발생할 수 있습니다." — @leiroigh

비평가들은 64비트 원자성 제한이 매우 작은 데이터 타입에만 혜택을 주는 점을 지적합니다. 다른 한편으로는 첫 단계에서 값 클래스를 널이 가능한 레퍼런스 타입으로 유지하는 것이 사용자 모델을 단순화하지만, 엄격한 "프리미티브 클래스" 모델에 비해 성능 상한을 낮춘다고 주장합니다. 그러나 옹호자들은 이러한 점진적 접근이 Java의 방대한 기존 생태계와 하위 호환성을 유지하기 위해 필수적이라고 강조합니다.

Sources