JEP 401: Value Classes and Objects이 실제 JDK preview로 들어가는 단계에 도달
- 핵심 목표는 Java 객체를 “
클래스처럼 코딩하고 int처럼 동작”하게 만들어 객체 헤더·힙 할당·GC·포인터 간접 참조 비용을 줄이는 것
- JDK 28의 value class는 아직
null 가능한 참조 타입이며, non-null 타입·전문화 제네릭·128비트 인코딩은 포함되지 않고 --enable-preview
가 필요함
- JVM은 value object를
스칼라화하거나 필드·배열에 힙 평탄화할 수 있지만, erased generic이나 Object
같은 상위 타입에서는 힙 객체로 materialize될 수 있음
- Java 개발자는 identity와 value의 차이를 코드 설계에 반영해야 하며,
==
, synchronized
, primitive wrapper, 배열 성능, 향후 제네릭 전문화까지 영향이 이어짐
JDK 28에 들어오는 Valhalla의 범위
- 6월 15일 Oracle 엔지니어 Lois Foltan이 JEP 401: Value Classes and Objects의 OpenJDK 메인 저장소 통합과
JDK 28 타깃을 확인함
- 관련 pull request는
1,816개 파일에 걸쳐 19만 7천 줄 이상을 추가함
- 변경 규모가 커서 통합 중 다른 커미터들에게 큰 커밋을 잠시 보류해 달라는 요청이 있었음
- JEP 401은 기본 비활성화된
preview 기능임
- 문법을 사용하려면
--enable-preview
가 필요함
Valhalla가 겨냥한 Java 객체 모델의 비용
-
Valhalla의 표어는 “
codes like a class, works like an int”임
-
메서드, 생성자 검증, 의미 있는 필드 이름을 가진 일반 클래스를 쓰면서도 JVM이 primitive처럼 효율적으로 다룰 수 있게 하는 것이 목표임
-
Java에서는 8개 primitive를 제외하면 거의 모든 것이
참조 타입임
Point p = new Point(1, 2)
에서 p
는 point 자체가 아니라 힙 객체를 가리키는 포인터임
-
필드를 읽을 때마다 JVM은 포인터를 따라가야 함
-
객체 수가 늘어나면 비용이 급격히 커짐
-
각 객체에는 타입, 동기화 상태 등을 위한
객체 헤더가 있음
-
객체는 힙에 할당되고 이후 GC 대상이 됨
Point
백만 개 배열은 실제로 백만 개 포인터와 힙 곳곳의 백만 개 객체로 구성됨
- Brian Goetz의 “State of Valhalla”는 이런 메모리 배치를
fluffy라고 부름
- Valhalla가 원하는 것은 데이터가 나란히 놓이는
dense한 배치임
하드웨어 격차와 escape analysis의 한계
-
밀도 높은 메모리 배치가 중요한 이유는 CPU와 메모리 속도 격차 때문임
-
1995년에는 메모리 접근 비용이 CPU 연산과 비슷했음
-
현재 CPU는 메인 메모리보다 두 자릿수 배 빠르며, 이 차이를 캐시가 메움
-
CPU는 보통
64바이트 cache line 단위로 메모리를 읽음
-
데이터가 조밀하고 순서대로 있으면 한 번에 유용한 값을 많이 가져옴
-
포인터를 따라 흩어진 객체에 접근하면 cache miss가 발생할 수 있고, hit보다 훨씬 느릴 수 있음
-
JVM의
escape analysis는 일부 객체 할당을 제거할 수 있음
-
객체가 로컬 코드 조각 밖으로 “escape”하지 않는다고 판단되면 힙에 할당하지 않고 필드를 변수나 레지스터로 펼칠 수 있음
-
다만 escape analysis는 예측 가능성이 낮고 취약함
-
객체가 다른 클래스의 필드에 들어가거나 배열에 저장되거나 복잡한 메서드로 전달되거나 JIT가 분석할 수 없는 경계를 넘으면 최적화가 멈출 수 있음
-
작은 리팩터링, JDK 업데이트, 코드 구조 변경만으로 객체가 다시 힙에 올라갈 수 있음
-
성능을 위해 객체를 포기하고
r
, g
, b
같은 raw byte로 직접 인코딩하면 속도는 얻을 수 있지만 안전성·가독성·검증·메서드를 잃음
2014년 시작과 Q World에서 L World로의 전환
-
Project Valhalla는 공식적으로 2014년에 시작됨
-
James Gosling은 당시 이를 “six PhDs tied into a single knot”라고 표현함
-
Java 창시자들은 Java 1.0 시절부터 value type을 원했지만, 1995년에는 문제가 너무 어려워 포기했음
-
초기 목표는 프로그래밍 모델과 현대 하드웨어 성능 특성의
정렬을 회복하는 것이었음
-
사용자가 직접 primitive처럼 flat하고 dense한 타입을 선언하되, 일반 클래스처럼 보이고 동작하게 만드는 방향임
-
초기 prototype은
Q World 방향이었음
-
새 value type을 객체와 근본적으로 다른 존재로 보고, 별도 type descriptor, bytecode, top type을 두는 방식임
-
JVM 타입 시스템 전체가 두 가지 변형을 가져야 해 복잡성이 커졌음
-
2019년쯤 등장한
L World가 전환점이 됨
-
value type이 일반 reference와 같은 “L carrier”를 공유함
-
팀은 이 통합이 어렵다고 예상했지만, 큰 타협 없이 동작했고 이전 prototype의 여러 문제를 해결함
-
L World에서 중요한 분리가 생김
-
JVM 모델과 언어 모델이 100% 겹칠 필요는 없음
-
JVM에는 L World 모델을 두고, 프로그래머에게는 더 편한 언어 모델을 제공할 수 있음
-
이후 작업은 value class와 전문화 제네릭의
두 단계로 나뉨
이름과 모델의 변화
-
Valhalla 용어는 여러 번 바뀌었고, 단순한 명칭 변경이 아니라 모델 변경을 반영함
-
초기 용어는
value types였음
-
당시에는 이 타입들이 정확히 어떤 존재인지 아직 명확하지 않았음
-
2019~2020년쯤
inline classes 모델이 자리 잡음
-
기존 클래스는 identity classes로, 새 클래스는 identity가 없는 inline classes로 구분됨
-
inline class는 기본적으로 final이고, 필드는 final이며, 동기화할 수 없는 제약이 잡힘
-
2021년 “State of Valhalla”는
primitive classes와 두 projection 모델을 다룸
-
하나의 타입이 flat하고 null이 불가능한 value variant와 null을 허용하는 reference variant를 갖는 구상이었음
Point.val
/ Point.ref
, 이후 Point!
/ Point?
같은 문법도 실험됨
-
이 모델은 강력했지만
인지 부담이 컸음
-
프로그래머가 같은 타입의 두 형태와 변환 시점을 일상적으로 이해해야 했음
-
결국 사용자 모델을 단순화하기 위해 dualism이 축소됨
-
현재 JEP 401은
value class와 value object를 사용함
value
modifier로 value class를 선언함
-
인스턴스는 identity가 없는 value object임
-
value class는 여전히 reference type임
-
non-nullability는 별도 optional JEP인 Null-Restricted Value Class Types로 분리됨
-
이전 “primitive classes” 모델을 설명하는 오래된 글은 현재 OpenJDK 기준과 다를 수 있음
-
JEP 401에는 preview인 JEP 402: Enhanced Primitive Boxing도 함께 있음
-
primitive와 wrapper 사이 변환을 더 부드럽게 만드는 방향임
-
완성된 형태로 JEP 401과 함께 모두 들어온다고 가정하면 안 됨
JDK 28의 value class 모델
modifier로 선언함
value class USDCurrency implements Comparable<USDCurrency> {
private int cents; // implicitly final
public USDCurrency(int dollars, int cents) {
this.cents = dollars * 100 + cents;
}
public USDCurrency plus(USDCurrency that) {
return new USDCurrency(0, this.cents + that.cents);
}
// dollars(), cents(), compareTo(), toString()...
}
- value record도 가능함
- 주요 규칙은 다음과 같음
- 모든 instance field는
암묵적으로 final임
- method는
synchronized
일 수 없음
-
클래스는 기본적으로 final임
-
value class와 abstract value class로 구성된 계층은 가능함
-
identity가 있는 클래스를 상속할 수 없음
-
interface 구현은 가능함
-
핵심 특성은
identity가 없음임
-
일반 객체는 같은 내용을 가져도
new Point(1, 2)
로 두 번 만들면 서로 다른 객체임
값 4에 “서로 다른 두 개의 4”가 없는 것처럼 identity가 없음
==
, synchronized
, null의 변화
는 identity 비교가 아니라 substitutability 검사가 됨
- 같은 클래스이고 같은 필드 값을 가지는지 재귀적으로 비교함
- primitive field는 bit 단위로 비교하고, object field는 다시
==
로 비교함
new USDCurrency(3,95) == new USDCurrency(3,95)
는 true가 됨
는 내부 상태를 보기 때문에, “같은 데이터를 표현하는가”에는 보통 equals
가 더 적합함
- value object에는 동기화할 identity가 없음
- 동기화를 시도하면
IdentityException이 발생함
- identity를 강제 확인해야 할 때는
Objects.requireIdentity
와 Objects.hasIdentity
를 사용할 수 있음
- JDK 28의 value class는 여전히
null 가능함
USDCurrency d = null;
은 합법임
스칼라화와 힙 평탄화
- JEP 401은 JVM이 value object를 최적화할 수 있는 자유를 줌
스칼라화(scalarization) 는 value object reference를 필드 집합으로 분해하는 JIT 기법임
Color
포인터를 전달하는 대신 r
, g
, b
byte와 null 여부 flag를 전달할 수 있음
-
할당과 GC 비용이 사라질 수 있음
-
escape analysis와 비슷하지만 더 예측 가능하고, inline되지 않은 method call 경계를 넘어 적용될 수 있음
-
스칼라화에는 제한이 있음
-
변수 타입이 value class의 supertype인
Object
이거나 erased generic parameter이면 보통 동작하지 않음
- 이 경우 객체가 힙에 materialize되어야 함
힙 평탄화(heap flattening) 는 value object의 필드 값을 compact bit vector로 인코딩해 필드나 배열 셀에 직접 쓰는 방식임
-
다른 힙 위치를 가리키는 포인터가 필요 없음
-
데이터 밀도와 locality가 생김
-
평탄화된 데이터는 concurrent access에서 tearing을 피하기 위해
atomic하게 읽고 쓸 수 있어야 함
-
일반 플랫폼에서 “충분히 작은” 크기는 null flag를 포함해 64비트 수준일 수 있음
-
작은 value class는 잘 평탄화될 수 있지만, 두 개의
int
필드나 하나의 double
만 있어도 atomic write 크기에 맞지 않아 일반 힙 객체가 될 수 있음
- 향후에는 128비트 인코딩과 null-restricted type이 더 큰 value class의 평탄화를 가능하게 할 수 있음
Boxing, wrapper, 배열에서의 효과
, Long
, Double
같은 primitive wrapper class 자체가 value class가 됨
- box가 identity를 잃으므로 JVM이 스칼라화하고 평탄화할 수 있음
Integer[]
는 int[]
의 효율에 가까워지고, boxing overhead가 크게 줄어드는 방향임
- JEP 402: Enhanced Primitive Boxing은 primitive와 box 사이 변환을 더 확장함
List<int>
같은 표현으로 가는 길을 열지만, 아직 별도의 성숙 중인 작업임
- 배열에서 효과가 가장 잘 드러남
- 기존
Color[]
는 백만 개 포인터와 힙에 흩어진 백만 개 객체가 될 수 있음
는 연속된 색상 값을 직접 저장하는 contiguous block이 될 수 있음
- CPU는 cache line 단위로 여러 값을 순차적으로 읽을 수 있음
Point[]
예시로 보는 전후 차이
- Valhalla 이전의 일반 class 예시는 다음과 같음
final class Point {
final int x;
final int y;
Point(int x, int y) { this.x = x; this.y = y; }
}
Point[] points = new Point[1_000_000];
- 이 배열은 백만 개의
포인터를 담음
- 각 포인터는 힙 어딘가의 별도
Point
객체를 가리킴
외에도 객체 헤더가 있음
-
순회할 때 포인터를 읽고, 해당 주소로 점프하고, 필드를 읽어야 함
-
Valhalla 이후 value class 예시는 다음과 같음
value class Point {
final int x;
final int y;
Point(int x, int y) { this.x = x; this.y = y; }
}
Point[] points = new Point[1_000_000];
한 단어지만, 메모리 배치는 달라짐
- JVM은 각 point의 값을 배열 안에 조밀하게 저장할 수 있음
- element마다 헤더가 없고 포인터도 없음
x
, y
두 int
기준 8바이트와 가능한 null flag로 연속 배치될 수 있음
는 여전히 이름, 생성자, 검증, method를 가진 class임
int[] xs
, int[] ys
로 쪼개고 index를 맞추는 방식을 피할 수 있음
전문화 제네릭이 아직 남은 이유
- Java generics는
type erasure로 구현됨
List<String>
과 List<Integer>
는 runtime에서 같은 List
임
는 Object
로 erasure됨
-
erasure는 Java의 기존 코드베이스를 깨지 않고 generics를 도입하기 위한 의도적 선택이었음
-
non-generic class를 generic으로 바꿔도 기존 source file과 compiled class를 깨지 않게 함
-
Valhalla와 erasure는 성능상 충돌함
List<Point>
에 value object를 넣으면 T
가 Object
로 erasure되므로 객체가 힙에 materialize되어야 함
Point[]
에서 얻은 평탄화 이점이 ArrayList<Point>
에서는 사라질 수 있음
- 복구 계획은 두 단계임
Universal Generics: 언어 수준에서 type variable이 value type까지 다룰 수 있게 함
- 여전히 erasure를 사용함
T
필드가 기본적으로 null에서 시작하는 문제 때문에 “null pollution” compiler warning이 생길 수 있음
- 경고를 해결하면 API가 specialization-ready에 가까워짐
Specialized Generics: JVM 수준에서 concrete type argument별 전문화된 class layout을 생성함
- 프로젝트 용어로 species와 type restriction이 관련됨
- 이 단계에서야
ArrayList<Point>
가 실제 flat memory를 쓸 수 있음
- JDK 28에는
full specialized generics가 없음
- 컬렉션, stream, API가 value type 위에서 flat하고 allocation-free가 되는 것은 future release의 작업임
JDK 28에 있는 것과 없는 것
- JDK 28에 들어오는 것은 다음과 같음
value class
와 value record
선언
-
JDK의 기존 value-based class 중 primitive wrapper 같은 클래스의 value class migration
-
조건을 만족하는 클래스의 스칼라화와 평탄화
-
더 저렴한 boxing
-
JDK 28에 없는 것은 다음과 같음
-
null-restricted types
-
full specialized generics
-
128비트 인코딩
-
완전히 성숙한 JEP 402
-
preview feature이므로 syntax와 동작은 release마다 feedback에 따라 바뀔 수 있음
-
JDK 28은 LTS가 아님
-
다음 LTS는 2027년 9월의 JDK 29일 가능성이 큼
-
많은 회사는 안정화된 Valhalla를 LTS에서 만날 수 있지만, JDK 28 preview가 실제 코드 feedback loop를 시작함
생태계와 코드에 생길 변화
-
고성능 Java 영역에서 Valhalla는 abstraction을 포기하지 않고 dense data를 다루는 경로가 됨
-
데이터 처리, vector computation, ML, game development, finance, codec 같은 영역이 해당됨
-
framework와 library는 value-based class migration을 시작할 수 있음
-
identity에 의존하던 코드는 동작 차이를 겪을 수 있음
==
가 value object에서 address 비교가 아니라 substitutability 비교가 됨
synchronized
는 value object에서 IdentityException
으로 이어짐
Integer
가 value class가 되어도 대부분의 경우 binary는 계속 link됨
- 새 compilation error는 이런 타입에 동기화하려는 경우임
Integer
identity에 의존한 ==
나 synchronized(someInteger)
는 영향을 받을 수 있음
- early-access build는 jdk.java.net/valhalla에서 사용할 수 있음
자주 나오는 질문 정리
- value class는 record와 다름
record
는 content가 component라는 선택임
value
는 identity를 포기하는 선택임
로 비교할 수 있음
- 의미는 address 비교가 아니라 substitutability임
- 표현하는 데이터의 동등성에는 보통
equals
가 더 적합함
-
JDK 28 value class는 null이 가능함
-
non-nullable type은 future JEP임
-
더 큰 value class의 평탄화에도 중요함
-
빠른 flat
ArrayList<Point>
는 아직 아님
- type erasure 때문에 generic collection 안의 객체는 힙에 materialize됨
- JDK 28에서 평탄화가 직접 작동하는 대표 사례는 value type의 field와 array인
Point[]
임
-
escape analysis가 전부를 대신하지는 못함
-
객체가 field, array, 분석 경계 밖으로 나가면 최적화가 깨질 수 있음
-
value object의 스칼라화는 더 예측 가능하고 method-call boundary를 더 멀리 넘을 수 있음
-
전체 Valhalla는 여러 release에 걸쳐 확장됨
-
JDK 28은 value class의 첫 preview임
-
specialized generics, null-restricted types, 128비트 인코딩은 future release에 걸친 작업임