type
Post
status
Published
date
May 3, 2025
slug
summary
一次「我只想改个 final 字段,为什么这么难」的旅程。
tags
Java
unsafe
反射
category
技术分享
icon
password
一次「我只想改个 final 字段,为什么这么难」的旅程。
一、起因:反射 Hack 的黄金时代结束了
如果你写过序列化库、Mod 框架、或者任何需要「偷偷改
static final 字段」的代码,一定用过下面这段经典咒语:这段代码在 Java 8 ~ Java 11 之间几乎是万能钥匙。但从 Java 12 起:
- Java 12:
Field.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。代码里我尝试:- 用 Unsafe 把
config字段置空,期望触发懒初始化重新读属性;
- 再
System.setProperty("jdk.reflect.useDirectMethodHandle", "false")。
但实际测下来,
Config 在 ReflectionFactory 的 <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) 就能成功写入。