Split View: JDK 26의 final 필드 변경 경고 — JEP 500이 실제로 바꾼 것, 그리고 지금 확인해 둘 것
JDK 26의 final 필드 변경 경고 — JEP 500이 실제로 바꾼 것, 그리고 지금 확인해 둘 것
- 들어가며 — 어느 날 stderr에 세 줄이 늘었다
- final은 지금까지 final이 아니었다
- JEP 500이 JDK 26에서 실제로 바꾼 것
- 새 플래그, 그리고 --add-opens로는 안 되는 이유
- 실제로 뭐가 걸리나 — 이슈 트래커가 말해 주는 것
- 지금 할 수 있는 것 — deny로 돌려보고, JFR로 범인 찾기
- 라이브러리 쪽 대안, 그리고 ReflectionFactory라는 반쪽짜리 탈출구
- 정직한 트레이드오프 — 그리고 지금 당장은 안 급한 이유
- 마치며
- 참고 자료
들어가며 — 어느 날 stderr에 세 줄이 늘었다
JDK 26으로 올리고 애플리케이션을 띄웠는데, 아무것도 안 바꿨는데 표준 에러에 이런 게 찍힙니다.
WARNING: Final field finalField in class TestFinalFieldModification$ClassWithFinalField has been mutated reflectively by class com.google.gson.internal.bind.ReflectiveTypeAdapterFactory$2 in unnamed module @59fd97a8 (file:/.../gson-2.13.2.jar)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
이건 제가 지어낸 예시가 아니라, Gson 이슈 트래커에 실제로 올라온 재현입니다. 재현 코드는 놀랄 만큼 평범합니다 — final String 필드 하나 있는 클래스에 new Gson().fromJson(...)을 돌린 게 전부입니다. JDK 25에서는 조용히 잘 되던 코드가, JDK 26에서는 경고를 뱉습니다.
범인은 JEP 500: Prepare to Make Final Mean Final입니다. Ron Pressler와 Alex Buckley가 쓴 이 JEP는 2026년 3월 17일 GA한 JDK 26에 Closed/Delivered로 실렸습니다. 이름 그대로 "final이 진짜 final을 의미하게 만들기 위한 준비" 단계이고, 이번 릴리스에서 하는 일은 딱 하나 — 경고를 띄우는 것입니다.
final은 지금까지 final이 아니었다
JEP의 표현을 그대로 옮기면 이렇습니다. "final 필드가 재할당될 수 없다는 기대는 거짓이다(false)." 왜냐하면 JDK 5부터 리플렉션 API가 이걸 허용해 왔기 때문입니다.
// final 필드를 가진 평범한 클래스
class C {
final int x;
C() { x = 100; }
}
// 1. C의 final 필드에 딥 리플렉션
java.lang.reflect.Field f = C.class.getDeclaredField("x");
f.setAccessible(true); // C의 final 필드를 변경 가능하게
// 2. 인스턴스 생성
C obj = new C();
System.out.println(obj.x); // 100 출력
// 3. final 필드를 변경
f.set(obj, 200);
System.out.println(obj.x); // 200 출력
f.set(obj, 300);
System.out.println(obj.x); // 300 출력
이게 왜 들어갔냐면, 역직렬화 때문입니다. JDK 자신의 플랫폼 직렬화는 생성자를 우회해서 스트림의 값을 필드에 직접 꽂는데, 서드파티 직렬화 라이브러리도 동등한 기능을 제공하려면 같은 짓이 필요했습니다. 그래서 JDK 5에서 리플렉션 API를 바꿔 final 필드를 바꿀 수 있게 했습니다. JEP는 이 결정을 "돌이켜 보면 무결성을 희생한 나쁜 선택"이었다고 씁니다.
문제는 이게 두 가지를 동시에 망가뜨린다는 것입니다.
첫째, 정확성 추론이 불가능해집니다. final 필드의 불변성은 JDK 5부터 자바 메모리 모델에서 핵심 역할을 해 왔습니다 — 멀티스레드 코드에서 객체의 안전한 초기화(safe initialization)가 여기에 얹혀 있습니다. Lucene 이슈에 달린 한 코멘트가 이 지점을 정확히 찌릅니다. 리플렉션으로 final 필드를 바꾸는 건 "허용"돼 있었을 뿐, 일관되게 동작한 적이 없다는 것입니다 — 바꾼 값이 모든 스레드에서 보이지는 않더라는 관찰입니다. 즉 이건 JDK 26이 새로 만든 문제가 아니라, 원래부터 깨져 있던 걸 이제 말해 주기 시작한 것에 가깝습니다.
둘째, JIT가 최적화를 못 합니다. 대표적으로 상수 폴딩(constant folding)입니다. final 필드가 절대 안 바뀐다고 믿을 수 있어야 상수 표현식을 매번이 아니라 한 번만 평가하고, 거기서부터 최적화 연쇄가 시작됩니다. 그런데 "어떤 코드든 언제든 final 필드를 바꿀 수 있다"면 JVM은 모든 final 필드를 의심해야 합니다. JEP의 표현으로는, 그런 API가 존재한다는 사실만으로 모든 프로그램의 안전성과 성능이 함께 깎입니다. 실제로 final 필드를 바꾸는 코드는 극소수인데도 말입니다.
이 방향 자체는 갑자기 튀어나온 게 아닙니다. JDK 15의 히든 클래스와 JDK 16의 레코드는 처음부터 딥 리플렉션의 final 필드 변경을 막아 뒀고, JDK 17에서 JDK 내부를 강하게 캡슐화했고, JDK 24에서 sun.misc.Unsafe의 해당 메서드들을 제거하는 절차를 시작했습니다. JEP 500은 같은 "기본적으로 무결성(integrity by default)" 노선의 다음 칸입니다.
JEP 500이 JDK 26에서 실제로 바꾼 것
핵심은 Field::set의 동작 변경입니다. f.set(...)이 호출됐고 그 필드가 final이면, 이제 다음 세 조건이 모두 참이어야 변경됩니다.
f.setAccessible(true)가 이미 성공했고,- 필드를 선언한 클래스의 패키지가 호출자 모듈에 open돼 있고,
- 호출자 모듈에 대해 final 필드 변경이 활성화돼 있어야 합니다.
2번과 3번이 JDK 26에서 새로 생긴 조건입니다. 여기서 자주 오해가 생기는 지점 하나 — setAccessible의 동작은 바뀌지 않았습니다. 즉 f.setAccessible(true)는 여전히 성공하는데 f.set(...)은 불법일 수 있습니다. 둘은 이제 별개 관문입니다.
조건이 깨졌을 때 런타임이 무엇을 할지는 새 옵션 --illegal-final-field-mutation이 정합니다. JDK 9의 --illegal-access(JEP 261), JDK 24의 --illegal-native-access(JEP 472)와 같은 계보입니다 — JEP가 직접 이 둘을 선례로 듭니다.
| 값 | 동작 |
|---|---|
allow | 경고 없이 변경 허용 |
warn | 변경은 허용하되 경고. JDK 26의 기본값 |
debug | warn과 같되, 매 변경마다 경고 + 스택 트레이스 |
deny | Field::set이 IllegalAccessException을 던짐. 장래의 기본값 |
JEP가 명시한 로드맵은 이렇습니다 — 장래 릴리스에서 deny가 기본이 되고, 그때 allow는 제거되며, warn과 debug는 최소 한 릴리스 더 남습니다. warn 모드 자체도 결국 단계적으로 폐지됩니다.
경고 형식은 JEP에 이렇게 적혀 있습니다.
WARNING: Final field f in p.C has been [mutated/unreflected for mutation] by class com.foo.Bar.caller in module N (file:/path/to/foo.jar)
WARNING: Use --enable-final-field-mutation=N to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
여기에 함정이 있습니다. 경고는 리플렉션을 하는 모듈당 최대 한 번만 나옵니다. 애플리케이션 전체가 클래스패스에 있으면(대부분 그렇습니다) 전부 unnamed module 하나로 묶이니까, 위반이 열 군데든 백 군데든 경고는 딱 한 번 뜨고 끝입니다. 즉 이 경고를 보고 "우리는 한 군데만 걸렸네"라고 읽으면 안 됩니다. 세는 도구가 아니라 알람일 뿐입니다.
바뀐 건 Field::set만이 아닙니다. MethodHandles.Lookup::unreflectSetter도 같은 방식으로 바뀌었고, 런타임에 패키지를 여는 Module::addOpens 계열도 영향을 받습니다 — 커맨드라인에서 아무도 활성화되지 않은 패키지라면 JVM은 그 패키지의 final 필드를 신뢰하기로 하고, 이후 addOpens를 호출해도 final 필드 변경 권한은 생기지 않습니다.
반대로 안 바뀐 것도 못 박아 둡니다. System의 setIn/setOut/setErr는 JDK 26에서 아무것도 바뀌지 않았습니다. 이 필드들은 원래부터 write-protected라 딥 리플렉션으로 바꿀 수 없었고, 해당 메서드로만 바꿀 수 있었습니다.
새 플래그, 그리고 --add-opens로는 안 되는 이유
경고와 (장래의) 예외를 피하려면 새 영구 옵션 --enable-final-field-mutation을 씁니다.
# 클래스패스의 모든 코드에 대해 허용
$ java --enable-final-field-mutation=ALL-UNNAMED ...
# 모듈 경로의 특정 모듈에만 허용
$ java --enable-final-field-mutation=M1,M2 ...
런처에 직접 넘기는 것 말고도 경로가 몇 가지 더 있습니다 — JDK_JAVA_OPTIONS 환경 변수, java @config 같은 인자 파일, 실행 가능 JAR의 매니페스트에 Enable-Final-Field-Mutation 추가(단 지원되는 값은 ALL-UNNAMED 뿐이고, 다른 값을 넣으면 예외가 납니다), jlink의 --add-options, 그리고 JNI Invocation API로 JVM을 임베드하는 네이티브 애플리케이션. 다만 이 옵션은 부트 모듈 레이어만 대상으로 하며, 사용자 정의 레이어에는 적용할 수 없습니다.
여기서 실무자가 제일 많이 헛발질할 지점을 짚습니다. --add-opens만으로는 이 경고가 사라지지 않습니다. JEP가 명시적으로 못 박은 문장입니다. 그리고 그 역도 참입니다 — --enable-final-field-mutation을 켠다고 해서 딥 리플렉션이 무조건 되는 게 아니고, 바꾸려는 final 필드가 그 코드에 open돼 있어야 합니다. 앞서 본 조건 2번과 3번은 AND입니다. 그래서 실무에서는 이 둘을 같이 쓰게 되는 경우가 많습니다.
또 하나 — 이 트레이드오프를 결정하는 주체는 애플리케이션 개발자(또는 그 조언을 받은 배포자)이지 라이브러리 개발자가 아닙니다. 이건 JEP가 정책적으로 못 박은 부분입니다. 라이브러리가 자기 사용자에게 "이 플래그 켜세요"라고 요구하는 건 최후의 수단이어야 합니다. 왜냐하면 플래그는 그 라이브러리에만 켜지는 게 아니라 모듈 단위로 켜지고, 클래스패스라면 결국 애플리케이션 전체에 켜지니까요.
실제로 뭐가 걸리나 — 이슈 트래커가 말해 주는 것
추측 대신 GA 전후로 실제로 올라온 이슈를 보겠습니다.
Gson. 앞서 본 이슈 #2991입니다. deny로 돌리면 경고가 아니라 예외로 바뀝니다. 흥미로운 건 메인테이너의 답변입니다 — final 필드를 바꾸는 게 Gson에 "깊이 배어 있어서(deeply ingrained)" 당장 할 수 있는 최선은 예외 메시지를 JEP 500에 맞게 고치고 대안을 문서화하는 것 정도라고 했습니다. 제시된 대안은 필드에서 final 떼기, 레코드 쓰기, 코틀린 data class + 전용 TypeAdapterFactory, 문제되는 클래스마다 커스텀 TypeAdapter나 JsonDeserializer 쓰기입니다. 덧붙여 리플렉션 기반 직렬화는 이 문제와 별개로도 나쁘다고 말합니다 — 클래스의 private 필드로부터 공개 JSON API가 유도되고, 그것도 Gson용으로 설계된 클래스가 아니라 아무 클래스에나 그렇게 된다는 이유입니다. 이 글을 쓰는 시점에 이슈는 열려 있고, 올라온 PR도 예외 메시지 개선 범위입니다.
Spring Security. 이슈 #19127입니다. spring-security-acl 7.0.5에서 BasicLookupStrategy가 AclImpl의 final 필드 aces를 리플렉션으로 바꿉니다. 딱 위 경고 세 줄이 그대로 찍힙니다.
Trino. 이슈 #28207이 가장 교훈적입니다. 테스트를 아예 --illegal-final-field-mutation=deny로 돌려 본 결과인데, 걸린 게 Trino 자기 코드가 아닙니다. picocli가 ClientOptions의 public final 리스트 필드에 값을 못 넣고, Jackson databind의 FieldProperty가 다른 라이브러리(hoverfly)의 final 필드를 못 씁니다. 에러 메시지가 아주 직설적입니다.
class com.fasterxml.jackson.databind.deser.impl.FieldProperty (in unnamed module @359df09a)
cannot set final field io.specto.hoverfly.junit.api.view.HoverflyInfoView.upstreamProxy
(in unnamed module @359df09a), unnamed module @359df09a is not allowed to mutate final fields
교훈은 이겁니다 — 이건 대체로 당신 코드의 문제가 아니라 의존성의 문제입니다. 그리고 당신이 고칠 수 없는 코드에서 납니다.
Lucene. 이슈 #15482는 반대편 사례입니다. Lucene은 forbiddenapis로 자기 코드의 setAccessible을 원래부터 금지해 왔기 때문에, 이제 테스트 러너에 deny를 넘겨서 의존성이 하는 짓까지 잡겠다는 계획입니다. 자기 집이 깨끗하면 이 플래그는 부담이 아니라 무기가 됩니다.
지금 할 수 있는 것 — deny로 돌려보고, JFR로 범인 찾기
JEP의 권고는 명확합니다. "장래를 대비하려면 기존 코드를 deny 모드로 돌려서 딥 리플렉션으로 final 필드를 바꾸는 코드를 찾아내라."
경고가 모듈당 한 번뿐이라 세기 어렵다는 문제는 두 가지 방법으로 우회합니다.
# 1) 매 변경마다 경고 + 스택 트레이스
$ java --illegal-final-field-mutation=debug -jar app.jar
# 2) JFR로 이벤트 수집 (JFR이 켜져 있으면 자동 기록)
$ java -XX:StartFlightRecording:filename=recording.jfr ...
$ jfr print --events jdk.FinalFieldMutation recording.jfr
jdk.FinalFieldMutation 이벤트는 final 인스턴스 필드를 바꾸거나 Lookup.unreflectSetter로 쓰기 가능한 MethodHandle을 얻을 때 기록되고, 필드를 선언한 클래스, 필드 이름, 그리고 스택 트레이스를 담습니다. 스택 트레이스가 있다는 게 중요합니다 — 위 Gson 사례처럼 실제 호출자가 프레임워크 깊숙한 곳일 때 이게 없으면 못 찾습니다.
실용적인 순서는 이렇게 됩니다.
- CI의 테스트 잡을 JDK 26 +
--illegal-final-field-mutation=deny로 한 번 돌려서 목록을 뽑는다(Trino와 Lucene이 정확히 이걸 합니다). - 나온 항목을 "내 코드 / 내가 고칠 수 있는 의존성 / 못 고치는 의존성"으로 나눈다.
- 내 코드는 고친다 — 대개 생성자로 옮기면 끝납니다.
- 못 고치는 것만 남겨서, 프로덕션 런타임에는
--enable-final-field-mutation으로 명시적으로 열어 둔다. 이건 도망이 아니라 기록입니다. 어디에 빚이 있는지가 커맨드라인에 남습니다.
라이브러리 쪽 대안, 그리고 ReflectionFactory라는 반쪽짜리 탈출구
JEP와 Avoiding Final Field Mutation이 제시하는 대안은 결국 하나로 수렴합니다 — 생성자를 쓰라는 것입니다.
문제의 뿌리는 "일단 빈 객체를 만들고 나중에 필드를 채우는(construct-first-assign-later)" 패턴입니다. 플랫폼 역직렬화가 그렇고, 자바빈이 그렇고, 예전 DI가 그랬습니다. 이 패턴은 final과 정면충돌하니까 강제 할당이 필요해집니다. 그런데 이건 final 문제 이전에 그 자체로 나쁩니다 — 클래스의 불변식은 보통 생성자에서 세워지는데, 생성자를 우회하면 불변식을 만족하지 않는 인스턴스가 만들어질 수 있습니다. Inside Java 글의 ReflectionFactory 예제가 이걸 대놓고 보여 줍니다. 생성자를 우회하니까 검증도 우회되고, age에 -5가 그냥 들어갑니다.
DI 진영은 이미 이 길을 걸었습니다. 필드 주입에서 생성자 주입으로 옮겨 왔고, 대부분의 프레임워크가 이제 final 필드 주입을 금지하거나 권장하지 않습니다. 직렬화 라이브러리라면 레코드로 한정하기, 레코드를 프록시로 쓰기, 또는 사용자가 생성자/정적 팩터리를 지정하게 하기 같은 선택지가 있습니다. 클론이라면 super.clone() 대신 복사 생성자나 정적 팩터리로 갑니다(Effective Java의 오래된 조언이 그대로 유효합니다).
그럼 기존 직렬화 라이브러리는? JEP의 공식 답은 sun.reflect.ReflectionFactory입니다. jdk.unsupported 모듈에 있는 critical internal API이고, 여기서 얻은 메서드 핸들은 JDK 자신의 직렬화와 같은 권한을 갖습니다. 커맨드라인 플래그가 필요 없고, deny에서도 경고나 에러가 나지 않습니다.
그런데 여기에 결정적인 제약이 있습니다. ReflectionFactory는 java.io.Serializable을 구현한 클래스의 역직렬화만 지원합니다. 이건 버그가 아니라 의도된 선을 긋는 것입니다. JEP의 설명이 명쾌합니다 — JVM은 Serializable 객체의 final 필드는 변경될 수 있다고 가정해야 하지만, 그 외 모든 객체(압도적 다수)의 final 필드는 영구히 불변이라고 가정할 수 있게 하려는 것입니다. 상수 폴딩 같은 최적화의 여지를 지키기 위한 거래입니다.
그래서 이 탈출구는 Gson에게 별 도움이 안 됩니다. Gson은 Serializable 구현을 요구하지 않으니까요. 이슈에서 지적된 그대로입니다. 메인테이너의 반응도 시니컬하게 정확합니다 — 값 클래스에 Serializable을 붙이도록 고칠 거면, 차라리 레코드로 만드는 게 낫지 않냐는 것입니다. Inside Java 글도 같은 경고를 답니다. ReflectionFactory는 "겁 많은 사람을 위한 물건이 아닌" 날카로운 도구이고, 플랫폼 직렬화의 온갖 난점(readObject/readResolve 프로토콜 재구현, 보안, Serializable 확산)을 그대로 떠안습니다.
정직한 트레이드오프 — 그리고 지금 당장은 안 급한 이유
여기서 톤을 낮춰야 할 부분들입니다.
성능 이득에 숫자가 없습니다. JEP는 "더 안전하고 잠재적으로 더 빠르다(potentially faster)"고 씁니다. Oracle의 JDK 26 주요 변경 문서도 같은 표현을 씁니다. 메커니즘(상수 폴딩과 그 뒤의 최적화 연쇄)은 설명하지만, "몇 % 빨라진다"는 벤치마크는 어디에도 없습니다. 이 글에서도 지어내지 않겠습니다. 지금 시점에 정직한 요약은 "미래의 최적화를 위한 여지를 확보하는 변경이고, 그 이득은 아직 측정치로 제시되지 않았다"입니다.
왜 투기적 최적화로 안 되냐는 반론에 JEP가 직접 답한 부분은 오히려 흥미롭습니다. JIT가 늘 하듯 "final은 안 바뀐다고 낙관적으로 가정하고, 바뀌면 역최적화(deoptimize)"하면 되지 않냐는 것인데, JEP의 답은 그걸로는 부족할 수 있다는 것입니다. 앞으로 계획된 최적화들은 프로세스 수명 안에서의 불변성만이 아니라 실행에서 다음 실행으로 넘어가는 불변성에 기댈 수 있기 때문입니다. AOT 캐시와 Leyden 계열 작업을 떠올리면 이 문장의 무게가 읽힙니다. 런타임에 역최적화할 수 있는 것과, 이전 실행에서 구워 둔 걸 믿는 것은 다른 문제입니다.
그리고 타임라인. 이게 실무자에게 제일 중요합니다. JEP 500은 deny가 기본이 될 릴리스를 명시하지 않습니다. 그냥 "장래 릴리스"입니다. 그리고 2026년 9월 15일 GA 예정인 JDK 27은 이미 Rampdown Phase One을 지나 기능 세트가 동결됐고("No further JEPs will be targeted to this release"), 확정된 JEP 목록에 final 필드 강제와 관련된 건 없습니다. 즉 JDK 27에서 이게 에러가 되지는 않습니다. 빨라도 그다음입니다.
그래서 지금 이 경고에 대한 합리적인 반응은 이렇습니다.
신경 써야 하는 경우
- JDK 26 이상으로 올렸고, 스택에 리플렉션 기반 직렬화(Gson 등)나 오래된 필드 주입이 있다.
- 라이브러리·프레임워크 메인테이너다. 여기는 진짜 숙제입니다 — 사용자에게 플래그를 요구하는 건 최후의 수단이라는 게 JEP의 입장이고, 아키텍처를 바꾸는 데는 시간이 걸립니다.
- 테스트가 이미
deny로 돌 만큼 집이 깨끗하다면, 지금 켜서 회귀를 막는 게 이득입니다.
아직 신경 안 써도 되는 경우
- 아직 JDK 21이나 25 LTS에 있다. 이 변경은 JDK 26부터이고, JDK 26은 6개월 주기의 비-LTS 릴리스입니다. 대다수 팀은 다음 LTS를 통해 이걸 처음 만나게 됩니다.
- 경고가 뜨는 게 전부이고, 그 원인이 남이 만든 라이브러리이며, 당신은 지금 다른 불을 끄는 중이다.
--enable-final-field-mutation=ALL-UNNAMED로 조용히 시키고 백로그에 적어 두는 건 완전히 합당한 선택입니다. 이건 영구 옵션이지 임시 유예가 아닙니다.
다만 반사적으로 --illegal-final-field-mutation=allow를 박아 넣는 건 피하는 게 좋습니다. JEP가 명시적으로 말합니다 — deny가 기본이 되는 날 allow는 제거됩니다(warn과 debug는 최소 한 릴리스 더 남습니다). 즉 유효기간이 붙은 값이고, 그날 이 플래그를 쥔 팀은 어떻게든 다시 손대야 합니다. 굳이 지금 조용히 시켜야 한다면 --enable-final-field-mutation 쪽이 맞습니다. 이쪽은 없어질 예정이 없는 영구 옵션이고, 무엇보다 "우리는 여기서 final을 바꾸고 있다"를 커맨드라인에 남긴다는 점에서 정직합니다.
마지막으로 사각지대 하나. 이 모든 진단은 자바 코드 얘기입니다. 네이티브 코드가 JNI의 Set<type>Field 계열로 final 필드를 바꾸는 건 정의되지 않은 동작(undefined behavior)이고, JEP는 그 결과로 배열 경계 검사가 무너지거나 프로세스가 죽을 수도 있다고까지 씁니다. 진단은 -Xlog:jni=debug나 -Xcheck:jni로만 얻을 수 있습니다. 그리고 sun.misc.Unsafe로 바꾸는 건 진단조차 없습니다. 조용히 이상한 버그나 JVM 크래시로 나타날 수 있습니다.
마치며
정리하면 이렇습니다. JDK 26은 딥 리플렉션의 final 필드 변경에 경고를 띄우기 시작했습니다. 동작은 아직 그대로이고, 기본값은 warn이며, 경고는 모듈당 한 번뿐이라 실제 규모를 알려면 deny나 JFR이 필요합니다. 장래에 deny가 기본이 되지만 그게 언제인지는 JEP도 말하지 않았고, 최소한 JDK 27은 아닙니다.
이 변경의 진짜 메시지는 플래그가 아니라 그 아래에 있습니다. 자바는 20년 넘게 "final은 불변이다"라고 말하면서 그걸 깨는 도구를 같이 쥐여 줬고, 그 위에 생태계의 상당 부분이 올라앉았습니다. JEP 500은 그 청구서를 이제 나눠서 발행하기 시작한 것입니다. 경고 한 줄은 작지만, 그 뒤에 있는 질문은 작지 않습니다 — 당신의 객체는 생성자로 완성됩니까, 아니면 누군가 나중에 필드를 채워 넣습니까.
참고 자료
- JEP 500: Prepare to Make Final Mean Final (Ron Pressler, Alex Buckley — JDK 26, Closed/Delivered)
- JDK 26 — 기능 목록과 GA 일정(2026/03/17)
- JDK 27 — Rampdown Phase One, 확정 JEP 목록(2026/09/15 GA 예정)
- Quality Outreach Heads-up — JDK 26: Warnings About Final Field Mutation (Nicolai Parlog, 2026/05/15)
- Avoiding Final Field Mutation (Nicolai Parlog, 2026/04/27) — 시나리오별 대안과 ReflectionFactory 예제
- Significant Changes in JDK 26 Release — Oracle 마이그레이션 문서
- google/gson #2991 — Prepare for final field mutation warning under Java 26
- spring-projects/spring-security #19127 — Final field mutation reported on Java 26
- trinodb/trino #28207 — illegal-final-field-mutation violations (deny로 테스트 실행)
- apache/lucene #15482 — When running tests on Java 26 disallow modification of final fields
The Final Field Mutation Warning in JDK 26 — What JEP 500 Actually Changed, and What to Check Now
- Introduction — One Day, Three New Lines Showed Up in stderr
- final Has Never Really Been final
- What JEP 500 Actually Changed in JDK 26
- New Flags, and Why --add-opens Doesn't Cut It
- What Actually Breaks — What the Issue Trackers Say
- What You Can Do Now — Flip to deny, Find the Culprit with JFR
- The Library-Side Alternative, and ReflectionFactory as a Half-Measure Escape Hatch
- An Honest Tradeoff — And Why This Isn't Urgent Right Now
- Closing
- References
Introduction — One Day, Three New Lines Showed Up in stderr
You upgrade to JDK 26, start the application, and even though you changed nothing, this shows up on standard error.
WARNING: Final field finalField in class TestFinalFieldModification$ClassWithFinalField has been mutated reflectively by class com.google.gson.internal.bind.ReflectiveTypeAdapterFactory$2 in unnamed module @59fd97a8 (file:/.../gson-2.13.2.jar)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
This isn't an example I made up — it's an actual reproduction filed on the Gson issue tracker. The repro code is surprisingly mundane — a class with one final String field, and running new Gson().fromJson(...) on it is the whole story. Code that quietly worked on JDK 25 now spits out a warning on JDK 26.
The culprit is JEP 500: Prepare to Make Final Mean Final. Written by Ron Pressler and Alex Buckley, this JEP shipped as Closed/Delivered in JDK 26, which GA'd on March 17, 2026. True to its name, it's the preparation stage for making final actually mean final, and the one thing this release does is emit a warning.
final Has Never Really Been final
To carry over the JEP's own wording: "the expectation that final fields cannot be reassigned is false." That's because the reflection API has allowed exactly this since JDK 5.
// An ordinary class with a final field
class C {
final int x;
C() { x = 100; }
}
// 1. Deep reflection on C's final field
java.lang.reflect.Field f = C.class.getDeclaredField("x");
f.setAccessible(true); // Make C's final field mutable
// 2. Create an instance
C obj = new C();
System.out.println(obj.x); // prints 100
// 3. Mutate the final field
f.set(obj, 200);
System.out.println(obj.x); // prints 200
f.set(obj, 300);
System.out.println(obj.x); // prints 300
The reason this was ever allowed is deserialization. The JDK's own platform serialization bypasses the constructor and writes stream values straight into fields, and third-party serialization libraries needed to do the same thing to offer equivalent functionality. So JDK 5 changed the reflection API to allow mutating final fields. The JEP calls this decision, in hindsight, "a bad choice that traded away integrity."
The trouble is that this breaks two things at once.
First, it makes correctness reasoning impossible. The immutability of final fields has played a core role in the Java Memory Model since JDK 5 — safe initialization of objects in multithreaded code rests on it. A comment on the Lucene issue nails this exactly: mutating final fields via reflection was only ever "permitted," never consistently correct — the observation is that a mutated value isn't necessarily visible on every thread. In other words, this isn't a problem JDK 26 created; it's closer to JDK 26 finally saying out loud something that was already broken.
Second, the JIT can't optimize. Constant folding is the textbook case. Only if the JIT can trust that a final field never changes can it evaluate a constant expression once instead of every time, and that's where a whole chain of further optimizations starts. But if "any code, at any time, can mutate a final field," the JVM has to treat every final field as suspect. In the JEP's words, the mere existence of such an API degrades the safety and performance of every program — even though the code that actually mutates final fields is a tiny minority.
This direction isn't something that came out of nowhere. Hidden classes in JDK 15 and records in JDK 16 blocked final-field mutation via deep reflection from the start, JDK 17 strongly encapsulated JDK internals, and JDK 24 began the process of removing the relevant methods from sun.misc.Unsafe. JEP 500 is the next step on that same integrity-by-default line.
What JEP 500 Actually Changed in JDK 26
The core of it is a behavior change to Field::set. When f.set(...) is called and the field is final, the mutation now goes through only if all three of the following are true.
f.setAccessible(true)has already succeeded,- the package of the class that declared the field is open to the caller's module, and
- final field mutation is enabled for the caller's module.
Conditions 2 and 3 are new in JDK 26. Here's a spot where people commonly get confused: the behavior of setAccessible itself hasn't changed. So f.setAccessible(true) can still succeed while f.set(...) is illegal. The two are now separate gates.
What the runtime does when a condition fails is governed by the new --illegal-final-field-mutation option. It's in the same lineage as --illegal-access (JEP 261) from JDK 9 and --illegal-native-access (JEP 472) from JDK 24 — the JEP cites both directly as precedent.
| Value | Behavior |
|---|---|
allow | Allows the mutation, no warning |
warn | Allows the mutation but warns. JDK 26's default |
debug | Same as warn, but with a warning plus a stack trace on every mutation |
deny | Field::set throws IllegalAccessException. The future default |
The roadmap the JEP lays out is this — in a future release deny becomes the default, at which point allow is removed, while warn and debug stick around for at least one more release. warn mode itself is eventually phased out too.
The warning format, as written in the JEP, looks like this.
WARNING: Final field f in p.C has been [mutated/unreflected for mutation] by class com.foo.Bar.caller in module N (file:/path/to/foo.jar)
WARNING: Use --enable-final-field-mutation=N to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
Here's the trap. The warning fires at most once per module that does the reflecting. If your whole application sits on the classpath (which is the common case), it all gets bundled into a single unnamed module, so whether there are ten violations or a hundred, you get exactly one warning and that's it. So you should not read this warning as "only one spot is affected." It's an alarm, not a counter.
Field::set isn't the only thing that changed. MethodHandles.Lookup::unreflectSetter changed the same way, and the Module::addOpens family that opens packages at runtime is affected too — for a package that nobody enabled on the command line, the JVM commits to trusting that package's final fields, and calling addOpens afterward does not grant permission to mutate them.
Worth nailing down what didn't change, too. System's setIn/setOut/setErr are completely unchanged in JDK 26. Those fields were already write-protected and could never be changed via deep reflection — only through those dedicated methods.
New Flags, and Why --add-opens Doesn't Cut It
To avoid the warning — and the (eventual) exception — you use the new, permanent --enable-final-field-mutation option.
# Allow it for all code on the classpath
$ java --enable-final-field-mutation=ALL-UNNAMED ...
# Allow it only for specific modules on the module path
$ java --enable-final-field-mutation=M1,M2 ...
There are a few paths besides passing it straight to the launcher — the JDK_JAVA_OPTIONS environment variable, an argument file like java @config, adding Enable-Final-Field-Mutation to an executable JAR's manifest (though the only supported value there is ALL-UNNAMED; any other value throws an exception), jlink's --add-options, and native applications that embed the JVM through the JNI Invocation API. That said, this option only targets the boot module layer and cannot be applied to a custom layer.
This is where practitioners trip up most often: --add-opens alone does not make this warning go away. The JEP states this explicitly. And the reverse is also true — turning on --enable-final-field-mutation doesn't unconditionally enable deep reflection either; the final field you're trying to mutate still has to be open to that code. Conditions 2 and 3 from earlier are an AND, not an OR. In practice, that means you often end up using both together.
One more thing — the party who gets to make this tradeoff is the application developer (or a deployer acting on their advice), not the library developer. This is a policy point the JEP nails down explicitly. A library telling its users "turn on this flag" should be a last resort, because the flag doesn't get turned on just for that library — it's turned on per module, and on the classpath that ends up meaning the whole application.
What Actually Breaks — What the Issue Trackers Say
Instead of guessing, let's look at issues actually filed around GA.
Gson. This is issue #2991 from earlier. Run under deny and it turns into an exception instead of a warning. What's interesting is the maintainer's response — mutating final fields is "deeply ingrained" in Gson, so the best that can be done right now is to fix the exception message to match JEP 500 and document the alternatives. The alternatives offered are: drop final from the field, use a record, use a Kotlin data class plus a dedicated TypeAdapterFactory, or write a custom TypeAdapter or JsonDeserializer per problem class. On top of that, the maintainer notes that reflection-based serialization is bad independent of this problem too — a public JSON API ends up derived from a class's private fields, and not even for classes designed for Gson, but for any class. As of this writing the issue is still open, and the PR that's been filed is scoped only to improving the exception message.
Spring Security. Issue #19127. In spring-security-acl 7.0.5, BasicLookupStrategy mutates AclImpl's final field aces via reflection. It prints exactly the same three-line warning shown above.
Trino. Issue #28207 is the most instructive one. It comes from running the test suite outright under --illegal-final-field-mutation=deny, and what breaks isn't Trino's own code. picocli can't write to ClientOptions's public final list field, and Jackson databind's FieldProperty can't write to a final field belonging to yet another library (hoverfly). The error message is blunt.
class com.fasterxml.jackson.databind.deser.impl.FieldProperty (in unnamed module @359df09a)
cannot set final field io.specto.hoverfly.junit.api.view.HoverflyInfoView.upstreamProxy
(in unnamed module @359df09a), unnamed module @359df09a is not allowed to mutate final fields
The lesson here: this is, for the most part, not a problem in your code but in your dependencies' — and it happens in code you can't fix.
Lucene. Issue #15482 is the flip side of the story. Because Lucene had already banned setAccessible in its own code via forbiddenapis, the plan is to now pass deny to the test runner and catch what its dependencies get up to as well. If your own house is clean, this flag turns from a burden into a weapon.
What You Can Do Now — Flip to deny, Find the Culprit with JFR
The JEP's recommendation is unambiguous: "to prepare for the future, run existing code in deny mode to find code that mutates final fields via deep reflection."
The problem that the warning only fires once per module, making it hard to count, has two workarounds.
# 1) A warning plus a stack trace on every mutation
$ java --illegal-final-field-mutation=debug -jar app.jar
# 2) Collect events with JFR (recorded automatically if JFR is on)
$ java -XX:StartFlightRecording:filename=recording.jfr ...
$ jfr print --events jdk.FinalFieldMutation recording.jfr
The jdk.FinalFieldMutation event is recorded when a final instance field is mutated or when Lookup.unreflectSetter produces a writable MethodHandle, and it carries the declaring class, the field name, and a stack trace. Having the stack trace matters — like the Gson case above, when the real caller is buried deep inside a framework, you can't find it without one.
In practice, the sequence looks like this.
- Run your CI test job once under JDK 26 with
--illegal-final-field-mutation=denyto pull a list (this is exactly what Trino and Lucene do). - Split what comes out into "my code / a dependency I can fix / a dependency I can't fix."
- Fix your own code — usually moving the assignment into the constructor is the whole fix.
- For what's left that you can't fix, explicitly open it up in the production runtime with
--enable-final-field-mutation. This isn't running away — it's a record. Where the debt lives stays visible on the command line.
The Library-Side Alternative, and ReflectionFactory as a Half-Measure Escape Hatch
The alternatives that the JEP and Avoiding Final Field Mutation offer converge on one thing in the end — use the constructor.
The root of the problem is the construct-first-assign-later pattern: build an empty object first, fill in the fields later. Platform deserialization does this, JavaBeans do this, and old-style DI did this. The pattern collides head-on with final, which is why forced assignment becomes necessary. But setting the final problem aside, this pattern is bad on its own terms — a class's invariants are normally established in the constructor, and bypassing the constructor can produce an instance that doesn't satisfy them. The ReflectionFactory example in the Inside Java article shows this bluntly: bypassing the constructor bypasses validation too, and -5 goes straight into age with nothing to stop it.
The DI world has already walked this path — it moved from field injection to constructor injection, and most frameworks now either forbid or discourage injecting into final fields. For a serialization library, the options include restricting yourself to records, using a record as a proxy, or letting users designate a constructor or static factory. For cloning, use a copy constructor or static factory instead of super.clone() — the old Effective Java advice still holds exactly as it did.
So what about existing serialization libraries? The JEP's official answer is sun.reflect.ReflectionFactory. It's a critical internal API living in the jdk.unsupported module, and method handles obtained from it carry the same privileges as the JDK's own serialization. No command-line flag is needed, and it produces no warning or error even under deny.
But there's a decisive restriction here. ReflectionFactory only supports deserializing classes that implement java.io.Serializable. This isn't a bug — it's a deliberate line. The JEP's explanation is crisp: the JVM has to assume that a Serializable object's final fields might change, but for every other object (the overwhelming majority), it wants to be able to assume the final fields are permanently immutable. It's a trade to preserve room for optimizations like constant folding.
So this escape hatch doesn't help Gson much, because Gson doesn't require implementing Serializable — exactly the point raised in the issue. The maintainer's reaction is cynically accurate too: if you're going to fix this by slapping Serializable on your value classes, wouldn't it be better to just make them records? The Inside Java article carries the same warning — ReflectionFactory is a sharp tool "not for the faint of heart," and it inherits every headache of platform serialization wholesale: reimplementing the readObject/readResolve protocol, security, and the spread of Serializable.
An Honest Tradeoff — And Why This Isn't Urgent Right Now
Here's where the tone needs to come down a notch.
There's no number behind the performance gain. The JEP writes that this is safer and "potentially faster." Oracle's Significant Changes in JDK 26 document uses the same wording. It explains the mechanism (constant folding and the optimization chain that follows), but nowhere is there a benchmark saying "X% faster." I won't invent one here either. As of now, the honest summary is: this is a change that clears room for future optimizations, and the payoff hasn't been presented as a measurement yet.
What's actually interesting is how the JEP directly answers the objection "why not just do speculative optimization instead?" — the JIT could, as it usually does, optimistically assume final never changes and deoptimize if it does. The JEP's answer is that this might not be enough, because some of the optimizations planned down the road may lean on immutability that carries from one execution to the next, not just immutability within a single process's lifetime. Think of the AOT cache and the Leyden line of work, and the weight of that sentence becomes clear. Being able to deoptimize at runtime and trusting something baked in from a previous run are two different problems.
And the timeline — this is what matters most to practitioners. JEP 500 does not specify which release makes deny the default. It just says "a future release." And JDK 27, due to GA on September 15, 2026, has already passed Rampdown Phase One and had its feature set frozen ("No further JEPs will be targeted to this release"), and nothing related to enforcing final fields is on its confirmed JEP list. In other words, this does not become an error in JDK 27. At the earliest, it's the release after that.
So a reasonable response to this warning, right now, looks like this.
When you should care
- You've upgraded to JDK 26 or later, and your stack has reflection-based serialization (Gson, etc.) or old-style field injection.
- You're a library or framework maintainer. This is real homework — the JEP's position is that requiring a flag from your users is a last resort, and reworking architecture takes time.
- Your tests are already clean enough to run under
deny— in that case, turning it on now to guard against regressions is a clear win.
When you don't need to care yet
- You're still on JDK 21 or the 25 LTS. This change starts at JDK 26, a non-LTS release on the six-month cadence. Most teams will first meet this through the next LTS.
- All that's happening is a warning, the cause is a library someone else wrote, and you're busy putting out a different fire right now. Silencing it with
--enable-final-field-mutation=ALL-UNNAMEDand writing it into the backlog is a perfectly reasonable choice. This is a permanent option, not a temporary grace period.
That said, it's worth avoiding the reflex of just slapping in --illegal-final-field-mutation=allow. The JEP says explicitly that on the day deny becomes the default, allow is removed (warn and debug stick around for at least one more release). In other words, it's a value with an expiration date, and whichever team is still holding this flag that day will have to go touch it again one way or another. If you have to silence this quietly right now, --enable-final-field-mutation is the right call — it's a permanent option with no planned removal, and more importantly, it's honest about it: it leaves "we are mutating final fields here" written on the command line.
One last blind spot. Everything diagnosed here is about Java code. Native code mutating a final field through JNI's Set<type>Field family is undefined behavior, and the JEP goes so far as to say the result can be array-bounds-check corruption or a process crash. You can only get diagnostics for that through -Xlog:jni=debug or -Xcheck:jni. And mutating via sun.misc.Unsafe gets no diagnostics at all — it can just quietly show up as a strange bug or a JVM crash.
Closing
To sum up: JDK 26 started warning about final-field mutation via deep reflection. Behavior is still unchanged, the default is warn, and because the warning fires only once per module, you need deny or JFR to see the real scope. deny becomes the default at some future point, but the JEP doesn't say when, and it's at least not JDK 27.
The real message of this change sits underneath the flag, not in it. For more than twenty years, Java has said "final is immutable" while handing out the tool to break that promise, and a substantial part of the ecosystem built itself on top of that tool. JEP 500 is where that bill starts getting issued, in installments. One line of warning is small, but the question behind it isn't: is your object complete once the constructor returns, or does someone fill in the fields later?
References
- JEP 500: Prepare to Make Final Mean Final (Ron Pressler, Alex Buckley — JDK 26, Closed/Delivered)
- JDK 26 — feature list and GA schedule (2026/03/17)
- JDK 27 — Rampdown Phase One, confirmed JEP list (GA due 2026/09/15)
- Quality Outreach Heads-up — JDK 26: Warnings About Final Field Mutation (Nicolai Parlog, 2026/05/15)
- Avoiding Final Field Mutation (Nicolai Parlog, 2026/04/27) — alternatives by scenario, plus the ReflectionFactory example
- Significant Changes in JDK 26 Release — Oracle migration guide
- google/gson #2991 — Prepare for final field mutation warning under Java 26
- spring-projects/spring-security #19127 — Final field mutation reported on Java 26
- trinodb/trino #28207 — illegal-final-field-mutation violations (tests run under deny)
- apache/lucene #15482 — When running tests on Java 26 disallow modification of final fields