Split View: 자바 Value Objects(JEP 401)가 바꾸는 것 — 아이덴티티를 버리면 얻는 것과 잃는 것
자바 Value Objects(JEP 401)가 바꾸는 것 — 아이덴티티를 버리면 얻는 것과 잃는 것
- 들어가며 — 7월 31일 00시 45분 UTC, 22만 줄
- 아이덴티티가 없다는 것의 정확한 의미
- 성능은 어디서 나오나 — 플래트닝과 스칼라화
- records, 그리고 primitives와의 관계
- 무엇이 깨지나
- 라이브러리 작성자가 지금 할 일
- 프리뷰라는 단어의 무게 — 일정과 남은 조각
- 마치며 — 객체 모델을 바꾸는 변경은 조용히 오지 않는다
- 참고 자료
들어가며 — 7월 31일 00시 45분 UTC, 22만 줄
2026년 7월 31일 00시 45분 UTC, OpenJDK 메인라인에 커밋 하나가 들어갔습니다. 제목은 두 줄입니다.
8389219: Implement JEP 401: Value Objects (Preview)
8389220: Implement JEP 539: Strict Field Initialization in the JVM (Preview)
해당 커밋의 통계는 1,888개 파일에 208,011줄 추가, 13,161줄 삭제입니다. 원본 풀 리퀘스트 31120은 5월 11일에 David Simms가 열었고, OpenJDK의 Skara 봇이 관례대로 단일 커밋으로 스쿼시해 넣었습니다. 몇 시간 뒤 해커뉴스 스레드가 177포인트까지 올라갔습니다.
JEP 401 자체는 2020년 8월 13일에 만들어졌고, 그 뿌리인 Project Valhalla는 2014년에 시작됐습니다. Effort와 Duration 항목이 나란히 XL로 적혀 있는 JEP는 흔치 않습니다. 이 글을 쓰는 시점의 상태는 Targeted, Release 28이고, 문서 갱신일은 2026년 7월 30일입니다. 함께 들어간 JEP 539는 값 객체의 필드가 초기 생성 단계에서 반드시 초기화되도록 바이트코드 검증 수준에서 강제하는 의존 JEP로, 401 없이는 의미가 없고 401은 이것 없이 성립하지 않습니다.
여기서 흥분을 한 단계 낮춰 두는 편이 좋겠습니다. 이건 프리뷰 기능이고, 기본으로 꺼져 있으며, JDK 28의 GA는 아직 한참 남았습니다. 그럼에도 짚어 볼 가치가 있는 이유는 이것이 자바 객체 모델 자체를 건드리기 때문입니다. == 연산자와 synchronized 키워드의 의미가 바뀌는 변경은 자바 역사에서 손에 꼽습니다.
아이덴티티가 없다는 것의 정확한 의미
JEP가 드는 예가 명료합니다. LocalDate.of(1996, 1, 23)으로 만든 객체와, 거기에 30년을 더했다가 다시 뺀 객체는 같은 년·월·일을 담고 있습니다. equals는 true를 돌려줍니다. 그런데 ==는 false입니다. 두 객체가 서로 다른 아이덴티티를 갖기 때문입니다.
가변 객체에게 아이덴티티는 꼭 필요합니다. 지금은 상태가 같아도 나중에 달라질 수 있는 두 객체를 구별해 줘야 하니까요. 텍스트 편집기의 두 줄이 우연히 같은 문자열을 담고 있다고 해서 하나를 고칠 때 다른 하나가 같이 바뀌면 안 됩니다. 하지만 불변 데이터에서는 사정이 반대입니다. 1996-01-23을 나타내는 두 LocalDate 사이에는 실질적인 차이가 없습니다. 구별할 이유가 없는데 구별을 강제당하고 있는 상태입니다.
그 강제의 대가가 두 가지입니다. 하나는 매번 새 메모리를 할당해야 한다는 것이고, 다른 하나는 그 메모리를 쓸 때마다 포인터를 따라가야 한다는 것입니다. JEP는 LocalDate 배열의 메모리 배치를 그림으로 보여 주는데, 사실상 48비트짜리 데이터 다섯 개를 담기 위해 포인터 다섯 개와 힙 객체 다섯 개(각각 최소 64비트짜리 헤더 포함)를 쓰고 있습니다. 게다가 그 객체들이 서로 멀리 떨어져 있으면 순회할 때마다 캐시 라인이 갈립니다.
JDK 28 프리뷰에서는 플랫폼 API의 30개 클래스가 값 클래스로 선언됩니다. 박싱 타입 전부(Integer, Long, Float, Double, Byte, Short, Character, Boolean)와 Number, Record, Optional 계열 넷, java.time의 12개, java.time.chrono의 역법 날짜 넷입니다. 그래서 프리뷰를 켜면 이런 일이 벌어집니다.
// jshell --enable-preview
Integer x = 1996, y = 1996;
x == y // true (프리뷰 없이는 false — Integer 캐시 범위 밖이므로)
LocalDate d1 = LocalDate.of(1996, 1, 23);
LocalDate d3 = d1.plusYears(30).minusYears(30);
d1 == d3 // true
Objects.hasIdentity(d1) // false (신규 메서드)
String은 값 클래스가 아닙니다. API와 구현이 아이덴티티에 의존하는 지점이 남아 있기 때문이고, 그래서 문자열은 계속 아이덴티티 객체입니다.
==의 새 정의는 이렇습니다. 두 값 객체는 (1) 같은 값 클래스의 인스턴스이고, (2) 원시 타입 필드의 비트 패턴이 같고, (3) 참조 타입 필드가 ==를 재귀적으로 적용했을 때 구별 불가능하면 ==입니다. 아이덴티티 객체에 대한 ==의 동작은 자바 1.0 이후 그대로입니다.
주의할 점은 ==와 equals가 이제 값 객체에서도 다를 수 있다는 것입니다. JEP의 예는 문자열의 부분열을 원본 문자열과 두 좌표로 표현하는 값 클래스입니다. 같은 문자 시퀀스를 나타내지만 내부 상태가 다르므로 equals는 true, ==는 false입니다. 부동소수도 마찬가지입니다 — 서로 다른 비트 패턴의 NaN을 담은 두 값 객체는 equals로는 같고 ==로는 다릅니다. "값 객체니까 ==를 써도 된다"는 결론으로 건너뛰면 안 됩니다. 여전히 equals가 기본입니다.
성능은 어디서 나오나 — 플래트닝과 스칼라화
JEP는 최적화를 두 갈래로 나눠 설명합니다. 이 구분이 실무적으로 꽤 중요합니다.
참조 플래트닝은 힙에 있는 필드나 배열 원소에 적용됩니다. 어떤 객체의 필드가 값 객체를 가리킬 때, 포인터 대신 그 값 객체의 필드를 참조 안에 직접 인코딩합니다. Integer 배열이라면 각 원소가 널 플래그 1비트와 int 32비트를 담은 64비트 워드가 됩니다. 포인터 배열보다 확실히 작고, 무엇보다 추가 메모리 로드가 없습니다.
참조 스칼라화는 메서드 파라미터와 지역 변수에 적용됩니다. JIT가 LocalDate 참조 하나를 널 플래그, int, byte, byte 네 개의 지역 값으로 분해해 다룹니다. JEP의 의사 코드가 이 부분을 잘 보여 주는데, plusYears 메서드가 컴파일되고 나면 LocalDate 포인터를 아예 만지지 않습니다.
두 최적화의 결정적 차이는 크기 제한입니다. 플래트닝된 참조는 항상 원자적으로 읽고 써야 합니다. 그러지 않으면 찢길 수 있습니다 — 한 스레드가 쓴 앞 절반과 다른 스레드가 쓴 뒤 절반이 섞여, 존재한 적 없는 날짜가 관측될 수 있습니다. 흔한 하드웨어에서 원자적 접근이 보장되는 크기가 64비트이므로, 가변 필드에 담기는 플래트닝된 참조는 사실상 64비트로 제한됩니다.
그래서 LocalDateTime은 가변 필드에 플래트닝될 수 없습니다. 내부의 LocalDate와 LocalTime 필드, 각각의 널 플래그, 그리고 자신의 널 플래그까지 합치면 64비트를 넘기 때문입니다. JVM은 이 경우 조용히 포인터 배치를 선택합니다. 반면 값 클래스의 필드에는 이 제한이 없습니다 — 값 객체의 필드는 변경되는 것이 관측될 수 없으므로 찢길 여지 자체가 없기 때문입니다. 같은 LocalDateTime 필드라도, 담고 있는 클래스가 아이덴티티 클래스면 포인터가 되고 값 클래스면 플래트닝될 수 있습니다.
스칼라화에는 이 제한이 없습니다. 스택과 레지스터에 있는 값은 데이터 레이스에 노출되지 않기 때문입니다.
플래트닝과 스칼라화가 언제 되는지도 JEP가 명시합니다. 이건 언어 기능이 아니라 최적화이고 직접 제어할 수 없지만, 가능성을 높이는 방법은 있습니다.
Integer[] ints = { 1996, 2006, 1996, null, null }; // 플래트닝 가능
Object[] objs = { 1996, 2006, 1996, null, null }; // 불가능
record Box<T>(T field) { } // field는 소거되어 Object — 플래트닝 불가
var b = new Box<Integer>(1996); // 힙 포인터를 저장한다
변수가 특정 값 클래스 타입으로 선언돼 있어야 합니다. 상위 타입이나 제네릭 타입 파라미터로 선언된 자리는 소거 때문에 사실상 Object이고, 최적화 대상이 아닙니다. 이 지점이 남은 Valhalla 조각들(JEP 218, 원시 타입에 대한 제네릭)이 필요한 이유입니다.
한 가지 더 있습니다. 클래스를 컴파일할 때 필드·메서드 시그니처에 등장하는 값 클래스 이름이 LoadableDescriptors라는 새 클래스 파일 속성에 기록되고, JVM은 이걸 보고 해당 값 클래스를 충분히 일찍 로드해 플래트닝된 필드와 스칼라화된 파라미터를 준비합니다. 즉 기존 클래스가 값 클래스로 마이그레이션되면, 그것을 참조하는 코드는 재컴파일해야 최적의 성능이 납니다. 재컴파일하지 않아도 동작은 하지만 힙 할당이 남습니다.
records, 그리고 primitives와의 관계
레코드는 이미 final이고 필드도 전부 final이므로 값 클래스의 유력한 후보입니다. 문법은 그냥 겹칩니다.
value record Point(int x, int y) { }
Point p = new Point(17, 3);
Objects.hasIdentity(p); // false
new Point(17, 3) == p; // true
다만 모든 값 클래스가 레코드가 될 수 있는 것은 아닙니다. 레코드는 생성자 인자와 필드가 정확히 대응하는 투명한 클래스여야 하는데, 내부 표현을 다르게 잡아 메모리를 아끼는 클래스가 많기 때문입니다. JEP의 예는 유로와 센트를 long 하나로 저장하는 통화 클래스입니다 — 값 레코드는 될 수 없지만 값 클래스는 됩니다.
원시 타입과의 관계는 JEP의 Non-Goals에 못 박혀 있습니다. 원시 타입의 취급을 바꾸는 것은 목표가 아닙니다. 값 객체는 여러 면에서 원시 타입처럼 동작하지만 별개의 개념입니다. 자바 언어는 계속 두 종류의 데이터만 다룹니다 — 원시 타입과 객체 참조. C나 C#의 struct를 도입하는 것도 명시적 비목표입니다. C#의 값 타입은 인스턴스가 아이덴티티를 갖고 필드 변경이 가능해서, 대입·호출 시 복사 의미론을 세밀하게 규정해야 하고 그만큼 사용자 모델이 복잡해집니다. 자바는 그 저수준 결정을 JVM 구현자에게 맡기는 쪽을 택했습니다.
계층 구조도 정리해 두면 이렇습니다. 값 클래스는 인터페이스를 구현할 수 있고, sealed이 될 수 있으며, abstract으로 선언하면 확장 가능한 "값 호환" 상위 클래스가 됩니다. 값 클래스는 Object나 추상 값 클래스만 확장할 수 있고 아이덴티티 클래스는 확장할 수 없습니다. java.lang.Value 같은 공통 상위 클래스는 없습니다.
해커뉴스 토론에서 자주 언급된 "네 개의 버킷"(일반 객체 / 아이덴티티 없는 객체 / 원자적 값 / 찢김 허용 값) 프레이밍은 Valhalla 설계 노트에서 나온 것이지만, 이번에 들어간 것은 앞의 둘까지입니다. 찢김을 명시적으로 허용해 원자성 제약을 벗어나는 옵트아웃 문법은 이 PR에 포함되지 않았고, JEP도 "미래의 향상"으로 미뤄 두었습니다.
무엇이 깨지나
값 클래스로 바뀌면 아이덴티티에 의존하던 연산들이 동작을 바꾸거나 실패합니다. JEP 원문 기준으로 정리하면 이렇습니다.
| 연산 | 값 객체에서의 결과 |
|---|---|
== | 아이덴티티가 아니라 필드 값을 재귀적으로 비교. Integer 1996 두 개가 이제 == |
synchronized | 정적 타입이 값 클래스면 컴파일 오류, Object 타입을 거치면 런타임에 IdentityException |
wait / notify | 락을 잡을 수 없으므로 항상 IllegalMonitorStateException |
System.identityHashCode | 아이덴티티가 아니라 필드 값에서 해시를 계산 (이름만 레거시) |
finalize | GC가 절대 호출하지 않음. 오버라이드하면 javac가 identity 경고 |
java.lang.ref 계열, WeakHashMap | Reference 생성 시 IdentityException |
| 직렬화 | 값 레코드는 자동 동작. 그 외 값 클래스는 writeReplace/readResolve 없으면 InvalidClassException |
| 깊은 리플렉션으로 final 필드 변경 | 지원하지 않음. --enable-final-field-mutation을 줘도 불가 |
clone | 원본과 구별 불가능한 값 객체를 반환. x.clone() != x 기대는 무의미해짐 |
이 표에서 실무적으로 가장 아플 자리는 세 곳입니다.
첫째, 약한 참조 계열입니다 — java.lang.ref의 Reference 하위 클래스들과 WeakHashMap. 값 객체를 키로 쓰는 약한 캐시가 있다면 그대로 예외입니다. 다만 JDK 25부터 value-based 클래스에 대해 javac가 identity 경고를 내 왔고, JDK 28부터는 값 클래스에 대해서도 같은 경고를 냅니다. 경고를 보고 있었다면 이미 목록을 갖고 있는 셈입니다.
둘째, 직렬화입니다. 값 클래스는 엄격 초기화(strictly-initialized) 필드로 컴파일되고, 역직렬화는 생성자를 거치지 않고 필드를 채우는 방식이라 안전하게 초기화할 수 없습니다. 그래서 Serializable을 구현하는 비레코드 값 클래스는 대체 객체를 쓰도록 writeReplace와 readResolve를 직접 구현해야 합니다. 안 하면 런타임 InvalidClassException이고, javac가 serial 경고를 냅니다.
셋째, 깊은 리플렉션으로 final 필드를 건드리는 라이브러리입니다. 직렬화 프레임워크, ORM, 목 라이브러리, DI 컨테이너 중 일부가 이 방식을 씁니다. 값 객체에 대해서는 예외 없이 막힙니다 — 생성자를 통해야만 합니다. final 필드 변경 경로 자체가 좁아지는 흐름은 JDK 26의 final 필드 변경 경고 — JEP 500 편에서 이미 시작된 이야기이고, JEP 401은 그 끝을 보여 줍니다.
그리고 JEP의 Risks and Assumptions에 조용히 적힌 것 중 두 가지는 라이브러리 설계자가 기억할 만합니다. ==가 값 객체 트리를 재귀적으로 비교하므로 깊은 중첩에서는 시간이 오래 걸리거나 StackOverflowError가 날 수 있습니다. 그리고 ==와 identityHashCode가 비공개 필드 값을 간접적으로 노출할 수 있으므로, 민감한 데이터를 담는 클래스는 값 클래스로 만들지 않는 편이 안전합니다.
라이브러리 작성자가 지금 할 일
JDK 28은 아직 멀었지만, 지금 해 두면 나중에 공짜로 얻는 것들이 있습니다.
1. 후보를 골라 둡니다. 판단 기준은 두 개입니다 — 인스턴스가 불변인가(모든 인스턴스 필드가 final이고 표현하는 값이 시간에 따라 변하지 않는가), 그리고 서로 교체 가능한가(같은 값을 나타내는 두 인스턴스를 구별할 필요가 없는가). 필드가 없는 추상 클래스도 좋은 후보입니다 — 아이덴티티 요구사항을 하위 클래스에 강요할 이유가 없기 때문입니다. Number가 정확히 그렇게 추상 값 클래스가 됐습니다.
2. equals와 hashCode를 먼저 오버라이드합니다. 마이그레이션 전에 이 둘이 아이덴티티에 의존하지 않도록 만들어 두면, 값 클래스로 바뀔 때 동작이 변하지 않습니다. 오버라이드하지 않은 상태로 마이그레이션하면 상속된 구현의 의미가 바뀝니다.
3. public 생성자를 정리합니다. 클라이언트가 new로 "다른 모든 객체와 구별되는 인스턴스"를 만들어 락으로 쓰거나 == 비교의 표식으로 쓰고 있을 수 있습니다. JEP가 권하는 경로는 자바 9에서 Integer 생성자에 했던 것과 같습니다 — 생성자를 deprecate하고 팩토리 메서드로 유도합니다.
4. 동기화 대상을 감사합니다. 자신의 코드가 값 후보 클래스의 인스턴스를 락으로 쓰고 있지 않은지, 그리고 문서에서 클라이언트에게 그렇게 하도록 유도하고 있지 않은지 확인합니다. 자바 16부터 value-based 클래스에 대한 동기화 경고가 있었으므로, 빌드 경고를 켜 두었다면 목록이 이미 나와 있습니다.
5. 이진 호환성은 걱정 없습니다. 클래스가 final이거나 abstract이고 필드가 모두 final이라면, value 키워드를 붙이거나 떼는 것은 이진 호환 변경입니다. 소스·동작 호환성 리스크는 위의 목록이 전부입니다.
지금 실제로 만져 보려면 JDK 28 EA 빌드를 받아 프리뷰를 켜면 됩니다.
javac --release 28 --enable-preview Main.java
java --enable-preview Main
# 단일 파일 실행기
java --enable-preview Main.java
# jshell
jshell --enable-preview
프리뷰의 규칙 하나를 짚어 둘 필요가 있습니다. 플랫폼 API의 그 30개 클래스는 프리뷰를 켰을 때만 값 클래스입니다. 프리뷰 없이 컴파일하면 컴파일러가 기존 아이덴티티 버전의 LocalDate를 쓰고, 프리뷰를 켜면 값 버전을 씁니다. 프리뷰를 켠 상태에서 아이덴티티 버전을 쓸 방법은 없습니다. 즉 이 기능은 "일부만 켜기"가 불가능합니다.
프리뷰라는 단어의 무게 — 일정과 남은 조각
날짜를 정확히 해 두겠습니다. JDK 28 프로젝트 페이지에서 JEP 401의 targeting 리뷰는 2026년 7월 30일에 끝났고, JEP 문서의 상태는 그날 자로 Targeted / Release 28입니다. 병합은 그다음 날 새벽이었습니다. 같은 페이지에 JEP 539(리뷰 종료 7월 30일)와 JEP 535(Shenandoah 세대별 모드 기본화, 리뷰 종료 8월 3일)가 함께 올라와 있습니다.
한편 JDK 27은 이미 Rampdown Phase Two에 들어가 GA가 2026년 9월 15일로 잡혀 있습니다. 자바의 6개월 주기를 감안하면 JDK 28 GA는 2027년 3월입니다. 그리고 프리뷰 기능은 관례적으로 최소 한 번 이상의 릴리스를 프리뷰로 더 보내므로, --enable-preview 없이 값 클래스를 쓸 수 있는 시점은 그보다 뒤입니다. 몇 번의 프리뷰를 거칠지는 아직 정해지지 않았습니다 — 이 부분은 확인되지 않은 사항입니다.
그리고 이번에 들어간 것이 Valhalla의 전부가 아닙니다. JEP가 Future Work로 명시한 것만 두 개입니다.
- JEP 402, Enhanced Primitive Boxing — 박싱이 값 객체로 가벼워진 것을 언어 차원에서 활용합니다.
- JEP 218, Generics over Primitive Types(개정판) — 제네릭 클래스가 값 클래스로 파라미터화됐을 때 필드·배열·지역 변수 레이아웃을 특수화할 수 있게 합니다. 앞 절에서 본 "제네릭 필드는 플래트닝 안 됨" 제약을 푸는 조각입니다.
여기에 앞서 말한 찢김 옵트아웃, 널을 배제하는 레이아웃, 값 클래스의 자동 직렬화까지가 남아 있습니다. 그러니 "Valhalla가 끝났다"는 표현은 정확하지 않습니다. 12년 만에 첫 조각이 메인라인에 들어갔습니다.
마치며 — 객체 모델을 바꾸는 변경은 조용히 오지 않는다
정리하면 이렇습니다.
- 값 클래스는 아이덴티티를 포기하는 대가로 JVM에게 메모리 레이아웃의 자유를 줍니다.
==는 필드 값 비교가 되고,synchronized는 불가능해집니다. - 성능은 플래트닝(힙의 필드·배열)과 스칼라화(스택의 지역 변수·파라미터)에서 나옵니다. 앞의 것은 원자성 때문에 64비트라는 실질적 벽이 있고, 뒤의 것은 없습니다.
- 최적화를 받으려면 변수를 구체적인 값 클래스 타입으로 선언해야 합니다.
Object나 제네릭 타입 파라미터 자리는 대상이 아닙니다. 그리고 마이그레이션된 값 클래스를 쓰는 코드는 재컴파일해야 합니다. - 깨지는 것은
java.lang.ref계열,WeakHashMap, 비레코드 값 클래스의 직렬화, 깊은 리플렉션에 의한 final 필드 변경, 그리고 클라이언트의 동기화입니다. 자바 16 이후 누적된 identity 경고가 그 목록입니다. - 상태는 Targeted / JDK 28 프리뷰이고, JDK 28 GA는 2027년 3월 예정입니다. 기본으로 켜지는 시점은 그 이후입니다.
당장 프로덕션에 영향은 없습니다. 하지만 WeakHashMap에 LocalDate를 키로 넣은 코드가 어딘가에 있다면, 그 코드는 지금 이미 경고를 내고 있을 것입니다. 지금이 그 경고를 읽을 때입니다.
참고 자료
- JEP 401: Value Objects (Preview) — 원문
- 8389219: Implement JEP 401 — 병합 커밋 cc278db (2026-07-31)
- openjdk/jdk PR #31120 — 구현 풀 리퀘스트
- 해커뉴스 토론 (item 49119063, 2026-07-31)
- JDK 28 프로젝트 페이지 — targeting 리뷰 일정
- JDK 27 프로젝트 페이지 — GA 2026-09-15
- Project Valhalla — Value Classes and Objects
- Valhalla 설계 노트 — State of Valhalla
- Try Out JEP 401 Value Classes and Objects — inside.java
- JDK 26의 final 필드 변경 경고 — JEP 500 (관련 글)
What Java Value Objects (JEP 401) Change — What You Gain and Lose by Giving Up Identity
- Introduction — July 31, 00:45 UTC, 220,000 Lines
- What It Precisely Means to Have No Identity
- Where the Performance Comes From — Flattening and Scalarization
- Records, and the Relationship with Primitives
- What Breaks
- What Library Authors Should Do Now
- The Weight of the Word "Preview" — Timeline and What's Left
- Conclusion — Changes to the Object Model Don't Arrive Quietly
- References
Introduction — July 31, 00:45 UTC, 220,000 Lines
At 00:45 UTC on July 31, 2026, a single commit landed on the OpenJDK mainline. Its title is two lines.
8389219: Implement JEP 401: Value Objects (Preview)
8389220: Implement JEP 539: Strict Field Initialization in the JVM (Preview)
The stats on the commit are 208,011 lines added and 13,161 removed across 1,888 files. The original pull request, #31120, was opened by David Simms on May 11, and OpenJDK's Skara bot squashed it into a single commit as usual. A few hours later, the Hacker News thread climbed to 177 points.
JEP 401 itself was created on August 13, 2020, and its roots — Project Valhalla — go back to 2014. It's rare for a JEP to have both Effort and Duration listed as XL. As of this writing its status is Targeted, Release 28, with the document last updated on July 30, 2026. JEP 539, merged alongside it, is a dependent JEP that enforces at the bytecode-verification level that a value object's fields must be initialized during its initial construction phase — it's meaningless without 401, and 401 doesn't hold together without it.
It's worth tempering the excitement a notch here. This is a preview feature, off by default, and JDK 28's GA is still a long way off. Still, it's worth examining closely because it touches the Java object model itself. Changes to the meaning of the == operator and the synchronized keyword are rare in Java's history.
What It Precisely Means to Have No Identity
The JEP's example is clean. An object created with LocalDate.of(1996, 1, 23), and another object obtained by adding 30 years to it and then subtracting 30 years, hold the same year, month, and day. equals returns true. But == is false. The two objects have different identities.
For a mutable object, identity is essential — you need to distinguish two objects that happen to have the same state now but may diverge later. If two lines in a text editor happen to hold the same string, editing one must not also change the other. But for immutable data, the situation is reversed. There's no real difference between two LocalDate instances representing 1996-01-23. There's no reason to distinguish them, yet distinction is being forced anyway.
That forced distinction costs two things: fresh memory has to be allocated every time, and a pointer has to be chased every time that memory is used. The JEP illustrates the memory layout of a LocalDate array with a diagram — to hold five 48-bit pieces of data, it ends up using five pointers and five heap objects (each carrying a header of at least 64 bits). And if those objects happen to sit far apart, every traversal fragments the cache line.
In the JDK 28 preview, 30 classes in the platform API are declared as value classes: all the boxed types (Integer, Long, Float, Double, Byte, Short, Character, Boolean), plus Number, Record, four classes in the Optional family, twelve in java.time, and four calendar-date classes in java.time.chrono. So turning on the preview makes this happen.
// jshell --enable-preview
Integer x = 1996, y = 1996;
x == y // true (false without preview — outside the Integer cache range)
LocalDate d1 = LocalDate.of(1996, 1, 23);
LocalDate d3 = d1.plusYears(30).minusYears(30);
d1 == d3 // true
Objects.hasIdentity(d1) // false (new method)
String is not a value class. There are still places where its API and implementation depend on identity, so strings remain identity objects.
The new definition of == is this: two value objects are == if (1) they are instances of the same value class, (2) the bit patterns of their primitive-typed fields match, and (3) their reference-typed fields are indistinguishable when == is applied to them recursively. The behavior of == on identity objects is unchanged since Java 1.0.
One thing to watch for: == and equals can now diverge even on value objects. The JEP's example is a value class that represents a substring using the original string plus two coordinates. Two instances can represent the same character sequence but hold different internal state, so equals is true while == is false. The same goes for floating point — two value objects holding NaNs with different bit patterns are equal by equals but not by ==. Don't jump to "it's a value object, so == is fine to use." equals is still the default.
Where the Performance Comes From — Flattening and Scalarization
The JEP splits the optimization into two branches, and the distinction matters in practice.
Reference flattening applies to fields and array elements on the heap. When an object's field points to a value object, instead of a pointer, the value object's fields are encoded directly inside the reference. For an Integer array, each element becomes a 64-bit word holding a 1-bit null flag and a 32-bit int. That's clearly smaller than an array of pointers, and, more importantly, there's no extra memory load.
Reference scalarization applies to method parameters and local variables. The JIT breaks a single LocalDate reference into four local values: a null flag, an int, a byte, and a byte. The JEP's pseudocode makes this vivid — once the plusYears method is compiled, it never touches a LocalDate pointer at all.
The decisive difference between the two optimizations is the size limit. A flattened reference always has to be read and written atomically, or it can tear — the first half written by one thread could get mixed with the second half written by another, and a date that never existed could be observed. Since 64 bits is the size at which atomic access is guaranteed on common hardware, a flattened reference stored in a mutable field is, in practice, capped at 64 bits.
That's why LocalDateTime can't be flattened into a mutable field: its internal LocalDate and LocalTime fields, each with its own null flag, plus its own null flag, add up to more than 64 bits. The JVM quietly falls back to a pointer layout in this case. Fields of a value class, on the other hand, have no such limit — since a value object's fields can never be observed to change, there's no room for tearing in the first place. The very same LocalDateTime field can end up as a pointer if the containing class is an identity class, or flattened if it's a value class.
Scalarization has no such limit. Values living on the stack and in registers are never exposed to a data race.
The JEP also spells out when flattening and scalarization happen. These are optimizations, not language features, so you can't control them directly — but there are ways to raise the odds.
Integer[] ints = { 1996, 2006, 1996, null, null }; // flattening possible
Object[] objs = { 1996, 2006, 1996, null, null }; // not possible
record Box<T>(T field) { } // field is erased to Object — flattening not possible
var b = new Box<Integer>(1996); // stores a heap pointer
The variable has to be declared as a specific value-class type. A spot declared with a supertype or a generic type parameter is effectively Object due to erasure, and it's not eligible for optimization. This is exactly why the remaining pieces of Valhalla — JEP 218, generics over primitive types — are still needed.
One more detail. When a class is compiled, the names of value classes appearing in field and method signatures get recorded in a new class-file attribute called LoadableDescriptors, and the JVM uses this to load those value classes early enough to prepare flattened fields and scalarized parameters. In other words, when an existing class migrates to a value class, code that references it needs to be recompiled to get the best performance. It still works without recompiling — it just keeps the heap allocation around.
Records, and the Relationship with Primitives
Records are already final with all-final fields, which makes them a natural candidate for value classes. The syntax simply overlaps.
value record Point(int x, int y) { }
Point p = new Point(17, 3);
Objects.hasIdentity(p); // false
new Point(17, 3) == p; // true
That said, not every value class can be a record. A record has to be a transparent class where constructor arguments map exactly to fields, and plenty of classes choose a different internal representation to save memory. The JEP's example is a currency class that stores euros and cents packed into a single long — it can't be a value record, but it can be a value class.
The relationship with primitive types is nailed down in the JEP's Non-Goals. Changing how primitive types are treated is explicitly not a goal. Value objects behave like primitives in many respects, but they're a separate concept. The Java language still deals with exactly two kinds of data — primitives and object references. Introducing something like C's or C#'s struct is also an explicit non-goal. C#'s value types have instances with identity and mutable fields, which forces a detailed specification of copy semantics on assignment and call, and that makes the user-facing model that much more complex. Java chose to leave that low-level decision to JVM implementers instead.
The class hierarchy is worth laying out too. A value class can implement interfaces, can be sealed, and, if declared abstract, becomes an extensible "value-compatible" superclass. A value class can only extend Object or an abstract value class — it can't extend an identity class. There's no common superclass like java.lang.Value.
The "four buckets" framing (ordinary objects / identity-free objects / atomic values / tearable values) that came up often in the Hacker News discussion originates from the Valhalla design notes, but only the first two made it into this merge. The opt-out syntax that explicitly permits tearing to escape the atomicity constraint isn't part of this PR, and the JEP defers it as "future enhancement."
What Breaks
Once a class becomes a value class, operations that depended on identity change behavior or fail outright. Here's what the JEP text says.
| Operation | Result on a value object |
|---|---|
== | Recursively compares field values instead of identity. Two Integer 1996's are now == |
synchronized | Compile error if the static type is a value class; a runtime IdentityException if routed through an Object-typed reference |
wait / notify | Always IllegalMonitorStateException, since no lock can be acquired |
System.identityHashCode | Computes the hash from field values, not identity (the name is now legacy only) |
finalize | Never called by the GC. Overriding it triggers an identity warning from javac |
java.lang.ref family, WeakHashMap | IdentityException when constructing a Reference |
| Serialization | Value records work automatically. Other value classes throw InvalidClassException without writeReplace/readResolve |
| Mutating a final field via deep reflection | Not supported. Not possible even with --enable-final-field-mutation |
clone | Returns a value object indistinguishable from the original. Expecting x.clone() != x no longer makes sense |
Three spots on this table are where it hurts most in practice.
First, the weak-reference family — the Reference subclasses in java.lang.ref, plus WeakHashMap. If you have a weak cache keyed on value objects, it now throws, full stop. That said, javac has been issuing identity warnings for value-based classes since JDK 25, and from JDK 28 it issues the same warning for value classes too. If you've been watching those warnings, you already have your list.
Second, serialization. Value classes compile down to strictly-initialized fields, and deserialization fills in fields without going through a constructor, which means it can't be safely initialized that way. So a non-record value class that implements Serializable has to implement writeReplace and readResolve itself to substitute a stand-in object. Skip it, and you get a runtime InvalidClassException, plus a serial warning from javac.
Third, libraries that mutate final fields via deep reflection. Some serialization frameworks, ORMs, mocking libraries, and DI containers rely on exactly this. For value objects, it's blocked with no exceptions — you have to go through the constructor. This narrowing of the path to mutating final fields is a story that already started in JDK 26's final-field-mutation warning — JEP 500, and JEP 401 shows where it ends up.
Two more things quietly noted in the JEP's Risks and Assumptions section are worth remembering for anyone designing a library. Because == recursively compares a tree of value objects, deep nesting can make it slow, or even trigger a StackOverflowError. And because == and identityHashCode can indirectly expose private field values, it's safer not to turn a class holding sensitive data into a value class.
What Library Authors Should Do Now
JDK 28 is still a way off, but there are things worth doing now that pay off for free later.
1. Pick your candidates. There are two criteria: are instances immutable (are all instance fields final, and does the value they represent never change over time), and are they interchangeable (is there no need to distinguish two instances representing the same value)? Field-less abstract classes are good candidates too — there's no reason to force an identity requirement onto subclasses. Number became exactly this kind of abstract value class.
2. Override equals and hashCode first. If you make sure ahead of the migration that neither depends on identity, behavior won't change once the class becomes a value class. Migrate without overriding them, and the meaning of the inherited implementation shifts under you.
3. Clean up public constructors. Clients might be calling new to create "an instance distinguishable from every other object" to use as a lock or as a marker for == comparisons. The path the JEP recommends is the same one taken with the Integer constructor back in Java 9 — deprecate the constructor and steer people toward factory methods.
4. Audit what gets synchronized on. Check whether your own code locks on instances of a value-candidate class, and whether your documentation is steering clients to do the same. Java has had synchronization warnings for value-based classes since Java 16, so if you've had build warnings turned on, the list is already there.
5. Binary compatibility is nothing to worry about. If a class is final or abstract and all its fields are final, adding or removing the value keyword is a binary-compatible change. The list above covers the entire source- and behavior-compatibility risk.
To actually try this out right now, grab a JDK 28 EA build and turn on the preview.
javac --release 28 --enable-preview Main.java
java --enable-preview Main
# single-file source launcher
java --enable-preview Main.java
# jshell
jshell --enable-preview
One rule about the preview is worth pointing out. Those 30 platform-API classes are value classes only when the preview is on. Compile without the preview and the compiler uses the existing identity version of LocalDate; turn the preview on and it uses the value version. There's no way to get the identity version while the preview is on. In other words, this feature can't be "partially" turned on.
The Weight of the Word "Preview" — Timeline and What's Left
Let's get the dates exactly right. On the JDK 28 project page, the targeting review for JEP 401 closed on July 30, 2026, and as of that date the JEP document's status is Targeted / Release 28. The merge happened early the next morning. The same page also lists JEP 539 (review closed July 30) and JEP 535 (making Shenandoah's generational mode the default, review closing August 3) alongside it.
Meanwhile, JDK 27 has already entered Rampdown Phase Two, with GA set for September 15, 2026. Given Java's six-month cadence, JDK 28 GA lands in March 2027. And since preview features conventionally ship as a preview for at least one release beyond that, the point at which you can use value classes without --enable-preview is later still. How many preview rounds it'll take hasn't been decided yet — that part remains unconfirmed.
And what merged this time isn't all of Valhalla. The JEP names two items explicitly as Future Work.
- JEP 402, Enhanced Primitive Boxing — exploits, at the language level, the fact that boxing has gotten lighter now that it's backed by value objects.
- JEP 218, Generics over Primitive Types (revised) — lets a generic class specialize the layout of fields, arrays, and local variables when parameterized with a value class. This is the piece that lifts the "generic fields don't flatten" constraint from the earlier section.
Beyond that, there's still the tearing opt-out mentioned earlier, null-excluding layouts, and automatic serialization for value classes. So "Valhalla is done" isn't accurate. Twelve years in, the first piece has landed on the mainline.
Conclusion — Changes to the Object Model Don't Arrive Quietly
To sum up.
- A value class trades away identity in exchange for giving the JVM freedom over memory layout.
==becomes a field-value comparison, andsynchronizedstops being possible. - The performance comes from flattening (fields and arrays on the heap) and scalarization (local variables and parameters on the stack). The former hits a real 64-bit wall because of atomicity; the latter doesn't.
- To get the optimization, a variable has to be declared as a concrete value-class type — a spot typed as
Objector a generic type parameter doesn't qualify. And code that uses a migrated value class needs to be recompiled. - What breaks: the
java.lang.reffamily,WeakHashMap, serialization for non-record value classes, mutating final fields via deep reflection, and clients that synchronize on your objects. The identity warnings accumulated since Java 16 are exactly that list. - The status is Targeted / JDK 28 preview, with JDK 28 GA slated for March 2027. The point where it's on by default comes after that.
There's no production impact right now. But if there's code somewhere that puts a LocalDate into a WeakHashMap as a key, it's likely already emitting a warning. Now is the time to go read it.
References
- JEP 401: Value Objects (Preview) — the JEP text
- 8389219: Implement JEP 401 — merge commit cc278db (2026-07-31)
- openjdk/jdk PR #31120 — the implementation pull request
- Hacker News discussion (item 49119063, 2026-07-31)
- JDK 28 project page — targeting review schedule
- JDK 27 project page — GA 2026-09-15
- Project Valhalla — Value Classes and Objects
- Valhalla design notes — State of Valhalla
- Try Out JEP 401 Value Classes and Objects — inside.java
- JDK 26's final-field-mutation warning — JEP 500 (related post)