fuqiuluo’s blog

记录美好生活

技术分享

穿越 JEP 416:用 Unsafe 在 JDK 18+ 强改 final 字段的一次实践

#Java#unsafe#反射
type
Post
status
Published
date
May 3, 2025
slug
summary
一次「我只想改个 final 字段,为什么这么难」的旅程。
tags
Java
unsafe
反射
category
技术分享
icon
password
一次「我只想改个 final 字段,为什么这么难」的旅程。
一次「我只想改个 final 字段,为什么这么难」的旅程。

一、起因:反射 Hack 的黄金时代结束了

如果你写过序列化库、Mod 框架、或者任何需要「偷偷改 static final 字段」的代码,一定用过下面这段经典咒语:
这段代码在 Java 8 ~ Java 11 之间几乎是万能钥匙。但从 Java 12 起:
  • Java 12Field.class.getDeclaredField("modifiers") 开始抛 NoSuchFieldException——JDK 给核心反射类加了字段过滤器(Reflection.fieldFilterMap)。
  • Java 16:强封装 jdk.internal.*,默认不再允许非法反射访问。
  • Java 18(JEP 416:核心反射的底层实现从「运行期生成字节码的 NativeMethodAccessorImpl」切换为 MethodHandle,很多老 Hack 直接失效。
  • Java 19+ReflectionFactory 的内部结构再次调整,老开关能不能拨动都是个问题。
这篇文章就是记录我在 JDK 18 / 19+ 上,sun.misc.Unsafe 强行把时钟拨回老反射路径的过程。

二、前置知识:三把钥匙

1. sun.misc.Unsafe

JDK 内部的「上帝 API」,直接操作堆外内存、对象字段偏移量、绕过访问控制。它不能 new,但它有一个现成的静态单例 theUnsafe,反射拿出来就行:

2. 反射的两套底层实现

实现
触发条件
特点
NativeMethodAccessorImpl
老路径,达到 inflationThreshold 后升级为字节码 accessor
老 Hack(改 modifiers)能用
MethodHandleAccessor
JDK 18 默认
基于 MethodHandle,绕过老式字段过滤器

3. ReflectionFactory 中的两个关键开关

  • JDK 18:静态字段 useDirectMethodHandle(int),值为 1 表示启用 MH accessor。
  • JDK 19+:取消了简单开关,改为一个 config 对象 + 系统属性 jdk.reflect.useDirectMethodHandle

三、核心代码

完整代码长这样:
下面一块块拆。

3.1 拿到 Unsafe 单例

这一步是地基,没什么特别的,唯一需要注意的是:在 JDK 9+ 如果没有 --add-opens java.base/jdk.internal.misc=ALL-UNNAMED,某些派生操作会直接抛 InaccessibleObjectException。但 theUnsafe 字段本身仍然能读到,因为它是 public class 的 private static 字段,走的是反射 + setAccessible,而 sun.misc.Unsafe 并不在受限模块里。

3.2 JDK 18:翻转 useDirectMethodHandle

这里的思路是:我不走反射去 set 这个字段,而是直接用 Unsafe 改它在类静态区的内存值。
为什么不走反射?因为此时反射自己的实现正被这个字段控制,存在「我要改的开关决定了我改这个开关用的是哪条路径」的循环依赖。Unsafe 则绕开整个反射栈,直接写内存。
细节坑:METHOD_MH_ACCESSOR = 0x1 这个值的含义在不同小版本里语义可能不一样。我在 18.0.2 上验证有效,18.0.1 及以下没测。生产环境一定要加防御。

3.3 JDK 19+:试图清空 config(效果有限)

JDK 19 把 useDirectMethodHandle 重构进了一个不可变的 Config record。代码里我尝试:
  1. 用 Unsafe 把 config 字段置空,期望触发懒初始化重新读属性;
  1. System.setProperty("jdk.reflect.useDirectMethodHandle", "false")
但实际测下来,ConfigReflectionFactory<clinit> 阶段就已经被读进去并缓存到多个 call site,置空后续调用并不会重新读。所以我在注释里老老实实写了:
结论:JDK 19+ 上,这条 Hack 路径基本已经被堵死了。如果你真的要在 19+ 上改 final 字段,后面「替代方案」那一节才是正道。

3.4 unloadFinalModifier:去掉 final

注意这里没有用 Field.class.getDeclaredField("modifiers")——那一行在 JDK 12+ 会直接失败。必须自己实现一个 getAllFieldsUnsafely,绕开 Reflection.fieldFilterMap
getDeclaredFields0(false) 返回的字段数组 没有经过 fieldFilterMap 过滤modifiers 字段因此可以被看到。
拿到 modifiers 字段对象后,就是教科书级别的位运算:
final 位被抹掉,之后对目标 field 执行 field.set(obj, newValue) 就能成功写入。

四、整体调用时序


 
Loading...