Java 安全基础
Java 是一种 面向对象的编程语言,由 Sun Microsystems(现在是 Oracle)于 1995 年发布。它是 跨平台 的,可以运行在不同操作系统上,而不需要修改源代码。Java 程序可以通过 JVM(Java Virtual Machine) 在不同平台上运行。
- 跨平台 :Java 程序经过编译后,会生成与平台无关的字节码(.class 文件),通过 JVM 解释或编译执行。
- 面向对象 :Java 是一门面向对象的语言,强调封装、继承、多态、抽象等特性。Java 程序是通过 类(Class) 和 对象(Object) 组织的。
Java 中常见的成员标识写法约定如下:
.:表示 包名分隔 或 源码/文档里的内部类分隔。例:java.util.Map,Map.Entry$:表示 字节码/反射里的内部类分隔,这是编译产物,源码里一般不用写。例:Map$Entry#:Javadoc 风格的 成员访问符,用来指代类的 方法、构造方法、字段。例:List#add,Math#abs(double),System#out,ArrayList#ArrayList(int)
本质上就这三种符号组合:
.→ 包/内部类(源码视角)$→ 内部类(JVM视角)#→ 成员(方法/构造/字段,Javadoc 视角)
JDK(Java Development Kit)
JDK(Java Development Kit) 是 Java 开发工具包(Java Development Kit),是开发 Java 程序所需的工具集合。JDK 包含了 JRE 和一些用于开发 Java 应用的工具。
JDK 的组成
JRE(Java Runtime Environment)
JRE(Java Runtime Environment) 是 Java 运行时环境,是为 Java 程序的执行提供支持的软件环境。JRE 主要包含了 Java 虚拟机(JVM)和 Java 核心类库,确保 Java 应用程序可以在不同操作系统上无缝运行。
JRE 是 运行 Java 程序 的必需组件,它为开发者提供了一个可以运行 Java 字节码的环境。但它本身不包含开发工具,比如 Java 编译器(javac),因此 JRE 仅适用于运行 Java 程序,而不适用于开发 Java 程序。
Java 虚拟机(JVM)
JVM(Java Virtual Machine) 是 JRE 的核心部分,它负责加载和执行 Java 程序中的字节码。JVM 是 Java 实现 跨平台 的关键,能够在不同的操作系统上执行相同的 Java 程序。JVM 会将 Java 字节码转换成特定平台的机器代码并执行。
JVM 的主要任务包括:
- 加载字节码:JVM 加载 Java 字节码文件(
.class文件)到内存中。 - 字节码验证:JVM 验证字节码,确保它符合 JVM 的执行规范。
- 执行字节码:通过解释执行或即时编译(JIT)将字节码转换为机器码并执行。
- 内存管理:JVM 负责分配和回收内存,特别是堆内存和栈内存。
- 垃圾回收:JVM 通过垃圾回收机制自动回收不再使用的对象,以便释放内存。
Java 核心类库(Java Core Libraries)
Java 核心类库是 JRE 的另一个重要组成部分,它包含了 Java 程序运行所需的标准类和 API。核心类库包括以下几个常用的包:
java.lang:包括基础类和常用类,如String、Object、Math、System等。java.util:包含集合框架、日期、时间、随机数生成等常用工具类,如ArrayList、HashMap、Date、Calendar等。java.io:提供输入输出流类,用于文件操作和数据流处理。java.net:提供网络编程功能,支持 socket 编程、URL 处理等。java.sql:用于数据库访问的标准 API,支持 JDBC。
这些类库提供了 Java 应用程序开发和运行所需的基础功能。
本地接口(JNI)
Java 程序与操作系统和硬件交互时,可能需要调用底层操作系统的功能。JRE 通过 Java 本地接口(JNI,Java Native Interface) 提供与本地代码(如 C 或 C++)的互操作能力。JNI 使得 Java 可以调用平台特定的库,执行操作系统的低级功能。
Java 类加载器(ClassLoader)
JRE 中的 类加载器(ClassLoader) 负责将 Java 类(.class 文件)加载到 JVM 中。类加载器的任务包括:
- 加载类文件 :JVM 通过类加载器将字节码从硬盘、网络或其他地方加载到内存。
- 检查类的可用性 :类加载器负责确定类是否已经被加载,避免重复加载。
JRE 提供了几种不同类型的类加载器,分别负责加载不同类型的类,例如启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和系统类加载器(System ClassLoader)。
Java 开发工具(Java Development Tools)
Java 开发工具是为 Java 开发者提供的,用于支持 代码编写、编译、调试、测试、打包和文档生成等任务。这些工具大多是命令行工具,也可以在集成开发环境(IDE)中找到它们的图形化界面。常见的 Java 开发工具包括 编译器(javac)、调试器(jdb)、打包工具(jar)、文档生成工具(javadoc) 等。
Java 编译器(javac)
javac 是 Java 的编译器,负责将 Java 源代码(.java 文件) 编译成 字节码(.class 文件)。这些字节码是平台无关的,可以在任何安装了 JVM 的平台上运行。
例如我们运行下面这条命令,这会将 HelloWorld.java 编译成 HelloWorld.class 字节码文件。
1 | javac HelloWorld.java |
在 IDEA 中编译器选项在这里修改:

java 归档工具(jar)
jar 是 Java 的归档工具,用于将多个 .class 文件和其他资源(如配置文件、图片等)打包成一个单一的 JAR 文件,便于分发和部署。JAR 文件可以包含多个类文件和资源文件,并且支持压缩。
例如我们可以通过下面这条命令创建一个简单的 JAR 文件,包含多个 .class 文件:
1 | jar cf myapp.jar *.class |
这里,c 表示创建,f 表示指定文件名,myapp.jar 是输出的 JAR 文件,*.class 表示要打包的所有类文件。
Java 运行工具(java)
java 是用于运行 Java 程序的工具,它会调用 JRE 中的 Java 虚拟机(JVM)来加载执行 .class 文件。
1 | java HelloWorld # 并加载 Hello.class 文件来执行程序 |
JDK 的版本
Java 语言最初由 Sun Microsystems(后被 Oracle 收购)开发和维护,后来由 Oracle 以及一些开源社区继续维护和更新。由于不同的实现方式、开发目标以及不同的维护模式,产生了多种 JDK 实现。
常见 JDK 发行版
Oracle JDK
Oracle JDK 是由 Oracle 提供的官方 Java 实现。它包括了所有的功能和企业级支持,包括商业支持和额外的优化。Oracle JDK 是最广泛使用的 JDK 发行版之一,特别是在企业级应用中。
我们可以在 Oracle JDK官网下载安装历史版本的 Oracle JDK。
Oracle JDK 是 闭源 的,虽然它包含 Java 的完整功能,但对于某些用户(如企业用户)来说,可能需要购买支持和许可证。不过我们通常下载的 JAVA SE 版本,因此只需要登录即可。
Java SE(Java Standard Edition) 是 Java 语言的基础平台,提供了开发和运行桌面应用程序、控制台应用程序、命令行工具和普通应用程序所需要的所有基本类库和 API。
Java EE(Java Enterprise Edition) 是 Java 平台的企业级版本,专门用于开发大型、复杂、分布式的企业级应用程序。它建立在 Java SE 的基础上,并扩展了许多高级功能和 API,适合于开发 Web 应用、分布式系统、大型企业系统等。
Java EE 本身并不是一个独立的开发工具包,而是规范和技术的集合,它需要依赖 JDK 来进行开发和运行。JDK 提供了编译和运行 Java 应用所需要的工具,而 Java EE 则是一个扩展,提供了面向企业的技术和功能。
虽然 Java EE(或 Jakarta EE)提供了很多高级功能,如 EJB(企业级 Java Beans)、JPA(Java 持久化 API)、JMS(Java 消息服务)、JTA(Java 事务 API)等,但这些技术对于大多数 Web 应用来说并不总是必需的。随着 Spring 和 Spring Boot 的流行,很多开发者已经不再依赖传统的 Java EE 容器来开发 Web 应用,而是转向了更加轻量级、灵活的解决方案。也就是说现在的 Web 开发可以脱离传统的 Java EE,而使用 Java SE + Web 框架 + Web 服务器 的模式。
因此我们在研究 Java 安全的时候一般不需要 Java EE 。
关于 Oracle 官网的登录,网上有很多 Oracle 的共享账号,这里提供一个方便下载:
- 账号:
908344069@qq.com - 密码:
Java_2024
OpenJDK
OpenJDK 是 Java 的开源实现,是由多个开源社区(包括 Oracle)共同开发和维护的 JDK 版本。
“Oracle JDK 是闭源”是个历史印象。早年(JDK6/7/8 的很长一段时间)Oracle JDK里确实带了一些专有/未开源的组件和“商业功能”,而 OpenJDK 完全是 GPLv2 + Classpath Exception 的开源代码;所以大家才把 Oracle JDK 叫“闭源”。
从 JDK 11 开始,Oracle JDK 与 OpenJDK 在代码上基本等价(核心代码同源),主要区别变成了许可证和支持策略,而不是“有没有源码”。
OpenJDK 是 Java SE 的标准实现,并且是完全开源的,遵循 GNU General Public License(GPL)许可证。它没有 Oracle JDK 中的一些商业功能(如 Java Flight Recorder 等),但对于大多数开发者来说,OpenJDK 足以满足需求。
OpenJDK 是 Java SE 的参考实现,因此任何符合 Java SE 规范的 JDK 实现都可以被称为 “JDK”。多个公司和组织都基于 OpenJDK 提供了不同版本的 JDK,并提供相应的支持。例如:
- AdoptOpenJDK(现在更名为 Adoptium) :由社区维护的 OpenJDK 构建版本,适用于多种操作系统。
- Amazon Corretto :亚马逊提供的 OpenJDK 构建,具有长期支持(LTS)版本。
- Red Hat OpenJDK :由 Red Hat 提供的 OpenJDK 版本,适合企业级应用。
我们下载的 Oracle JDK 只提供了 java 和 javax 包下的源码,没有 sun 包源码,这时候就需要去 OpenJDK 官网下载 JDK 源码:
如果官网访问不了还何以去 github 下载:
例如我们运行 java -version 后的输出内容如下:
1 | java version "1.8.0_112" |
那么当前的 JAVA 版本是 8u112-b15,因此我们需要选择对应的 jdk8u112-b15 版本的源码下载。
Oracle 对 8u112 还做过 BPR(Bundled Patch Release),这类补丁版不是公开 GA,而是订阅/支持渠道提供;Oracle 的 8u112 BPR 发布说明里就有“Changes in Java SE 8u112 b32”。这解释了你看到的更高
-bXX号。OpenJDK 历史上也对 8u112 分支继续打了多个 tag,例如
.hgtags中可见 **jdk8u112-b31、-b32、-b33**;GitHub 的openjdk/jdk8u也保留了jdk8u112-b33的标签。它们代表在 8u112 线上继续集成的一批修复/合并。直观理解:
jdk8u112-b15= 8u112 的 GA(和 Oracle 公版对应)。jdk8u112-b31/32/33= 8u112 分支的后续构建(相当于 BPR/持续 backport),并非“下一个大版本”(那是 8u121)。
点击 zip 下载源码压缩包后,将 sun 目录复制到JDK的安装目录下的 src,IDEA 中 Project Structure->SDKs->SourcePath,添加 src 目录。
高版本 JDK 的目录变了,并且下压缩包不利于离线,一种方法是直接把 jdk8u-dev 和 jdk8u 仓库下载到本地,然后导出指定版本的 Java 源码压缩包。
1 | # 克隆并取全量标签 |
jdk8u-dev:开发/集成库(development)。新 backport、修复先并到这里,做集成测试、预发。
jdk8u:稳定/发布库(release)。当某个 update 版本稳定后,从-dev迁到这里,作为正式发布基线。很多 release 标签(比如
jdk8u191-b12)在两个库里都能找到;极少数老/特殊标签可能只在其中一个。
然后根据需求导出指定版本的 JDK 源码目录为 ZIP 压缩包:
1 |
|
JDK 的版本号演变
JDK 1.x 阶段(Java 的早期版本)
在这一阶段,Java 的版本号采用的是 1.x 的命名方式。从 JDK 1.0 到 JDK 1.4,每次更新都依赖于 1.x 的版本号来标识,包含了 Java 语言的最初发展和许多核心特性。
- JDK 1.0 (1996) :Java 的第一个版本,提供了最基本的类库和工具,标志着 Java 语言的正式发布。
- JDK 1.1 (1997) :增强了 Java 的图形用户界面(GUI)能力,增加了 Java Beans、反射 API、JDBC 等重要功能。
- JDK 1.2 (1998) :引入了 Java 2 的概念,增强了企业级开发(J2EE)和桌面应用开发(J2SE)支持,支持了集合框架、Swing 等技术。J2EE、J2SE 和 J2ME 架构首次提出。
- JDK 1.3 (2000) :增加了更多的性能优化,改进了 RMI(远程方法调用)和 CORBA 支持。
- JDK 1.4 (2002) :引入了正则表达式(regex)、日志 API、NIO(New I/O)等新特性,进一步增强了 Java 在企业级应用中的功能。
Java x 阶段(Java 语言的重大变革)
此阶段的标志是 Java 从 1.x 版本号跳跃至 Java 5(JDK 1.5),进入了语言层面的重大更新。在 Java 5(JDK 1.5) 之后,Java 版本号逐步向更简洁的 Java x 命名方式发展,这也标志着 Java 语言和平台开始与更现代的开发理念接轨。
- Java 5(JDK 1.5, 2004) :这是 Java 语言的重大更新,引入了泛型、枚举类型、注解、增强的
for循环等特性。为了突出其重要性,版本号直接跳跃至 Java 5,不再沿用1.x的版本号。这一版本的发布使得 Java 在语言层面发生了革命性的变化,正式支持了泛型和注解。 - Java 6(JDK 1.6, 2006) :改进了性能和 JDK 本身的功能,增强了对编译器和脚本语言的支持,引入了 Java Compiler API 和 Java Shell。
- Java 7(JDK 1.7, 2011) :引入了
try-with-resources语法、改进的 I/O(NIO 2)、Switch 支持字符串等新特性,增强了语言和库功能。 - Java 8(JDK 1.8, 2014) :这一版本非常重要,该版本带来了 Lambda 表达式、Stream API、java.time 日期时间 API 等新特性,开始支持 函数式编程,极大地提高了开发效率。
JDK x 阶段(现代的 JDK 版本)
从 JDK 9 开始,Java 进入了一个新的发展阶段,采用了更加简洁的版本号,并且开始实行 每六个月发布一个新版本 的计划。JDK 9 引入了 模块化系统(Project Jigsaw),并且进一步加强了语言功能、JVM 性能和标准库。
- JDK 9(2017) :引入了 模块化系统(Project Jigsaw),允许开发者将应用程序划分为模块,提高了 Java 的灵活性和可维护性。此版本是第一个采用新版本号格式的版本,标志着从 1.x 系列向简化版本号过渡。
- JDK 10(2018) :引入了 局部变量类型推断(var),增强了 Java 的开发效率,并开始了更频繁的版本发布周期。
- JDK 11(2018) :这是一个 长期支持(LTS)版本,作为 LTS 版本,JDK 11 被广泛应用于生产环境,并且继续加强了模块化功能。
- JDK 12、13、14 等(2019-2020) :这些版本主要聚焦于性能改进、垃圾收集器的增强、语言的进一步优化等,继续沿用 JDK x 版本号。
- JDK 17(2021) :另一个 LTS 版本,在许多企业环境中被广泛使用。该版本提供了更多的语言特性改进、JVM 性能优化和稳定性增强。
在 IDEA 中切换 JDK
切换 Java 开发工具
这里切换的主要是编译 java 文件所依赖的 Java 开发工具。Java 开发工具编译生成的 class 文件在通过 JRE 加载运行时会检查版本,如果版本不匹配通常会有如下报错:
1 | java.lang.UnsupportedClassVersionError: com/example/Main has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 52.0 |
提示
JRE 对 Java 开发工具向下兼容,也就是说如果我们用新版的 JRE 加载运行旧版本的 Java 开发工具编译的 class 文件通常不会报错,但反之会出现上述报错。
在 IDEA 中我们通常需要打开 文件 → 项目结构 然后选择所需 Java 开发工具对应的 JDK。

切换 JRE
在 IDEA 中我们通常需要点击 编辑配置...:

在 构建并运行 处选择运行所需的 JRE 对应的 JDK 版本。

提示
通常我们测试 Java 的特性的时候看的就是这里的 Java 版本。
Java 项目分析
Jar 包还原 Java 项目
1 |
|
Java 字节码
字节码动态生成
在编写 exp 的时候会涉及恶意类的字节码,为了方便动态修改参数通常我们会选择动态生成字节码,而不是分别编译恶意类和 exp。
Javassist 是一个开源的字节码操作库,能够在运行时对 Java 类的字节码进行修改。如果你使用 Maven 作为构建工具,可以在 pom.xml 文件中添加以下依赖来引入 Javassist:
1 | <dependency> |
Javassist 封装了复杂的字节码操作,使得开发者可以通过更简单的 API 进行字节码生成、修改、方法插桩等操作。不过这里我们主要用到的就是 Javassist 的字节码生成功能。
获取 ClassPool 实例
ClassPool 是一个类池,它用于存储已经加载或创建的类的字节码。通过 ClassPool,你可以创建新的类,修改现有类,或者从池中获取已加载的类。
1 | // 获取默认的 ClassPool 实例 |
ClassPool.getDefault()方法返回一个全局的ClassPool实例,这是最常用的获取方式。ClassPool默认会使用一些常见的类路径进行初始化。ClassPool本质上是一个类字节码的存储池,所有的类都会保存在这里。
创建一个新的类
通过 ClassPool 创建一个新的类,可以使用 makeClass 方法,这个方法会返回一个 CtClass 对象,表示一个新的类。
1 | // 创建一个新的类,名为 "HelloWorld" |
CtClass 是 Javassist 中表示类的对象,可以将其看作是 Java 类的抽象表示。你可以通过 CtClass 操作类的字段、方法、构造器等。
向类中添加属性
创建字段
你可以为新创建的类添加字段(属性)。字段的类型、名称以及访问修饰符都可以在创建时指定。
1 | // 创建一个公共的 String 类型的字段 "message" |
CtField表示类中的一个字段。构造方法需要指定字段的类型、字段名和所属的类。Modifier.PUBLIC:用于指定字段的访问修饰符,可以是public、private、protected等。addField():将创建的字段添加到类中。
创建方法
你可以使用 CtMethod 类来表示方法,并通过 CtMethod.make() 或直接调用构造方法来创建方法。
1 | // 创建一个公共的无参数方法 sayHello,打印 message 字段的值 |
CtMethod.make()用于从源代码字符串创建一个方法。你可以将方法的代码(作为字符串)传递给make()方法。addMethod():将方法添加到类中。
当然也可以定义一个带参数的 sayHello(String name),其中字符串里就是完整的 Java 方法源码,Javassist 会帮你编译。
1 | CtMethod method = CtMethod.make( |
提示
CtMethod.make("源码字符串", ctClass) 只能用来生成方法。它内部会把你写的源码解析成一个 CtMethod 对象,然后挂到 ctClass 上。也就是说,它只适合 普通方法(有参 / 无参、静态 / 实例方法都可以)。
如果你想动态指定参数类型,不用写源码字符串,可以这么写:
1 | // 定义方法签名:返回类型 void,方法名 sayHello,参数类型 String |
这里有几个关键点:
CtClass.voidType表示返回类型是void。new CtClass[]{...}里放方法参数类型,可以多个,比如String、int等。- 方法体里的
$1表示第一个参数(类似$2表示第二个参数)。
创建构造方法
为了初始化类的字段,通常会创建一个构造方法。Javassist 提供了 CtConstructor 类用于构造方法的生成。
1 | // 创建一个带参数的构造方法,接受一个 String 类型的参数并赋值给 message 字段 |
CtConstructor:表示类中的一个构造方法。构造方法的参数类型通过CtClass数组传递,方法体通过setBody()设置。$1:表示构造方法的第一个参数(在setBody()中的$1对应构造方法传入的参数。另外普通函数直接源码构建,不需要这么写)。
注意
因为在字节码规范里,构造方法 <init> 和普通方法是不一样的,所以 CtMethod.make("...源码...", ctClass) 只能解析普通方法。构造函数必须用 CtConstructor 创建,不能像方法一样直接用源码字符串。
虽然没有 CtConstructor.make(...),但 CtConstructor.setBody("{ ... }") 的参数就是一段源码字符串(方法体),所以写法上也很接近。
生成字节码
现在我们已经定义好了类的字段、方法和构造方法,接下来需要生成类的字节码。CtClass 提供了 toBytecode() 方法,来将类转化为字节码。
1 | // 获取字节码 |
例如我们可以实现一个 getEvilClass 函数来动态生成恶意类的字节码:
1 | public static byte[] getEvilClass(String cmd) throws Exception { |
也可以将恶意代码放到静态代码块中,这样就可以直接在类加载的时候触发。
1 | public static byte[] getEvilClass(String cmd) throws Exception { |
通常有些场景需要我们开启 HTTP 服务监听指定端口并返回恶意类:
1 | import com.sun.net.httpserver.HttpExchange; |
字节码动态修改
获取指定类的字节码
代码中获取类的字节码
1 | String b64 = Base64.getEncoder().encodeToString(Repository.lookupClass(MyClass.class).getBytes()); |
获取动态生成的类的字节码
类加载机制
在 Java 中,类的加载是指将 .class 文件的字节码加载到 JVM 的内存中并进行相应的处理。类加载是 Java 程序启动的一个重要步骤,而类的初始化是确保类在第一次使用时能正常工作的一部分。
类的加载和初始化是通过 类加载器(ClassLoader) 来完成的,类加载器负责加载类文件,并将其转换为可以在 JVM 中使用的 Class 对象。
Java 虚拟机规范并没有指明二进制字节流要从一个 Class 文件获取,或者说根本没有指明从哪里获取、怎样获取。这种开放性使得 Java 在很多领域得到充分运用,例如:
- 从ZIP包中读取:这很常见,成为 JAR,EAR,WAR 格式的基础。
- 从网络中获取:最典型的应用就是 Applet。
- 运行时计算生成:最典型的是动态代理技术,在
java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass来为特定接口生成形式为$Proxy的代理类的二进制字节流。 - 由其他文件生成:最典型的 JSP 应用,由 JSP 文件生成对应的 Class 类。
类加载的过程
类加载过程包括 加载(Loading)、链接(Linking) 和 初始化(Initialization) 三个阶段。
加载(Loading)
加载阶段是将类的字节码从文件系统(或其他来源)读取到内存中,并且将其转换成一个 Class 对象的过程。此时,JVM 并不会执行类中的任何代码,仅仅是将字节码加载到内存中。加载过程包括:
- 通过类的全限定名获从文件系统、JAR 包、网络等不同的源查找类的字节码。
- 类加载器将字节码代表的静态存储结构转化为方法区(在 Java 8 之后,“方法区” 实现变成了 元空间 Metaspace,但概念上没变)的运行时数据结构。
- 在内存中生成一个代表该类的
java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
链接(Linking)
在 类加载(Loading) 阶段,JVM 只是把类的字节码文件读进来,转成了内存中的运行时数据结构(Class 对象)。但要让这个类真正能被 JVM 正确执行,还需要对它做进一步的处理,这就是 链接(Linking) 阶段。
链接就是把“纯字节码数据”变成“可执行的、合法的、可互操作的类” 的过程,它包括:
验证(Verification)
检查类文件的字节码是否合法且符合 JVM 的规范,确保字节码不会破坏 JVM 的安全性。
准备(Preparation)
为类的静态变量分配内存,并将其初始化为默认值(如 0、null、false 等)。
解析(Resolution)
将常量池中的符号引用(如类名、方法名、字段名)替换为直接引用(即实际的内存地址)。
这一步是懒惰的,即 JVM 规范允许解析在初始化之后延迟进行;HotSpot 通常会在首次使用符号引用时才解析。
初始化(Initialization)
类初始化相关结构
static 变量
类级别,与实例无关;同一 ClassLoader 中只有一份。在“准备”阶段先置默认值;若有显式初始化(非常量表达式),会在 <clinit> 里按源码顺序执行。
1 | [ClassLoader #1] |
准备(Preparation)阶段:JVM 为
static字段分配存储并设默认值(数值型=0,引用=null,boolean=false…)。初始化(Initialization)阶段:执行
<clinit>(由静态字段的显式赋值 +static {}块按源码顺序合成),把上面的默认值更新为你写在代码里的初始值。- 例:
static int s = f();、static { s = g(); }都在<clinit>里执行,每个 ClassLoader 仅一次。
- 例:
提示
在容器(如 Tomcat)里,不同 WebApp 的类由不同的 ClassLoader加载;每个加载器有自己的一份 static 存储。这就是热部署/多应用下“看起来又初始化了一次”的原因。
static final 变量
分两类:
编译期常量(constant variable)
- 形态:
public static final原始类型或String,且右值是编译期常量表达式(如字面量或纯常量算式) - 不会触发初始化:其他类读取它时,通常早已内联进自己的常量池
- 在“准备”阶段就通过
ConstantValue直接设为该值
- 形态:
非常量(运行期求值)
- 形态:
static final Integer X = Integer.valueOf(42);、static final int Y = foo();等 - 需要在
<clinit>执行赋值;读取它会触发类初始化
- 形态:
final 的语义:只能赋值一次。对引用来说,引用一旦指向某对象就不能再指向别的对象,但对象内容(若本身可变)仍可变。
静态代码块 static { ... }
静态代码块中的代码会合并进 <clinit>,和静态字段的显式赋值一起按源码顺序执行,并且每个 ClassLoader 只执行一次。在代码块中只能访问静态成员;不能使用 this,若抛出异常,初始化失败,该类在该 ClassLoader 下将“损坏”,后续使用会得到 NoClassDefFoundError。
没有 static 声明的是实例初始化块 / 非静态初始化块({ ... }),这种代码块中的代码在每次 new 对象、在父类构造完成之后、当前类构造器体之前执行。也就是说每创建一个实例就执行一次(所有构造器都会执行它),并且在代码块中可以用 this,可访问实例字段/方法。
类初始化的操作
类初始化阶段的主要动作包括:
执行类中的静态代码块(
static {})JVM 会把所有静态代码块合并到
<clinit>方法里,保证只执行一次。执行顺序与代码在源文件中出现的顺序一致。<clinit>其实是 JVM 在类加载过程中 自动生成的一个特殊方法,全称是 Class Initializer(类初始化方法)。JVM 会在类初始化阶段调用该方法一次。
静态变量的初始化
- 如果静态变量有显式赋值语句,就在
<clinit>方法里执行赋值。 - 如果没有赋值,则保持在链接阶段中的准备阶段时的默认值(
0、null、false等)。 - 静态变量赋值和静态代码块会按源文件中的顺序依次执行。
- 如果静态变量有显式赋值语句,就在
注意
要注意类初始化和对象初始化概念的区分,二者的含义是不同的。
类初始化:执行
<clinit>(静态变量赋值 + 静态代码块),只会执行一次,发生在“类的首次主动使用”时。构造函数:是
<init>,在创建对象时执行,可以执行多次,每 new 一个实例就会执行一次。
也就是说构造函数是用于创建类实例的函数,构造函数的执行与类的初始化无关。构造函数只会在 实例化类时才会执行,而不是在类的加载和初始化时。即只有在通过 new 操作符或反射机制创建实例时,才会调用构造函数。
触发类初始化的事件
初始化阶段是类加载的最后一步,它发生在类的首次使用时。具体来说,触发初始化的典型时机有下面几种:
访问类的静态变量(不是
final常量):当访问类的某个非编译期常量的静态变量时,必须触发类初始化。1
2
3
4
5
6
7
8class A {
static int x = 10; // 非 final
}
public class Test {
public static void main(String[] args) {
System.out.println(A.x); // 触发 A 初始化
}
}注意
static final编译期常量不会触发初始化,因为常量值在编译阶段就会放进调用类的常量池里。1
2
3
4
5
6
7
8class B {
static final int CONST = 100; // 编译期常量
}
public class Test {
public static void main(String[] args) {
System.out.println(B.CONST); // 不触发 B 初始化
}
}调用类的静态方法 :调用类中的静态方法时,必须先初始化类。
1
2
3
4
5
6
7
8
9class C {
static { System.out.println("C 初始化"); }
static void foo() {}
}
public class Test {
public static void main(String[] args) {
C.foo(); // 触发初始化
}
}使用反射 :调用
Class.forName("类名")会触发类的初始化;通过反射调用方法(如clazz.getMethod(...).invoke(...))也会触发。1
2
3
4
5
6
7
8class D {
static { System.out.println("D 初始化"); }
}
public class Test {
public static void main(String[] args) throws Exception {
Class.forName("D"); // 触发初始化
}
}初始化子类时,必须先初始化父类,即当初始化一个类时,若它有父类,必须保证父类先完成初始化。
1
2
3
4
5
6
7
8
9
10
11class Parent {
static { System.out.println("Parent 初始化"); }
}
class Child extends Parent {
static { System.out.println("Child 初始化"); }
}
public class Test {
public static void main(String[] args) {
new Child(); // 输出:Parent 初始化 → Child 初始化
}
}JVM 启动时指定的主类 :例如执行
java MainClass时,MainClass一定会被初始化,因为 JVM 要调用main方法。1
2
3
4
5
6class MainClass {
static { System.out.println("MainClass 初始化"); }
public static void main(String[] args) {}
}
// java MainClass → 输出 "MainClass 初始化"
类加载方式
在 Java 中,类加载可以分为两种主要方式:显式类加载(Explicit Class Loading) 和 隐式类加载(Implicit Class Loading)。
显式类加载(Explicit Class Loading)
显式类加载是指开发者显式地调用某些 API 或者显式指明要加载某个类,通常通过 Class.forName(...)、ClassLoader.loadClass(...) 等方法来完成。这种方式通常用于在运行时动态加载类。
通过反射机制加载
在 Java 中,Class.forName 是最常见的显式加载类的方式,它有两个重载:
1 | public static Class<?> forName(String className) throws ClassNotFoundException; |
注意这里 className 必须是类的全限定名(Fully Qualified Name),否则会抛 ClassNotFoundException。
在 Java 中,全限定名就是类在 JVM 里的唯一身份标识,它由 完整的包名 + 类名 组成,例如
"java.util.ArrayList"。内部类要用$表示,例如"java.util.Map$Entry"。
其中第二种重载多了 initialize 和 loader 参数:
initialize- 为
true时,类在加载时会立即触发初始化(执行<clinit>:静态变量赋值 + 静态代码块)。 - 为
false时,仅仅加载类,但不会初始化。
- 为
loader- 指定加载该类的类加载器。
- 如果传入
null,表示使用 引导类加载器(Bootstrap ClassLoader) 来加载。 - 如果传入自定义的类加载器,则由该加载器负责加载。
第一个重载 Class.forName(String className) 等价于使用 调用者的类加载器,并且 initialize=true:
1 | Class.forName("Foo"); |
提示
JVM 会用「谁调用,就用谁的加载器」的原则,来决定默认的类加载器。
- 调用者类 = 调用
Class.forName这一行代码所在的类。 - 调用者的类加载器 = 把这个调用者类本身加载进 JVM 的类加载器。
如果想显式用 系统类加载器,应该这样写:
1 | Class.forName("Foo", true, ClassLoader.getSystemClassLoader()); |
如果想用 Bootstrap 类加载器(加载核心类库),则传 null:
1 | Class.forName("java.lang.String", true, null); |
通过类加载器加载
在 Java 中,可以通过 ClassLoader 的 loadClass 方法来显式加载类:
1 | public Class<?> loadClass(String name) throws ClassNotFoundException; |
ClassLoader是 Java 的一个 抽象类(java.lang.ClassLoader),所有类加载器都继承自它。
不同于 Class.forName(String className),ClassLoader.loadClass(String name) 默认只加载类,不会触发初始化。初始化仍然要等到类的主动使用(访问静态字段 / 调用静态方法 / new 实例 / 反射调用等)。
1 | ClassLoader classLoader = ClassLoader.getSystemClassLoader(); |
loadClass(String name) 实际调用的是另一个重载:
1 | protected Class<?> loadClass(String name, boolean resolve) |
- **
name**:类的全限定名(如"com.example.Hello") - **
resolve**:是否解析类(是否调用resolveClass)
默认的 loadClass(String name) 等价于:
1 | return loadClass(name, false); // 默认不解析 |
因此默认的 loadClass(String name):
- 只加载(Load):读取字节码,生成
Class<?>对象。 - 不解析(Resolve=false):不会立即把符号引用替换成直接引用。
- 不初始化:不会执行
<clinit>(静态变量赋值 + 静态代码块)。
提示
当 Class.forName 的 initialize=false 且使用的类加载器与你 loadClass 指定的类加载器一致时,两者等价:
1 | // 方式 1:不初始化 |
隐式类加载(Implicit Class Loading)
隐式类加载是指在 Java 程序中,类的加载是由 JVM 自动触发的,不需要显式调用类加载方法。隐式加载通常发生在类的首次使用时,例如实例化对象、访问类的静态字段或调用静态方法等。
通过实例化对象加载
当我们通过 new 关键字创建一个类的实例时,JVM 会自动加载该类。如果该类尚未被加载,JVM 会根据类加载器的机制来加载并初始化该类。
其中 类的静态块(static {})会在类加载时被执行,构造函数则在实例化时被调用。
1 | // 隐式加载类并创建实例 |
类的首次“主动使用”
JVM 规范规定:类的首次“主动使用”会触发初始化。而触发初始化时,如果该类尚未加载,就会顺带把“加载”和“链接”也做了。
前面总结的触发类初始化的情境有:
- 访问类的静态变量(不是
final常量) - 调用类的静态方法
- 初始化子类前,先初始化父类
- JVM 启动时指定的主类
在这种情境下 JVM 会自动加载该类,类的静态代码块被执行。但是由于类没有被实例化,因此构造函数不会被调用。
类加载器
类加载器(ClassLoader)是 JVM 中负责 加载类字节码 的组件。它的作用是:
- 在运行时把外部
.class文件(或字节码流)加载到内存; - 转化为
Class对象,供程序使用; - 实现 Java 的 动态类加载机制(类按需加载,而不是一次性全部加载)。
简单说:类加载器决定了“某个类由谁负责加载”,同时也影响了类在 JVM 中的唯一性。
类加载器的核心特性有:
- 延迟加载(Lazy Loading) :类只有在“首次主动使用”时才会被加载。
- 双亲委派模型(Parent Delegation Model) :加载类时,先委托父加载器 → 父加载器找不到,自己再尝试加载。
- 类的唯一性 :
- JVM 判断两个类是否相同:不仅类名一致,还要 由同一个类加载器加载。
- 不同的类加载器即使加载了同名类,也会被认为是不同的类。
类加载器的类型
Java 中的类加载器有几种不同的类型,每种类型都有不同的作用和责任。下面是 Java 中常见的几种类加载器:
引导类加载器(Bootstrap ClassLoader)
- 功能 :引导类加载器是 JVM 启动时创建的,负责加载 Java 标准库中的核心类,如
java.lang.*、java.util.*等。 - 实现 :引导类加载器由 C++ 编写,通常由 JVM 内部实现,无法在 Java 代码中直接访问,但它是所有其他类加载器的父加载器。
- 加载的路径 :引导类加载器的加载路径通常于
$JAVA_HOME/jre/lib或jre/lib/rt.jar中的类。
提示
在 Java 虚拟机(JVM)的层面,可以将这些类加载器大致分为两大类:
- 一种是启动类加载器(Bootstrap ClassLoader)。这个类加载器由 C++ 实现,是 JVM 虚拟机自身的一部分,因此没有继承自
java.lang.ClassLoader,不能直接在 Java 代码中访问。 - 另一种就是所有其他的类加载器,些类加载器通常用 Java 语言实现,它们都继承自
java.lang.ClassLoader类。
扩展类加载器(Extension ClassLoader)
- 功能 :扩展类加载器负责加载 Java 扩展库中的类,通常是指位于
jre/lib/ext目录下的由 JDK 提供的一些功能扩展类,例如 XML 解析(javax.xml.*)类 - 实现 :扩展类加载器是由
sun.misc.Launcher$ExtClassLoader实现的。 - 加载的路径 :扩展类加载器的加载路径通常是
jre/lib/ext目录下的类。
系统类加载器(System ClassLoader)
- 功能 :系统类加载器负责加载应用程序的类,通常是指项目中的
.class文件或 JAR 包。 - 实现 :系统类加载器是由
sun.misc.Launcher$AppClassLoader实现的。它是最常用的类加载器,通常由用户代码通过ClassLoader.getSystemClassLoader()获取。 - 加载的路径 :系统类加载器的类路加载径通常由环境变量
CLASSPATH或命令行的-cp参数指定。系统类加载器加载的是程序类(即用户自定义类和第三方类库)。
提示
在有的资料中系统类加载器(System ClassLoader)也被称为应用类加载器(Application ClassLoader)。
自定义类加载器(Custom ClassLoader)
在 Java 中,类加载器负责将 .class 文件加载到 JVM 中,生成类的 Class 对象。默认的类加载器(如系统类加载器、扩展类加载器等)已经能够满足大部分的类加载需求,但是在某些场景下,我们需要根据特定的规则来加载类,这时就需要使用 自定义类加载器(Custom ClassLoader)。
自定义类加载器需要继承 java.lang.ClassLoader 并重写其方法,尤其是 findClass 方法,该方法是加载类的核心方法。例如下面是一个自定义类加载器的示例代码:
1 | import java.io.File; |
类加载器的获取
在 Java 中可以使用不同的方式来获取类加载器。以下是几种常见的获取类加载器的方法:
获取当前类的类加载器
要获取当前类(即包含当前代码的类)的类加载器,可以使用 Class 类的 getClassLoader() 方法。这个方法返回一个 ClassLoader 实例,表示当前类的类加载器。
1 | ClassLoader classLoader = MyClass.class.getClassLoader(); |
注意
在 Java 中我们不能直接获取引导类加载器的引用,因为它是由 JVM 本身实现的,且不是 ClassLoader 类的子类。
例如如果我们使用 Class.getClassLoader() 方法来获取 String 类的类加载器,它会返回 null,因为 String 类是由引导类加载器加载的。
1 | ClassLoader bootstrapClassLoader = String.class.getClassLoader(); |
获取当前线程的上下文类加载器
每个 线程 都可以绑定一个 ClassLoader,叫做 上下文类加载器(Context ClassLoader)。这个类加载器可以通过 Thread.currentThread().getContextClassLoader() 拿到,默认情况下,这个就是 AppClassLoader(应用类加载器)。
1 | ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); |
上下文类加载器的出现是因为 双亲委派模型有个问题:
- 子加载器要委托父加载器先加载类;
- 但有时候,父加载器(比如
Bootstrap)并不知道子加载器路径下的类。
而线程上下文类加载器是一个“打破双亲委派”的机制,让父加载器的代码能反过来加载子加载器的类。
例如在 Java 8 及更早,JDBC API(java.sql.*)存放在 核心库 rt.jar 中,由 Bootstrap ClassLoader 加载;在 **Java 9+**,它被放进了 java.sql 模块,但依然是由 Bootstrap ClassLoader 加载。
而具体的 JDBC 驱动类(例如 com.mysql.cj.jdbc.Driver)则通常作为第三方 Jar 包提供,放在应用的 classpath 中,由 AppClassLoader 加载。
这就带来了一个问题:
- Bootstrap ClassLoader 无法直接看到由 AppClassLoader 管理的类。
- 如果 JDBC API(例如
DriverManager)直接用自己的加载器(Bootstrap)去加载驱动类,一定失败。
为了解决这个“父加载器看不到子加载器类”的矛盾,JDBC API 采用了 线程上下文类加载器(Thread Context ClassLoader, TCCL) 的机制:
DriverManager 在加载驱动时,不用 Bootstrap 自己,而是调用
Thread.currentThread().getContextClassLoader()。这样就能让 Bootstrap 级别的 API 间接使用 AppClassLoader 来加载驱动类。
获取系统类加载器
系统类加载器(AppClassLoader)是 JVM 启动时默认用于加载 应用类路径(classpath) 下的类的加载器。绝大多数我们自己写的业务代码、以及放在 classpath 中的第三方依赖 jar,都是通过系统类加载器加载的。
系统类加载器可以通过 ClassLoader.getSystemClassLoader() 方法获取:
1 | ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader(); |
类加载器常见方法
loadClass(加载指定的 Java 类)
loadClass(String name) 是 ClassLoader 类中一个常用的公共方法,用于加载类。它通过类的 二进制名称(即类的完全限定名,像 java.lang.String)来加载类。
1 | public Class<?> loadClass(String name) throws ClassNotFoundException { |
该方法首先会委托父类加载器(即父加载器)去加载类,如果父类加载器没有加载到类,它才会使用当前类加载器来加载。
findClass(查找指定的 Java 类)
findClass 是一个 抽象方法,通常需要在自定义类加载器中重写。findClass 会被 loadClass 方法调用,用于实际查找和加载类。类加载器不知道如何加载这个类则会抛出 ClassNotFoundException 异常。
1 | protected Class<?> findClass(String name) throws ClassNotFoundException; |
findLoadedClass(查找 JVM 已经加载过的类)
findLoadedClass 方法用于查找当前类加载器是否已经加载了指定的类。如果类已经加载并被缓存,则返回该类的 Class 对象。它是在 loadClass 方法内部调用的,用来避免重复加载已经加载过的类。如果类已经被加载并且缓存,则返回该类的 Class 对象;否则返回 null。
1 | protected final Class<?> findLoadedClass(String name); |
resolveClass(链接指定的 Java 类)
resolveClass 方法用于解析一个类。类加载器通过此方法将类链接到 JVM 中,并确保该类已经准备好执行。这个方法通常在类被 defineClass 后调用,用于确保类已经完全初始化,并准备好进一步的操作(如方法调用等)。
1 | protected final void resolveClass(Class<?> c); |
双亲委派模型(Parent Delegation Model)
双亲委派模型(Parent Delegation Model)是 Java 类加载器机制中的一种设计模式。通过这种模型,类加载器在加载类时,会先将请求委托给父类加载器处理,如果父类加载器无法加载这个类,才由当前类加载器尝试加载。
这种机制保证了Java核心类库(如 java.lang.*、java.util.* 等)始终由引导类加载器(Bootstrap ClassLoader)加载,从而避免了类的重复加载和冲突,确保了 Java 核心类库的安全性。
类加载器层次结构
在 Java 中,类加载器采用双亲委派模型来加载类。这个模型的层次结构是通过父子关系来表示的,不同层次的类加载器通过 getParent() 方法来连接。
提示
在 Java 里谈类加载器的“层次结构”时,往往有两个不同的含义:
- 类的继承结构 :这是 Java 类型系统上的关系,编译时就确定了。这是 “类继承树”,描述的是类的实现复用关系。例如:
AppClassLoader extends URLClassLoader extends ClassLoaderExtClassLoader(JDK 8)或PlatformClassLoader(JDK 9+)也都直接extends ClassLoader- 所有类加载器最终都继承自
java.lang.ClassLoader
- 父子委派关系 :这是 对象运行时的组合关系,由
ClassLoader的parent字段维护。这是 “对象委派链”,决定了类加载请求的传递路径。例如:AppClassLoader的parent=ExtClassLoader(JDK 8) /PlatformClassLoader(JDK 9+)ExtClassLoader/PlatformClassLoader的parent=Bootstrap(C++ 实现,不暴露,返回null)- 自定义类加载器如果没指定
parent,默认parent= 系统类加载器(AppClassLoader)
而这里我们提到的类的层次结构指的是类加载过程中类的查找相关的层次结构,也就是类的父子委派关系,即 ClassLoader 的 parent 字段。
我们可以通过如下代码验证类加载器的层次结构:
1 | package com.example; |
这段代码运行结果如下:
1 | 自定义类加载器: com.example.MyClassLoader@330bedb4 |
提示
自定义类加载器默认的父级是 系统类加载器。当你创建一个自定义类加载器时,它会继承 AppClassLoader 类,而 ClassLoader 的构造方法会自动指定父加载器。具体来说:
- 如果你没有显式指定父加载器,
ClassLoader的构造函数会将 系统类加载器(ClassLoader.getSystemClassLoader())作为默认的父加载器。 - 你也可以通过重载构造函数,手动设置父加载器为其他加载器(如启动类加载器或扩展类加载器),但通常情况下,它的父加载器默认是 系统类加载器。
因此类加载器的层次结构如下:
双亲委派模型具体实现
在 Java 中,每个类加载器都有一个父类加载器,类加载器在加载类时,遵循以下步骤:
- 检查当前类是否已经加载:如果已经加载,则直接返回。
- 委托父类加载器加载:将加载请求委托给父类加载器,依次递归,直到最顶层的 Bootstrap ClassLoader。
- 父类加载器无法加载:如果父类加载器无法加载该类,则当前类加载器尝试自己加载。
在 Java 中,类加载器之间的双亲委派模型可以通过 ClassLoader 类的 loadClass 方法来实现。以下是 ClassLoader 类中 loadClass 方法的简化实现:
1 | protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { |
类加载器与类的隔离性
类加载器与类的隔离性是 Java 设计中的重要特性之一,它使得不同的类加载器能够加载同名的类,而这些类相互独立、互不干扰,从而避免了不同模块或应用之间的类冲突。类加载器与类的隔离性对于实现模块化、插件化以及动态类加载系统非常关键。
类命名空间
类命名空间(Class Namespace)是指类加载器在加载类时为其所分配的一个独立的命名空间。在 Java 中,每个类加载器都有自己的命名空间,用来隔离自己加载的类与其他类加载器加载的类。这种隔离机制保证了即使不同类加载器加载了相同名称的类,它们也可以作为独立的类存在,而互不干扰。
提示
不同的类加载器在加载类时依赖于自己的命名空间来确定类是否已经被加载以及加载后的处理。这就是为什么 loadClass 在双亲委派模型中每一级类加载器都要调用 findLoadedClass 来判断类是否已经被加载过了。
类加载的隔离性
Java 中的类是通过类加载器加载的。当一个类被类加载器加载时,它会被分配一个 Class 对象,该对象代表了这个类在 JVM 中的唯一身份。对于同一个类,如果通过不同的类加载器加载,它们的 Class 对象是不同的。
1 | ClassLoader loader1 = new MyClassLoader(); |
这是因为每个类加载器在加载类时,都会在自己的命名空间中维护已加载的类。类加载器的命名空间是独立的,意味着相同的类,如果由不同的类加载器加载,它们将被认为是不同的类,即使它们的字节码完全相同。
动态加载字节码
defineClass
不管是加载远程 class 文件,还是本地的 class 或 jar 文件,Java 都经历的是 loadClass、findClass、defineClass 这三个方法调用。其中 defineClass 的作用是将获取到的字节码处理成真正的 Java 类,是类加载中真正核心的部分。
注意
需要注意:类的链接(verification、preparation、resolution)不在 defineClass 内完成,而是在 resolveClass 中进行。
defineClass 将字节数组转换为一个 Class 对象。这个方法是 ClassLoader 的核心,用于将字节码定义为类。
1 | protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError; |
String name:要定义的类的全限定名称(例如com.example.MyClass),它是该类在 JVM 中的名字。注意
name参数必须与字节码中的类名匹配**或者设置为null**,否则会有如下报错:1
2
3
4
5
6
7
8
9
10
11Exception in thread "main" java.lang.reflect.InvocationTargetException
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:483)
at com.example.Main.main(Main.java:12)
Caused by: java.lang.NoClassDefFoundError: YourNameArgumentInput (wrong name: com/example/MyClass)
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:760)
at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
... 5 more另外
defineClass还有一个默认name为null的封装:1
2
3
4
5
6
protected final Class<?> defineClass(byte[] b, int off, int len)
throws ClassFormatError
{
return defineClass(null, b, off, len, null);
}byte[] b:包含类的字节码的字节数组。这个字节数组包含了通过编译器或其他方式生成的 Java 字节码,通常是.class文件的内容。int off:字节数组b中,字节码数据的起始偏移量。表示从数组b的哪个位置开始读取数据。一般情况下,如果整个数组都包含字节码数据,这个值为0。int len:要从字节数组b中读取的字节码数据的长度。表示从偏移量off开始,读取多长的数据来定义类。
Java 默认的 ClassLoader#defineClass 是一个 native 方法,逻辑在 JVM 的 C 代码中。我们可以通过反射调用这个方法把字节码转换为类:
1 | Method defineClassMethod = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class); |
在实际场景中,因为 defineClass 方法作用域是不开放的,所以攻击者很少能直接利用到它,但很多攻击链最终都是通过这个方法加载字节码。
URLClassLoader
URLClassLoader 是 JDK 提供的通用类加载器实现,能把一组 URL(目录 / JAR / 远程地址)当作类与资源的搜索路径。
AppClassLoader 是 URLClassLoader 的子类(继承意义的“子类”)。也就是说,AppClassLoader 本身就是 URLClassLoader 的一个具体实现。AppClassLoader 默认加载 java.class.path 里的内容,本质上就是把 classpath 转换成一组 URL,然后调用 URLClassLoader 的逻辑。
所以平时我们用 java -cp ... 指定的类路径,都是通过 URLClassLoader 机制来处理的。
URLClassLoader 实际上是通过内部的 URLClassPath 组件,为每个 URL 创建不同的资源加载器(实现类为 Loader 抽象类的不同子类)。其中核心逻辑为 sun.misc.URLClassPath.getLoader(URL url):
1 | /* |
核心就是:先判断“目录风格”,再看协议,最后兜底为 JAR 文件。
- 目录风格(
url.getFile().endsWith("/"))file:→ FileLoader
直接拼接路径/path/to/classes/com/example/Foo.class- 非
file:(http/ftp 等)→ Loader
用URLConnection拼出http://host/classes/com/example/Foo.class,直接下载字节
- 文件风格(没有
/结尾 → 当成 JAR)- JarLoader
无论是file:/lib/foo.jar还是http://host/foo.jar,都交给JarLoader
内部会通过JarFile(本地)或JarURLConnection(远程)读取 class entry。
- JarLoader
使用 Loader 寻找类是非 file 协议的情况下,最常见的就是 http 协议,此时会用到 URLClassLoader 。
1 | import java.net.URL; |
如果是远程加载 .class 文件,路径为 http://example.com/path/to/your/classes/,然后 loadClass 传入的是 .class 文件的文件名(没有后缀)。
1 | import java.net.URL; |
TemplatesImpl
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl 是 JDK 内置 Xalan XSLT 引擎中的一个类,实现了 javax.xml.transform.Templates 接口。
它的设计目的是:封装一个已编译好的 XSLT 样式表,并能基于内部保存的字节码反复创建 Transformer 实例,从而对 XML 文档执行转换。
在安全研究中,这个类之所以重要,是因为它内部维护了可直接加载和实例化的字节码数组。攻击者如果能够控制这些字段,就能在调用转换逻辑时触发任意字节码的加载与执行。TemplatesImpl 类内部有几个关键的字段和方法:
private String _name:样式表的名称。private byte[][] _bytecodes:存储了经过编译的字节码的数组,这些字节码会在运行时被加载并执行。private transient TransformerFactoryImpl _tfactory:TransformerFactory的引用,用于生成Transformer对象。private transient Class[] _class:用于缓存_bytecodes被真正加载为Class对象之后的结果。
这些字段中的 _bytecodes 和 _class 是特别重要的,因为它们直接涉及到字节码的加载和类的定义。
TemplatesImpl 定义了一个内部静态类 TransletClassLoader(继承自 ClassLoader),专门用来加载 XSLT 编译生成的 Java 类(translet)。这些类是在 XSLT 编译阶段动态产出的,用于执行特定的转换逻辑。
由于 ClassLoader#defineClass(String, byte[], int, int) 是 protected 方法,而 TemplatesImpl 本身既不在 java.lang 包里、也不是 ClassLoader 的子类,不能直接调用该受保护方法。
为此,TemplatesImpl 在它的内部定义了一个子类 TransletClassLoader,并在这个子类里额外提供了一个新的便捷方法 defineClass(byte[]):该方法签名与父类受保护方法不同,不是重写(override),而是新增的同名便捷包装(wrapper),内部再去调用父类的受保护 defineClass(...) 完成实际的类定义。
Java 中默情况下,如果一个方法没有显式声明作用域,其作用域为 包私有(default)。这意味着该方法只能在同一包内被访问,其他包的类无法访问该方法。
1 | static final class TransletClassLoader extends ClassLoader { |
注意
TransletClassLoader#defineClass(byte[]) 只是为 同包内 的 TemplatesImpl 提供了一个调用父类受保护 defineClass(...) 的“跳板”。它既没有改变父类方法的可见性,也没有把能力公开为 public。因此:
- 不是把父类的 protected 方法“降级”为 default
这里的defineClass(byte[])是新增的方法(签名不同),并没有改变父类受保护方法的可见性。父类的defineClass(String, byte[], int, int)依然是 protected。 - 包内可见 ≠ 任意类可见
这个便捷方法没有显式修饰符,因此是包内可见(package‑private)。这意味着只有同一包(com.sun.org.apache.xalan.internal.xsltc.trax)内的代码可以调用它;TemplatesImpl与该内部类处于同一包,可以调用;其他包的代码则不能直接调用。
例如下面这段代码,我们通过调用 TemplatesImpl 的 getOutputProperties 方法成功加载了设置在 _bytecodes 字段中的字节码。
1 | import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl; |
调用栈如下:
1 | at com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl$TransletClassLoader.defineClass(TemplatesImpl.java:185) |
提示
也就是说如果我们只要调用到上述调用栈中任意一个函数就可以实现任意字节码加载。
例如 com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter 的构造函数就可以调用参数 templates 的 newTransformer 方法。
1 | public TrAXFilter(Templates templates) throws |
如果我们构造的利用链能调用 TrAXFilter 的构造函数且参数可控就可以实现任意字节码加载:
1 | at com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl$TransletClassLoader.defineClass(TemplatesImpl.java:185) |
整个调用过程大致如下:
1 | getOutputProperties() |
首先 getOutputProperties 函数中会调用 newTransformer 函数,然后在 newTransformer 函数中调用 getTransletInstance。
1 | /** |
因为我们给 TemplatesImpl 的 _name 设置有值,因此会执行下面的 defineTransletClasses 来根据字节码数组 private byte[][] _bytecodes 中存放字节码依次调用的初始化 private Class[] _class,之后会将加载的下标为 _transletIndex 的类实例化。
1 | /** |
defineTransletClasses() 做了三件事:
- 在特权块里创建
TransletClassLoader; - 遍历
_bytecodes,调用loader.defineClass(byte[])把字节码“喂进 JVM”; - 之后调用
getSuperclass获取加载的类的直接父类,并通过“父类名是否是com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet”识别主类并记录_transletIndex(其余放入_auxClasses)。被识别为主类的类后续会被实例化。
defineTransletClasses 函数中使用 TransletClassLoader 加载类,也就是调用 defineClass 将字节码加载为类。之后调用 getSuperclass 获取加载的类的直接父类并判断父类的名称是否为 com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet。如果是则设置 _transletIndex 为当前加载类的下标,也就以为着这个类要被实例化。
1 | /** |
这就要求:
TemplatesImpl中对加载的字节码对应的类必须
是com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet的子类。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
public class HelloTemplatesImpl extends AbstractTranslet {
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
public HelloTemplatesImpl() {
super();
// 在这里编写代码
System.out.println("Hello TemplatesImpl");
}
}或者我们可以设置
_transletIndex = 0(默认是 -1)来避免异常,同时设置_auxClasses为HashMap放在调用put方法报错。1
2setFieldValue(templates, "_transletIndex", 0);
setFieldValue(templates, "_auxClasses", new HashMap<String, Object>());当然设置
_transletIndex = 0且_bytecodes为一项以上也行,这是因为当_bytecodes.length > 1的时候会初始化_auxClasses。1
2
3
4
5final int classCount = _bytecodes.length;
if (classCount > 1) {
_auxClasses = new Hashtable();
}
另外对于高版本的 JDK,TransletClassLoader 的创建需要用到 _tfactory,因此需要将 _tfactory 设置为一个 TransformerFactoryImpl 实例。
1 | TransletClassLoader loader = (TransletClassLoader) |
BCEL ClassLoader
BCEL 的全名应该是 Apache Commons BCEL,属于 Apache Commons 项目下的一个子项目,但其因为被 Apache Xalan 所使用,而 Apache Xalan 又是 Java 内部对于 Java XML功能的 JAXP 规范的实现,所以 BCEL 也被包含在了 JDK 的原生库中。
BCEL 库提供了一系列用于分析、创建、修改 Java Class 文件的 API。相较 Commons Collections,BCEL 被包含在原生 JDK 中,更容易被利用。
BCEL 的版本变化时间线主要有两条:
- 上游是
org.apache.bcel.*(Apache Commons BCEL)- JDK 里有一份重打包副本给 JAXP/XSLTC 用,包名是
com.sun.org.apache.bcel.internal.*(在java.xml里)。JDK 会按自身需求选择性裁剪并分批回移上游版本(BPR → 后续 GA 会吸纳),参见 Oracle 的 JDK 8 Update Release Notes 页面顶部说明“Fixes introduced on BPRs are added to later GA releases”。上游 Apache Commons BCEL(
org.apache.bcel.*)时间线:
2007 年 8 月:提出兼容性老坑 **BCEL‑110**:使用 BCEL 的
ClassLoader跑 JAXB 会抛LinkageError(类加载器约束冲突)。2015 年 7 月 26 日:为 6.0 做大版本筹备,创建任务 **BCEL‑222**(讨论是否更换包名/坐标,以规避不兼容;该任务最终在 2016‑06‑07 “Won’t Fix” 结题)。
2015 年 8 月前后(6.0 筹备期):上游在 6.0 的发布说明里写明要“remove the broken ClassLoader class”,并把这条变更挂到 **BCEL‑110*;可在 RELEASE‑NOTES.txt 看到“Problem with JAXB… remove the broken ClassLoader class*”。
2016 年 7 月 10 日:6.0 正式发布(见 **Maven Central 6.0、MavenRepository 6.0**)。
注意:虽然发布说明写“remove”,但上游最终并未物理删除该类,而是保留并标记废弃——在 Javadoc 里可以看到
org.apache.bcel.util.ClassLoader标注“Deprecated. 6.0 Do not use – does not work”(在 6.10.0 的包概览 也能看到)。后续上游版本:6.7.0(2022‑11‑28,见 changes‑report 与 **MavenRepository 6.7.0**),之后 6.8.x / 6.9.0 / 6.10.0(2024‑07‑13) 等;上述 Javadoc 的“保留但弃用”状态一直延续。
JDK 内置 BCEL(
com.sun.org.apache.bcel.internal.*,在java.xml)时间线:这条线是上游的重打包副本,但并不等同于上游;JDK 会挑删一些它认为在 JDK 环境里“没必要/有问题”的
util类,并按自己的节奏回移版本。
2016 年 8 月 3 日:JDK 提出 JDK‑8163121:上游 6.0 已发布,JDK 当前还是 5.2,应升级到 6.0。
2017 年 8 月 14 日(JDK 10 主线):落实 JDK‑8163121;在 JAXP 代码库里明确删除
src/java.xml/share/classes/com/sun/org/apache/bcel/internal/util/ClassLoader.java(以及一批 util 类),提交号是32af819a7f1c。2020 年 4 月 14 日(JDK 8u251 GA):8u251 回移 JDK‑8163121 到 8u 线上,Oracle 的 8u251 Bug Fixes 列表 直接列出“[JDK‑8163121] BCEL: update to the latest 6.0 release”。
自 8u251 起,JDK 8 的产物里看不到
com.sun.org.apache.bcel.internal.util.ClassLoader(以及同批 util 类);同期还有用户在 JDK‑8259663 报告“从 1.8.0_251 起缺失
ClassLoaderRepository”。2020–2022 的连续升级:
- 8u261(2020‑07):BCEL → 6.3.1(见 Oracle 的 8u261 bugfixes 列表 的“BCEL: update to version 6.3.1”);
- 8u301(2021‑07):→ 6.4.1(见 JDK‑8235368 / 回移单 JDK‑8262512 标注 8u301);
- 8u331(2022‑04):→ 6.5.0(见 **JDK‑8255035**)。
2022 年 7 月(8u342):老清理项落地:**JDK‑8132256** / 回移单 JDK‑8283529,把
com.sun.org.apache.bcel.internal.util.ClassPath这类在 JDK 场景里“基本无效”的代码移除(与ClassLoader的删除不是同一改动)。2023 年 4 月(8u371 BPR):Oracle 的合并版 8u 发行说明列出 JDK‑8301269:“Update Commons BCEL to Version 6.7.0”(把内置 BCEL 从 6.5.0 → 6.7.0)。
因为上游 6.x 一直“保留但废弃”
org.apache.bcel.util.ClassLoader(Javadoc 写明“Deprecated. 6.0 Do not use – does not work”),当 JDK 在 8u371 BPR 把内置 BCEL 升到 6.7.0 后(参见 JDK‑8301269),随着 BPR → 后续 GA 吸纳,后续版本就随上游形态把该类“带回来了”(包名是com.sun.org.apache.bcel.internal.util.ClassLoader)。
BCEL ClassLoader 的 loadClass 实现如下:
1 | /** |
从中可以看出 BCEL ClassLoader 加载字节码的过程为:
查找已加载的类:首先,系统会通过哈希表
classes在本地缓存中查找目标类是否已经加载。如果找到了,直接返回已加载的类。1
2
3if ((cl = (Class) classes.get(class_name)) == null) {
// 如果未找到,则继续尝试其他加载方式
}通过系统类加载器加载类:如果目标类不在缓存中,系统会使用默认的 系统类加载器 来加载类。这是 Java 中常见的类加载方式,适用于类在常规类路径中存在的情况。
这部分的代码通过检查类名是否属于被忽略的包(
ignored_packages)来决定是否需要使用默认的类加载器。1
2
3
4
5
6for (int i = 0; i < ignored_packages.length; i++) {
if (class_name.startsWith(ignored_packages[i])) {
cl = deferTo.loadClass(class_name); // 使用系统类加载器加载
break;
}
}处理包含
$$BCEL$$标记的类名:如果类名包含特殊标记$$BCEL$$,这意味着该类的字节码是经过特殊编码或压缩的。系统会调用createClass方法来解码并生成该类的字节码。1
2
3if (class_name.indexOf("$$BCEL$$") >= 0) {
clazz = createClass(class_name); // 使用 createClass 方法加载字节码
}createClass解码该类的字节码的具体过程为:- 查找
$$BCEL$$标记:首先,createClass方法检查类名中是否包含$$BCEL$$标记,并从中提取出实际的类名。class_name.indexOf("$$BCEL$$"):查找$$BCEL$$的位置。class_name.substring(index + 8):从$$BCEL$$后面开始获取实际的类名部分。
- 解码字节码:接下来,
real_name(即去掉$$BCEL$$后的部分)被传递给Utility.decode(real_name, true)方法解码出真正的字节码。注意这里第二个参数为true,这意味着字节码在解码后还要进行 GZIP 解压缩。 - 解析字节码:解码后的字节数组
bytes会被传递给ClassParser来解析。ClassParser是 BCEL 提供的一个工具类,用于解析字节数组并生成JavaClass对象。JavaClass对象包含了类的字节码、常量池、方法、字段等信息。 - 更新类名:一旦
JavaClass对象被成功创建,就需要更新类名。JavaClass对象包含了一个 常量池(ConstantPool),用于存储类的常量信息。- 在常量池中,有一个表示类名的常量(
ConstantClass)。通过getConstant(clazz.getClassNameIndex(), Constants.CONSTANT_Class)获取类名常量。 - 然后,我们通过
ConstantUtf8获取类名的具体字符串表示,并将其替换为传入的完整类名class_name.replace('.', '/')(将.转换为/)。 name.setBytes(class_name.replace('.', '/'));:更新常量池中的类名。
- 在常量池中,有一个表示类名的常量(
- 返回解析后的
JavaClass对象:最后,返回通过ClassParser解析后的JavaClass对象,它包含了该类的字节码和其他相关信息。
- 查找
定义类并返回:找到字节码后,
defineClass方法会被调用来将字节码转化为Class对象。这个方法会根据字节数组定义一个新的类,最终将其加载到 JVM 中。如果通过repository或其他方式加载的类已经存在,系统将跳过定义过程,直接返回Class对象。1
2byte[] bytes = clazz.getBytes(); // 获取字节码
cl = defineClass(class_name, bytes, 0, bytes.length); // 定义并加载类缓存类:最后,加载的类被缓存到
classes哈希表中,以便下次能够快速访问。1
classes.put(class_name, cl); // 缓存已加载的类
根据上述过程我们可以使用如下代码利用 BCEL ClassLoader 动态加载字节码:
1 | package com.example; |
首先代码使用 Repository.lookupClass(Evil.class) 来查找 Eval 这个类得到 BCEL 管理 Java 类所用的类型 JavaClass,然后通过 javaClass.getBytes() 获取到对应的字节码。这里 Repository 是 BCEL 提供的类,它用于查找和管理 Java 类,并且能够将类转换为原生字节码。
提示
这里只要有要加载的类的字节码即可,因此我们可以按照前面的方法用 javassist 动态生成一个:
1 | String code = Utility.encode(getEvilClass("calc"), true); |
getEvilClass 函数可以动态生成类的字节码:
1 | public static byte[] getEvilClass(String cmd) throws Exception { |
之后通过 Utility.encode 将类的字节码编码非 BCEL ClassLoader 能够识别的编码形式,该函数定义如下:
1 | /** |
我们首先需要将 Java 字节码进行 GZIP 压缩,之后借助 JavaWriter 将字节编码写入 CharArrayWriter 然后再转换成字符串输出。其中 JavaWriter 的 write 方法会对字符进行转义操作:
1 | // 用作转义前缀的特殊字符;凡遇到不可直接写出的字节,先写一个 '$' 再写编码内容 |
最后我们需要在编码后的字节码前面加上 $$BCEL$$ 标识,这样 BCEL ClassLoader 就可以正确识别类的字节码并动态加载类。
SPI(Service Provider Interface)
JS 引擎加载字节码
Java 有很多种表达式,不同表达式有不同的语法特点:有些必须要 用链式反射去调用方法,有些可以直接 new;有些表达式只能执行一 句,有些可以执行多句。想要做到武器化利用就要选取一种通用的中间层语言,去延展我们的利用链。
从 Java 6 开始,Java 引入了 JSR-223(Scripting for the Java Platform) 规范。这个规范提供了 javax.script 包,允许 Java 程序在运行时调用和执行脚本语言。开发者可以用它加载不同的脚本引擎,例如 JavaScript、Groovy、Python(Jython)等,从而在 Java 应用中实现动态逻辑。
我们可以通过下面这段代码测试当前环境支持那些脚本引擎:
1 | public static void main(String[] args) { |
在这些脚本引擎中, JS 引擎非常符合我们的要求:
一行代码即可调用 JS 引擎,在 JS 引擎中可以执行多句。
1
2
3
4
5
6
7
8
9
10
11
12
13
14// EL:
${''.getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval()}
// SpEL:
T(javax.script.ScriptEngineManager).newInstance().getEngineByName("js").eval()
// Ognl:
(new javax.script.ScriptEngineManager()).getEngineByName("js").eval()
// MVEL:
new javax.script.ScriptEngineManager().getEngineByName("js").eval();
// JEXL:
''.getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval()JDK6-14 都可以使用,基本满足对兼容性的需要。
可以间接调用 Java 方法,实现任意代码执行。
由于 JS 引擎的限制,很多比如注入内存马、加载 Shellcode 等相关操作在 JS 引擎中实现较为麻烦,涉及到 Java 跟 JS 类型之间的转换,容易有 BUG,无法直接复用原有的很多基于字节码的 Payload。为了实现任意代码执行的完全体,还是需要做到任意字节码的加载。
JDK8 任意字节码加载
Java 的 JS 引擎可以调用 Java 本身的 API,因此我们可以通过 JS 引擎执行一段 JS 代码来反射调用 defineClass 方法来动态加载字节码。
1 | var classBytes = Base64DecodeToByte(Classdata); |
然后借助 ScriptEngineManager 执行这段 JS 代码实现任意字节码加载:
1 | public static byte[] getEvilClass(String cmd) throws Exception { |
JDK6/7 Rhino下调用defineClass
JDK6/7 开始引入 JS 引擎,采用 Rhino 实现,不支持 Java.type 等方便的接口获取 Java 类型的操作,因此上述 Payload 在反射调用 ClassLoader.defineClass 会有玄学报错。
解决方法是使用 Unsafe 类下的 defineClass 绕过, BCELClassLoader 也可以,不过 Payload 跟后续的不通用。
1 | var classBytes = Base64DecodeToByte(Classdata); |
JDK9/10/11 绕过模块隔离强行反射方法
JDK11移除了 Unsafe.defineClass 方法,因此上述代码会出现如下报错:
TypeError: unsafe.defineClass is not a function in at line number 27
而 JDK9 引入了的模块化系统,因为 java.base 模块并没有开放 java.lang 包给 jdk.scripting.nashorn.scripts 模块因此如果使用 defineClass 加载字节码则会遇到无法强行对 defineClass 方法 setAccessible(true)。
Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(byte[],int,int) throws java.lang.ClassFormatError accessible: module java.base does not “opens java.lang” to module jdk.scripting.nashorn.scripts
java.lang.reflect.AccessibleObject#checkCanSet 的 Accessible 会通过 Modifier 判断权限修饰符,通过 Unsafe 类强行将其变为 public 方法可以绕过上述问题。
1 | var Unsafe = Java.type("sun.misc.Unsafe"); |
JDK12/13/14绕过fieldFilterMap
JDK>=12 报错提示:没有 modifiers 字段
Caused by: java.lang.NoSuchFieldException: modifiers at java.base/java.lang.Class.getDeclaredField(Class.java:2416)
核心原因在于 jdk.internal.reflect.Reflection#fieldFilterMap 的变化。
1 | fieldFilterMap = Map.of( |
置空 fieldFilterMap
我们可以通过反射置空 fieldFilterMap 绕过上述限制:
1 | function bypass() { |
完整代码如下:
1 | var theUnsafeMethod = java.lang.Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe"); |
使用 Unsafe#defineAnonymousClass 代替
如果仅仅是为了defineClass,不需要绕过 JDK 机制那么麻烦, 别忘了 Unsafe#defineAnonymousClass 是没有被移除的。
1 | function defineClass(classBytes) { |
反射
Java 反射(Reflection)机制是 Java 提供的一种非常强大的功能,它允许程序在运行时 动态地获取类的信息,并能够 操作类的方法、字段、构造器等成员。反射机制提供了对类、方法、字段等元数据的访问,而不需要在编译时提前知道这些信息。
反射机制的核心是 java.lang.reflect 包 和 java.lang.Class 类。
JVM 为每个加载的类创建了对应的 java.lang.Class 实例,并在该实例中保存了类的所有信息。因此,如果我们获取了某个类的 Class 实例,我们就可以通过这个 Class 实例获取到该实例对应 java.lang.reflect 包提供的一系列类的实例。通过获取到的这些实例,我们可以获取类的信息(如构造方法、字段、方法等),并能 操作对象的字段 或 调用方法。
Method:表示类中的方法,允许程序获取、调用类的方法。Field:表示类的字段,允许程序访问、修改对象的字段(包括私有字段)。Constructor:表示类的构造器,允许动态创建类的实例。
获取 Class 实例
每个 Java 类在运行时都有一个唯一的 Class 对象,该对象包含了该类的所有信息。反射的前提就是先要获取到这个 Class 对象。通常我们有三种获取方式这个 Class 对象。
通过类名获取
可以借助 Class 类的 forName 方法来获取 Class 实例,参数是目标类的全限定类名。
1 | Class<?> clazz = Class.forName("com.example.MyClass"); |
在 Java 中,类名(Class Name)指的是用于标识类的名称。
Java 中的类名可能有多个相同的类名,这些类名可能位于不同的包中。为了区分它们,我们需要使用 全限定类名,即包含包名的类名。
例如:
java.util.ArrayList,这里java.util是包名,ArrayList是类名。整个字符串java.util.ArrayList就是该类的全限定类名。由于
Class实例存放着类的所有信息,因此我们可以通过Class的getName方法获取一个类的全限定名称。
1 System.out.println(MyClass.class.getName());
通过对象获取
1 | MyClass obj = new MyClass(); |
通过类获取
1 | Class<?> clazz = MyClass.class; |
操作字段
获取字段
反射提供了两种方法来获取字段信息:
getField(String name):获取公共字段(包括父类中的公共字段)。如果字段是私有的或保护的,它将抛出NoSuchFieldException异常。1
public Field getField(String name) throws NoSuchFieldException
getDeclaredField(String name):获取当前类声明的字段(包括私有字段,但不包括继承的字段)。如果字段不存在,则抛出NoSuchFieldException异常。1
public Field getDeclaredField(String name) throws NoSuchFieldException
另外对于获取到的私有字段,反射提供了 setAccessible(boolean flag) 方法来允许对私有字段的访问。
1 | public void setAccessible(boolean flag) throws SecurityException |
其中参数 flag:
- 如果为
true,则表示允许访问字段(即使该字段是private、protected或包内字段)。 - 如果为
false,则恢复字段的访问控制。
在 getDeclaredField 的基础上我们还可以循环遍历当前类的父类从而确保继承的字段也能获取到:
1 | // 逐级获取字段,使用 while 循环并实现 getDeclaredField |
反射还提供了 getFields() 和 getDeclaredFields() 方法来获取类的所有字段:
getFields():返回当前类及其父类中所有公共字段。1
public Field[] getFields() throws SecurityException
getDeclaredField:返回当前类中所有声明的字段(包括私有字段)。1
public Field[] getDeclaredFields() throws SecurityException
获取字段值
获取字段值是 Java 反射机制中的一个常见操作。我们可以使用 Field 对象的 get(Object obj) 方法可以来获取字段的值。
1 | public Object get(Object obj) throws IllegalArgumentException, IllegalAccessException |
注意
同名字段在子类里只会“遮蔽”父类字段;拿哪个 Field,就只访问哪个类型里声明的那一个。
1 | class A { public int x = 1; } |
- 实例字段:
field.get(obj)要求obj是 declaringClass 的实例/子类。 - 静态字段:
obj参数被忽略,可传null。
我们通常把获取字段值的操作封装成 getFieldValue 函数方便使用:
1 | public static Object getFieldValue(Object object, String fieldName) throws Exception { |
由于字段可能是从父类中继承的,因此我们可以使用前面实现的 getDeclaredField 函数来获取字段。
1 | public static Object getFieldValue(Object object, String fieldName) throws Exception { |
设置字段值
同样的,使用 Field 对象的 set(Object obj, Object value) 方法可以设置字段的值。
1 | public void set(Object obj, Object value) throws IllegalArgumentException, IllegalAccessException |
我们通常把获取字段值的操作封装成 setFieldValue 函数方便使用:
1 | public static void setFieldValue(Object object, String fieldName, Object value) throws Exception { |
如果修改的字段可能是从父类中继承的,则使用前面实现的 getDeclaredField 函数来获取字段。
1 | public static void setFieldValue(Object object, String fieldName, Object value) throws Exception { |
修改 final 变量
如果是 final 修饰的成员,即使通过反射也无法直接修改,这是由于 Field 对象的 modifiers 成员的 FINAL 位置位导致的。
Field 对象的 modifiers 成员用于表示字段的访问修饰符(如 public、private、static、final 等)。这个成员变量是一个整数值,表示该字段的修饰符组合。我们可以先通过反射修改 Field 对象的 modifiers 成员,然后再反射修改成员变量。
1 | public static void setFieldValue(Object object, String fieldName, Object value) throws Exception { |
注意
我们在验证常量是否修改时不能直接通过属性访问获取常量的值。因为 Java 编译器在编译的时候会认为常量不可修改,导致直接通过属性访问获取常量值的操作的过程会被优化掉,获取的结果还是原来的值。
例如下面的测试代码中:
1 | FinalTest test = new FinalTest(); |
System.out.println(test.secret); 的 test.secret 会被优化成 secret 原本的值造成常量未被修改的假象。
1 | LDC "Y0U_C4nNot_M0d1fy_M3" |
操作方法
获取方法
反射提供了两种方法来获取类的方法信息:
getMethod(String name, Class<?>... parameterTypes):获取 公共方法(包括从父类继承的公共方法)。如果方法不存在或者不是public,会抛出NoSuchMethodException。1
public Method getMethod(String name, Class<?>... parameterTypes) throws NoSuchMethodException
name:方法名。parameterTypes:方法参数类型列表。如果方法没有参数,可以传递空数组或省略。
getDeclaredMethod(String name, Class<?>... parameterTypes):获取 当前类声明的方法(包括private和protected方法,但不包括继承的方法)。如果方法不存在,则抛出NoSuchMethodException。1
public Method getDeclaredMethod(String name, Class<?>... parameterTypes) throws NoSuchMethodException
同样的,我们可以实现一个可以获取从父类继承的方法的 getDeclaredMethod 函数。
1 | public static Method getDeclaredMethod(Class<?> clazz, String methodName, Class<?>... parameterTypes) { |
反射还提供了 getMethods() 和 getDeclaredMethods() 方法来获取类的所有方法:
getMethods():返回当前类及其父类中所有公共方法。1
public Method[] getMethods() throws SecurityException
getDeclaredMethods():返回当前类中所有声明的方法(包括私有方法)。1
public Method[] getDeclaredMethods() throws SecurityException
调用方法
使用 Method 对象的 invoke(Object obj, Object... args) 方法可以调用类的方法。
1 | public Object invoke(Object obj, Object... args) throws IllegalAccessException, IllegalArgumentException, InvocationTargetException |
obj是调用方法的对象,如果方法是static,需要传入null占位。args是方法的参数。
对于连续调用的情况,由于反射调用时对象是作为参数传入的,因此方法的书写顺序发生改变。

注意
通过反射获取的方法如果因可见性规则无法直接调用,必须在调用前先执行
setAccessible(true),否则会抛出IllegalAccessException;Method绑定的是“声明它的类型”而不是具体实例。只要目标对象是该类型或其子类/实现类,用这个Method#invoke调就行;真正执行哪个方法体取决于目标对象的实际运行时类型。1
2
3
4
5
6
7
8
9
10
11
12interface I { void f(); }
class A implements I { public void f(){ System.out.println("A"); } }
class B extends A { public void f(){ System.out.println("B"); } }
I x = new B();
Method mI = I.class.getMethod("f"); // 声明者:I
Method mA = A.class.getMethod("f"); // 声明者:A
mI.invoke(x); // 输出 B ✅(接口 Method 调到 B.f)
mA.invoke(x); // 仍然输出 B ✅(A 的 Method 也调到 B.f)
System.out.println(mI.equals(mA)); // false(声明者不同)- 归属:
method.getDeclaringClass()指出它在哪个类型上声明。
I.class.getMethod("f")和C.class.getMethod("f")是两个不同的Method(声明者不同)。 - 可用范围(实例方法):
invoke(target, …)要求target是declaringClass的实例/子类;若declaringClass是接口,则target实现了该接口即可。 - 实际执行体:无论你拿的是接口上的
Method还是实现类上的Method,最终执行都按目标对象的实际类型来决定(覆盖 → 调子类实现)。
- 归属:
操作构造函数
获取构造函数
Java 反射提供了两种方法来获取类的构造函数:
getConstructor(Class<?>... parameterTypes):获取 公共构造函数,如果当前类没有公共构造函数则尝试获取父类中的公共构造函数。如果找不到公共构造函数则会抛出NoSuchMethodException异常。1
public Constructor<T> getConstructor(Class<?>... parameterTypes) throws NoSuchMethodException
parameterTypes:构造函数参数类型列表。如果构造函数没有参数,可以传递空数组或省略。
getDeclaredConstructor(Class<?>... parameterTypes):获取 当前类声明的构造函数(包括private和protected构造函数,但不包括继承的构造函数)。如果构造函数不存在,则抛出NoSuchMethodException。1
public Constructor<T> getDeclaredConstructor(Class<?>... parameterTypes) throws NoSuchMethodException
注意
在 Java 里,一个类可以定义多个重载的构造器;只有当这个类完全没有自己声明任何构造器时,编译器才会隐式生成一个无参构造器,它的可见性与类一致,并且默认第一句是调用 super()。
一旦你在类里声明了任意构造器(哪怕只有一个带参数的),这个隐式的无参构造器就不会再生成;如果你仍然希望能写 new Example(),就需要显式补上 Example(){}。
在反射层面,只有当类里真的存在无参构造器时,Example.class.getDeclaredConstructor() 才会成功;如果类里没有无参构造器(例如只声明了带参的那个),调用就会抛 NoSuchMethodException。
另外要留意一些特殊形态:非静态内部类在字节码上有隐藏的“外部类实例”参数,enum 和大多数 record 也没有真正的无参构造器,因此这些场景都不要依赖“默认无参构造器”的假设。
非静态内部类每个实例都必须隶属于一个外部类实例,因此它的构造器在字节码里会额外带上一个“外部类实例”的隐藏参数。写代码时你看见的是 Inner(),但反射看到的其实是类似 (Outer) 的构造器,所以用空参数拿不到,必须带上 Outer.class 才行,例如:
1 | class Outer { |
带一提,静态内部类(static class)不带外部实例参数,它的构造器就跟普通顶层类一样;是否有无参仍然取决于你有没有自己声明过其它构造器。
枚举就算你源码里写了 Color(){},编译器也会在构造器里偷偷加上 (String name, int ordinal) 这两个参数来记录常量名和序号,所以反射层面它并不是“无参”。你会看到空参拿不到,而能看到一个形如 (String, int) 的构造器:
1 | enum Color { RED; Color() {} } |
record 的“正则构造器”参数就是它的所有组件;比如 record Point(int x, int y) {} 的构造器就是 (int, int),自然不是无参,只有“零组件”的记录类 record Empty(){} 才会是无参:
1 | record Point(int x, int y) {} |
有些情况下,我们想要获取的构造函数位于父类中且访问类型不为 public,此时需要参考前面的方法使用逐级查找父类的方式来获取:
1 | public static Constructor<?> getDeclaredConstructor(Class<?> clazz, Class<?>... parameterTypes) { |
反射还提供了 getConstructors() 和 getDeclaredConstructors() 方法来获取类的所有构造函数:
getConstructors():返回当前类及其父类中所有公共构造函数。1
public Constructor<?>[] getConstructors() throws SecurityException
getDeclaredConstructors():返回当前类中所有声明的构造函数(包括私有构造函数)。1
public Constructor<?>[] getDeclaredConstructors() throws SecurityException
创建对象
首先 Class 对象本身的 newInstance 方法可以调用类的无参构造函数创建类的实例。
1 | public T newInstance() throws InstantiationException, IllegalAccessException; |
不过这个方法只能调用无参构造函数,且私有的构造函数无法调用,因此比较鸡肋。
另外从 Java 9 开始,Class.newInstance() 方法已经被标记为废弃(deprecated)。推荐使用 Constructor.newInstance() 代替它。
我们可以先获取类的指定构造函数对应的 Constructor 对象,然后调用 Constructor 对象的 newInstance(Object... initargs) 方法从而调用类的构造函数实例化类。
1 | public T newInstance(Object... initargs) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException |
例如:
1 | // 无参构造 |
Unsafe 类
sun.misc.Unsafe 是 Java 中一个极为强大且危险的类。它提供了对底层内存和线程操作的直接访问能力,允许开发者绕过 Java 的安全机制、访问底层内存、进行 CAS 操作、创建对象、操作字段等。这种能力虽然强大,但使用不当极容易导致内存泄漏、程序崩溃、数据不一致,甚至破坏 JVM 的稳定性。
提示
Unsafe 类主要是通过 JVM 内部的 native(本地方法) 来实现的上述功能,并没有用到反射技术。但是这个类在操作类的方面与反射功能上有重合之处,并且可以帮助反射绕过一些高版本 JDK 的限制,因此放在这里介绍并且只介绍与字段操作有关的内容。
获取 Unsafe 实例
Unsafe 的构造器是私有的,且 theUnsafe 字段是私有的,需要通过反射获取。
1 | import sun.misc.Unsafe; |
注意
从 Java 9 开始,Unsafe 受到更严格的模块限制,需添加启动参数:
1 | --add-opens java.base/sun.misc=ALL-UNNAMED |
获取字段的偏移量
由于 Unsafe 是在内存层面对象的字段,因此 Unsafe 在操作指定字段之前需要先获取字段的偏移量。Unsafe 提供了两种方法来分别获取获取普通对象和静态字段的偏移量:
objectFieldOffset(Field field):用于获取普通对象字段的偏移量。staticFieldOffset(Field field):用于获取静态字段的偏移量。
1 | // 获取 Unsafe 实例 |
获取字段的内存偏移量需要我们反射获取字段对应 Field 对象,这也意味着如果我们能够获取到字段对应的 Field 对象那么我们就可以绕过反射的所有限制。因此高版本反射绕过本质上就是围绕如何获取 Field 对象展开的。
由于我们获取 Field 对象本身是为了获取字段的内存偏移量,因此其中一个绕过方法就是通过用 Unsafe#defineAnonymousClass(host, bytes, null),喂入原始字节码得到一个 匿名副本类。它和原类的字段布局完全一致(字节码一样 → 静态字段位置相同),但不是黑名单里的本体,因此可以自由反射获取字段。
1 | Class<?> reflectionClass = Class.forName("jdk.internal.reflect.Reflection"); |
虽然获取的字段不是原始类的字段,但是我们可以通过这个字段获取到字段的正确偏移,从而通过 Unsafe 修改原始类中对应的字段。
1 | long offset = unsafe.staticFieldOffset(fieldFilterMap); |
提示
这种通过 Unsafe#defineAnonymousClass 创建匿名副本类的方法在高版本 JDK 失效。
- 虽然 JDK11 把
Unsafe#defineClass移除了,但Unsafe#defineAnonymousClass还在。 Unsafe#defineAnonymousClass在后续的 JDK 版本中被逐步移除:- JDK 15 :
defineAnonymousClass方法被弃用,并标记为将在未来版本中移除。 - JDK 16 :该方法被进一步标记为“将来移除”。
- JDK 17 :
defineAnonymousClass方法被正式移除。
- JDK 15 :
另外如果我们能获取到字段对应的 Field 对象,那么我们同样也可以借助修改 final 变量的思路达到相同效果。
对象字段的读写
Unsafe 提供了多种字段操作方法,涵盖所有 Java 基础类型和对象类型:
| 方法 | 说明 |
|---|---|
getObject(Object obj, long offset) |
读取对象类型字段 |
getInt(Object obj, long offset) |
读取 int 类型字段 |
getLong(Object obj, long offset) |
读取 long 类型字段 |
getDouble(Object obj, long offset) |
读取 double 类型字段 |
getFloat(Object obj, long offset) |
读取 float 类型字段 |
getBoolean(Object obj, long offset) |
读取 boolean 类型字段 |
getByte(Object obj, long offset) |
读取 byte 类型字段 |
getShort(Object obj, long offset) |
读取 short 类型字段 |
getChar(Object obj, long offset) |
读取 char 类型字段 |
putObject(Object obj, long offset, Object value) |
写入对象类型字段 |
putInt(Object obj, long offset, int value) |
写入 int 类型字段 |
putLong(Object obj, long offset, long value) |
写入 long 类型字段 |
putDouble(Object obj, long offset, double value) |
写入 double 类型字段 |
putFloat(Object obj, long offset, float value) |
写入 float 类型字段 |
putBoolean(Object obj, long offset, boolean value) |
写入 boolean 类型字段 |
putByte(Object obj, long offset, byte value) |
写入 byte 类型字段 |
putShort(Object obj, long offset, short value) |
写入 short 类型字段 |
putChar(Object obj, long offset, char value) |
写入 char 类型字段 |
这些方法的使用示例如下:
1 | unsafe.putInt(user, offset, 30); |
在读写字段的时候我们需要向方法中传入字段所在的对象引用。对于于静态字段,静态字段存储在类的元数据区域(即方法区或元空间)中,而不是某个对象实例中,但 Unsafe 操作时,仍然要求传入“对象引用”,这个引用其实是类的内部静态基地址。这个地址可以通过 Unsafe.staticFieldBase(Field field) 方法获取。
1 | Object staticBase = unsafe.staticFieldBase(field); |
对于数组类型的对象,如果我们要修改其中的成员还需要先调用 arrayBaseOffset 和 arrayIndexScale 方法分别获取数组起始的偏移量和元素大小。然后根据这些信息计算要修改的数组成员的偏移量。
1 | int[] data = {10, 20, 30}; |
高版本 JDK 反射绕过
高版本 JDK 对于反射的限制主要有以下两个方面:
- 限制
setAccessible导致无法操作获取的被反射对象的成员。 - 过滤
getDeclaredFields获取的字段导致部分字段无法通过反射获取。
反射的限制以及绕过方法之间的关系如下:
getDeclaredMethod 获取 getDeclaredFields0 → setAccessible(true)()
9≤JDK<12 (setAccessible)
JDK9 开始针对 checkCanSetAccessible 出现一些限制。
模块化系统
从 JDK9 开始 Java 引入了模块化系统(Project Jigsaw)的概念。
模块化系统(Project Jigsaw)是 Java 平台的重要特性之一,旨在改善大型 Java 应用程序的可维护性、可扩展性、性能和安全性。模块化系统将 JDK 本身分解为多个模块,并为开发者提供了一种新的方式来组织、打包和管理 Java 应用。
模块是通过 module-info.java 文件进行声明的,它包含以下几个主要内容:
- 模块名称 :模块的唯一标识。
- 导出包(exports) :指定哪些包对外部可见。只有通过
exports声明的包才能被其他模块访问。 - 需要的模块(requires) :声明当前模块依赖的其他模块。如果当前模块依赖某个模块,它就可以访问该模块公开的
exports包。 - 开放包(opens) :与
exports类似,但允许通过反射机制访问包中的类和成员。
module-info.java 示例:
1 | module com.example.myapp { |
模块声明一般位于每个模块的根目录下,并且必须命名为 module-info.java。
模块化系统这一机制在一定程度上限制了我们反射操作对象的属性。
限制条件分析
我们在 JDK9 版本通过反射调用 java.lang.Runtime 的私有构造函数时依然可以成功调用,但是会出现如下警告:
1 | WARNING: An illegal reflective access operation has occurred |
因为 JDK 9 引入 JPMS(模块化系统)后,出于“平滑迁移”的考虑,默认启用了“放宽的强封装”:
启动器选项--illegal-access的默认值是 **permit**。在这个模式下:
- 对“类路径”(unnamed module)上的代码,JVM 会把 JDK 8 中就存在的所有包(例如
java.base/java.lang)临时“打开”给深反射使用,所以你对java.lang.Runtime的私有构造器仍然能成功调用;- 但这是非法的深反射,因此只在第一次触发时打印一组警告(你看到的那几行),并提示可以用
--illegal-access=warn对每一次非法访问都报警。JEP 261 对该行为写得很清楚:
--illegal-access=permit(JDK 9 默认)= 允许类路径代码对 JDK 8 时代的包做深反射,并只打印一次总警告;--illegal-access=warn/debug= 每次都警告(debug还带堆栈);--illegal-access=deny= 禁用所有非法访问(除非你显式用--add-opens放开)。这也解释了“为什么能成功但有警告”:成功是因为 JDK 9 的默认宽松策略在“给你开小灶”,警告是在提醒你这种访问将来会被拒绝。
然而通过 JS 引擎动态加载字节码则会有如下报错,这个报错是因为 java.base 模块并没有开放 java.lang 包给 jdk.scripting.nashorn.scripts 模块。也就是说 JDK9 引入了的模块化系统阻止了这一操作。
1 | Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(byte[],int,int) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to module jdk.scripting.nashorn.scripts |
具体分析报错信息可以看到,抛出 InaccessibleObjectException 异常的位置是 java.lang.reflect.AccessibleObject 的 checkCanSetAccessible 函数。并且触发异常的用户代码是 setAccessible 方法的调用。
因为
--illegal-access=permit的“迁移豁免”只对“类路径上的代码”(也就是Unnamed Module)生效。而 Nashorn 把脚本类放进了名为jdk.scripting.nashorn.scripts的动态“命名”模块 ⇒ 不在豁免范围内 ⇒ 被 JPMS 的强封装直接拒绝。
Method/Constructor(经 Executable)与 Field 都继承自 AccessibleObject。当我们试图通过 setAccessible(true) 或 trySetAccessible() 放开访问限制时,JDK 会调用 AccessibleObject#checkCanSetAccessible 按模块导出/开放策略做权限判定;判定通过才允许后续的反射调用或取值。

在 setAccessible(true) 时,JDK 会调用 checkCanSetAccessible 来判定调用者是否被允许为该反射对象打开越权访问:
caller:由Reflection.getCallerClass()得到的调用setAccessible的类。declaringClass:声明该成员的类(Field/Method/Constructor的getDeclaringClass())。注意它可能是父类而非你当前拿到的运行时类。1
2
3
4
5
6
7
8
9/**
* 返回声明了此方法的类或接口对应的 Class 对象。
* 换言之,即使你是从子类上拿到这个 Method,只要该方法是从父类或接口继承而来,
* 这里返回的仍然是最初声明该方法的那个类或接口的 Class。
*/
public Class<?> getDeclaringClass() {
return clazz; // 持有的“声明者”Class 引用
}判定依据包括安全管理器权限(如启用)与 JPMS 模块封装/
opens/--add-opens规则;不满足将抛InaccessibleObjectException。
1 | /** |
最终的核心函数 AccessibleObject#checkCanSetAccessible 用于检测成员是否可以被设置为可访问。
1 | /** |
总的来说,调用者为了能够获取被反射类的属性访问权限,必须满足以下至少一个条件:
同一模块
caller与declaringClass属于同一个模块。调用者在
java.base模块
即caller.getModule() == Object.class.getModule()(JDK 自身代码的“可信调用者”)。目标类处于未命名模块
declaringClass位于未命名模块(classpath 上的类),为历史兼容而放行。未命名模块就是:在 classpath 上加载的代码所在的“默认模块”。只要你没用
module-info.java、也没走--module-path,你的类就属于某个类加载器的未命名模块(clazz.getModule().isNamed() == false)。已导出(exports)+ 公共 / 受保护静态成员
同时满足:declaringClass是 public;declaringClass所在包 已 exports 给 caller 模块;- 且成员满足其一:
- 成员是 public;
- 成员是 protected 且 static,并且
caller是declaringClass的子类。
说明:此分支对应“公开访问路径”,本就不需要“深反射”(open)。对 public 成员,
setAccessible(true)通常是冗余的,但检查仍会放行。已开放(opens)
declaringClass所在包对 caller 模块已开放(满足任意一条即可),于是可走深反射路径(setAccessible(true)可放行):- 模块级全开放:
open module <M> { ... }(模块内所有包对所有模块开放)。 - 包级无条件开放:
opens <pkg>;(该包对所有模块开放)。 - 包级定向开放:
opens <pkg> to <callerModule>;(该包只对指定模块开放)。 - 自动模块(automatic module):JAR 放在 module path、无
module-info,其所有包视为 open。 - 命令行追加开放:
--add-opens <module>/<pkg>=<callerModule>或=ALL-UNNAMED(给 classpath 代码开口)。 - 运行时追加开放:
Module::addOpens(...)或 agent 的Instrumentation#redefineModules(...)动态追加。 - (仅 JDK 9–15 默认)迁移期自动开放:
--illegal-access=permit下,JDK8 已存在的 JDK 包(如java.base/java.lang)会临时 open 给 ALL-UNNAMED;caller 在 classpath 时isOpen(...)判真并打印一次性告警。
(JDK 16 默认改为强封装,JDK 17 移除该选项。)
说明:此分支对应“深反射路径”,允许越权访问非 public 成员或绕过语言级检查。
- 模块级全开放:
本判定适用于 **Field / Method / Constructor**(它们同属 AccessibleObject),其中:
**
caller**:直接调用setAccessible/trySetAccessible的类(由Reflection.getCallerClass()得到)。declaringClass:声明该成员的类(getDeclaringClass()),可能是父类/接口。
提示
1 | // 5) 若目标包对 callerModule 做了 opens(允许深反射访问所有成员)→ 放行 |
最后一个条件(isOpen)能放行深反射,但只有当包已显式开放或在 JDK 9–15 的兼容模式下被临时开放时才成立。
- 前者是合法的深反射,不会产生‘Illegal reflective access’警告;
- 后者才会触发
logIfOpenedForIllegalAccess的一次性(或多次)警告。
自 JDK 16/17 起,这种兼容期的自动开放被关闭/失效,必须使用 opens/--add-opens 才能通过该条件。”
绕过方法
修改访问属性
在分析出过滤条件之后,我们只需要设法满足上述条件即可绕过。通常来说我们都是通过修改被反射对象来满足 checkCanSetAccessible 的条件。
还是以 JS 引擎动态加载字节码为例,这一过程中被反射类 ClassLoader 的访问属性 public,但是 defineClass 方法为私有方法因此不满足条件。
我们可以通过反射修改 defineClass 方法对应 Method 的 modifiers 属性使得 defineClass 方法的访问修饰符为 public 来实现绕过。修改 modifiers 的过程既可以在 Java 代码中也可以在 JS 代码中。
1 | public static String getJsPayload(String code) throws Exception { |
12≤JDK<17 (getDeclaredField)
JDK12 起针对 getDeclaredField 的限制增多,一些字段对应的 Field 对象无法获取。
限制条件分析
jdk.internal.reflect.Reflection 中的 fieldFilterMap 用于存储需要被过滤的 Field,所有添加到这个 Map 的字段都不能通过反射获取。
在 JDK11 中,这个字段仅对很少一部分类的个别字段做了限制:
1 | /** |
但在 JDK12 中,限制变多了,其中 Field 的所有成员成员都被限制为不可通过反射获取。
1 | /** |
这部分修改对应的 issue:
问题:
java.lang.reflect和java.lang.invoke包中的许多类都包含私有字段,如果直接访问这些字段,可能会破坏运行时或导致虚拟机崩溃。理想情况下,java.base中所有非公共/非受保护的字段都应该通过核心反射进行过滤,并且不能通过UnsafeAPI 进行读写,但目前我们离这个目标还有很大距离。与此同时,现有的过滤机制暂时充当了一个应急补救措施。解决方案:
扩展过滤器,涵盖以下类中的所有字段:
java.lang.ClassLoaderjava.lang.reflect.AccessibleObjectjava.lang.reflect.Constructorjava.lang.reflect.Fieldjava.lang.reflect.Method- 以及
java.lang.invoke.MethodHandles.Lookup中用于查找类和访问模式的私有字段。
提示
其实从上面的分析来看实际上在 JDK12 之前 Java 就对获取字段进行了限制。只不过从 JDK12 开始由于这个限制的加强,导致一些关键操作失败,由此衍生出一些绕过方法。因此这里把 JDK12 作为一个阶段的起点。
例如从 JDK 12 起,在修改 final 类型的变量的过程中,调用 getDeclaredField 函数获取Field 对象的 modifiers 成员时由于 Field 对象的所有成员都被过滤导致无法找到:
1 | Exception in thread "main" java.lang.NoSuchFieldException: modifiers |
getDeclaredField 函数实际会通过调用 privateGetDeclaredFields 函数来获取所有字段,在 privateGetDeclaredFields 函数中:
- 首先会调用
getDeclaredFields0从 JVM 中获取调用getDeclaredField的clazz所表示的类的所有字段。 - 之后调用
Reflection.filterFields函数利用fieldFilterMap把这些字段过滤一遍,仅留下允许的字段。
1 | /** |
其中 filterFields 函数实际上就是暴力枚举获取到的所有字段 members 以及需要过滤的字段名称 filteredNames 得到过滤后的字段数组。
1 | /** |
绕过方法
调用 getDeclaredFields0 函数
由于过滤是基于 getDeclaredFields0 的结果进行的,而 getDeclaredFields0 本体在反射调用中并不会被限制(因为只有字段被限制获取,而方法没有被限制获取):
1 | private native Field[] getDeclaredFields0(boolean publicOnly); |
因此我们可以通过反射调用 getDeclaredFields0 获取字段,由此实现的 getDeclaredField 函数如下:
1 | /** |
提示
JDK17 和高版本的 JDK16 的模块化系统的限制增强,导致上述方法中的 getDeclaredFields0.setAccessible(true); 会出现如下报错:
1 | Exception in thread "main" java.lang.RuntimeException: java.lang.reflect.InaccessibleObjectException: Unable to make private native java.lang.reflect.Field[] java.lang.Class.getDeclaredFields0(boolean) accessible: module java.base does not "opens java.lang" to unnamed module @3d075dc0 |
置空 fieldFilterMap
另一种绕过方法是直接置空 fieldFilterMap,具体过程为:
读取
Reflection.class字节码并创建匿名类。1
2
3Class<?> reflectionClass = Class.forName("jdk.internal.reflect.Reflection");
byte[] classBuffer = reflectionClass.getResourceAsStream("Reflection.class").readAllBytes();
Class<?> reflectionAnonymousClass = unsafe.defineAnonymousClass(reflectionClass, classBuffer, null);因为
fieldFilterMap过滤了Reflection的所有成员,无法直接使用getDeclaredField获取Field。因此这里通过unsafe.defineAnonymousClass()方法,基于原始Reflection类字节码,动态创建一个匿名类,从而可以由此匿名类来获取类成员fieldFilterMap。获取
fieldFilterMap字段并置空。1
2
3
4Field fieldFilterMap = reflectionAnonymousClass.getDeclaredField("fieldFilterMap");
if (fieldFilterMap.getType().isAssignableFrom(HashMap.class)) {
unsafe.putObject(reflectionClass, unsafe.staticFieldOffset(fieldFilterMap), new HashMap<>());
}匿名类不受
fieldFilterMap过滤,因此可以成功获取到原本无法访问的fieldFilterMap字段。之后检查字段类型是否为HashMap,确保类型正确,并使用unsafe.putObject()将Reflection类中的静态字段fieldFilterMap替换为 新的空HashMap。这意味着反射 API 将不再过滤任何字段,从而绕过 Java 的反射安全机制。清除
Class类的反射缓存。JVM 在第一次反射时,会将反射信息缓存到
reflectionData中。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18ReflectionData<T> rd = reflectionData();
// [...]
// B) 缓存未命中:调用 VM 层获取原始字段数组,然后应用“敏感成员过滤”
// Reflection.filterFields 会基于内部黑名单(fieldFilterMap)剔除不应暴露的字段
res = Reflection.filterFields(this, getDeclaredFields0(publicOnly));
// C) 回写缓存:分别缓存“全部声明字段”和“仅 public 声明字段”的结果
if (rd != null) {
if (publicOnly) {
rd.declaredPublicFields = res;
} else {
rd.declaredFields = res;
}
}
return res;如果不清除缓存,
fieldFilterMap的修改不会立即生效,反射仍然会受到旧的限制1
2
3
4
5
6// A) 尝试从反射数据缓存读取(ReflectionData 由 Class 维护)
ReflectionData<T> rd = reflectionData();
if (rd != null) {
res = publicOnly ? rd.declaredPublicFields : rd.declaredFields;
if (res != null) return res; // 命中缓存
}通过分析
ReflectionData相关代码可以发现,reflectionData函数本质上返回的是Class中用于缓存反射信息的ReflectionData字段。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77/**
* 反射支持(为 Class 的反射结果做缓存及失效控制)。
*
* 设计要点:
* 1) 通过 SoftReference 缓存反射派生数据(字段/方法/构造器/接口等),在内存紧张时允许被 GC 回收;
* 2) 通过 classRedefinedCount 与 JVMTI 的 RedefineClasses() 钩子联动,当类(或其父类)被重定义时
* 使缓存失效,从而保证反射结果与最新类结构一致;
* 3) 所有关键字段使用 volatile,保证并发场景下的可见性;transient 避免将缓存随对象序列化。
*/
// 是否启用反射结果缓存的开关;可在极端调试/诊断场景关闭以每次都走真实计算。
private static boolean useCaches = true;
/**
* 每个 Class<T> 对应的一组反射缓存数据。
* 注意:当 JVM TI 的 RedefineClasses() 重定义该类或其父类时,这些数据会过期。
*/
private static class ReflectionData<T> {
// 仅本类“声明”的字段(不含父类)
volatile Field[] declaredFields;
// 本类“public 字段” + 从父类继承下来的 public 字段
volatile Field[] publicFields;
// 仅本类“声明”的方法(不含父类)
volatile Method[] declaredMethods;
// 本类“public 方法” + 从父类继承下来的 public 方法
volatile Method[] publicMethods;
// 仅本类“声明”的构造器(所有可见性)
volatile Constructor<T>[] declaredConstructors;
// 本类的 public 构造器
volatile Constructor<T>[] publicConstructors;
// getFields/getMethods 计算过程中的中间产物(声明 + 过滤 public 后的版本)
volatile Field[] declaredPublicFields;
volatile Method[] declaredPublicMethods;
// 本类直接实现的接口(不含父类实现的)
volatile Class<?>[] interfaces;
// 创建本 ReflectionData 时所记录的“类被重定义次数”。
// 若后续 classRedefinedCount 改变,说明类结构已变,当前缓存即过期。
final int redefinedCount;
ReflectionData(int redefinedCount) {
this.redefinedCount = redefinedCount;
}
}
// 软引用包装的缓存容器:在内存吃紧时可被 GC 清理;
// volatile 确保并发读写的可见性;transient 防止序列化带出缓存内容。
private volatile transient SoftReference<ReflectionData<T>> reflectionData;
// 当该类或其父类被 JVMTI 的 RedefineClasses() 重定义时,JVM 会递增该计数。
// volatile 保证并发下读取到最新值。
private volatile transient int classRedefinedCount = 0;
/**
* 懒加载并返回当前有效的 ReflectionData:
* - 若缓存存在、未被 GC 回收、且与当前重定义计数相等 → 直接复用;
* - 否则创建新的 ReflectionData 并替换(newReflectionData 内部完成写回)。
*/
private ReflectionData<T> reflectionData() {
SoftReference<ReflectionData<T>> reflectionData = this.reflectionData;
int classRedefinedCount = this.classRedefinedCount;
ReflectionData<T> rd;
if (useCaches &&
reflectionData != null && // 有软引用包装
(rd = reflectionData.get()) != null && // 软引用尚未被 GC 清理
rd.redefinedCount == classRedefinedCount) { // 与当前重定义计数一致 → 缓存有效
return rd;
}
// 没有软引用 / 软引用已被清理 / 计数不一致(缓存过期)
// → 创建并替换新的 ReflectionData(具体替换逻辑由 newReflectionData 实现)
return newReflectionData(reflectionData, classRedefinedCount);
}我们可以参考前面从
Reflection获取fieldFilterMap的方法从Class获取reflectionData字段并置为null,强制 JVM 清空缓存,迫使其重新解析反射数据。清除缓存后,JVM 将重新读取并应用新的fieldFilterMap,达到绕过限制的效果。1
2
3
4byte[] clz = Class.class.getResourceAsStream("Class.class").readAllBytes();
Class<?> classAnonymousClass = unsafe.defineAnonymousClass(Class.class, clz, null);
Field reflectionData = classAnonymousClass.getDeclaredField("reflectionData");
unsafe.putObject(Class.class, unsafe.objectFieldOffset(reflectionData), null);
完整代码如下:
1 | public static void bypassFieldFilterMap() throws Exception { |
这里置空 fieldFilterMap 字段时为了获取该字段的偏移用了 Unsafe#defineAnonymousClass 方法进行绕过。由于 defineAnonymousClass 方法在 JDK 17 被正式移除,因此高版本 JDK 还需要寻找其他方法进行绕过。
JDK ≥ 17 (setAccessible)
JDK17(以及高版本的 JDK16)针对 checkCanSetAccessible 的限制进一步增强。
限制条件分析
从 JDK 9 开始,checkCanSetAccessible 会在调用 setAccessible(true) 前做一次模块“开放”(opens)检查。核心判断就是:
1 | // 如果被反射类的包是对调用者开放的,返回 true |
- 在 JDK 9~15 的默认迁移模式(
--illegal-access=permit)下,JRE 会把 JDK 8 就已存在的包(例如java.base/java.lang)临时“打开”给所有未命名模块(ALL-UNNAMED)。因此当调用方在 classpath(未命名模块)时,这里isOpen(pn, callerModule)判定为 true,setAccessible(true)得以放行,并打印“一次性”非法反射警告。 - 到了 JDK 16,默认改为强封装:不再默认“临时打开”,除非你显式选择“宽松模式”(仍然可用
--illegal-access=permit恢复 JDK 9 的行为)。所以默认情况下isOpen(...)多半变为 false。 - 自 JDK 17 起,JDK 内部彻底强封装并移除了
--illegal-access选项;不再做那种“自动临时打开”。因此declaringModule.isOpen(pn, callerModule)对诸如java.base/java.lang这类包将**不再返回true**。若仍要深反射,必须显式加:
--add-opens java.base/java.lang=ALL-UNNAMED(或开到具体的命名模块),或在运行期调用Module::addOpens。
也就是说 JDK 16/17+ 默认不再自动 opens,isOpen(...) 因而返回 false,需要显式 --add-opens 才能通过。
绕过方法
修改 module 属性
前面在 JDK9 版本时我们已经提出了一个通过修改字段的访问属性来绕过 checkCanSetAccessible 的方法。然而这个方法需要我们修改被反射操作的对象成员的 modifiers 属性。
由于从 JDK 12 版本开始,getDeclaredField 方法限制增多,因此我们无法直接获取到 modifiers 属性然后通过 Unsafe 模块修改(由于这里是 setAccessible 加强因此根本不会考虑反射修改)。
如果直接利用 Unsafe 模块的 Unsafe#defineAnonymousClass 方法绕过字段获取,则会由于 JDK17 该方法删除而失败。
而前面绕过 getDeclaredField 的思路到 JDK17 版本时均已失效,因此同样无法获取字段。
- 调用
getDeclaredFields0方法在setAccessible这一步被禁止。 - 置空
fieldFilterMap方法因Unsafe#defineAnonymousClass方法被移除而失效。
不过对于 getDeclaredFields0 这个方法,在 checkCanSetAccessible 中还判断了很多条件,我们只要想办法让其中一个条件得到满足就能绕过。
由于 fieldFilterMap 没有过滤 Class 的 module 成员,因此一种常见的方法就是通过 Unsafe 修改当前调用者的模块为 Object 所在模块,这样就可以通过下面这条判断。
1 | // set by VM |
提示
当我们设置 callerModule 为 Object.class.getModule() 时,相当于关于 setAccessible 的防护已经被关闭了,此时不光是 getDeclaredFields0,其它任何属性我们都可以调用 setAccessible(true) 获取所有权限。
我们可以对前面的 getDeclaredField 方法进一步作如下改进:
1 | /** |
提示
由于我们无法直接调用 Reflection 的 getCallerClass 方法(因为此时 setAccessible 还没有被绕过),因此这里我通过 Class.forName(Thread.currentThread().getStackTrace()[1].getClassName()) 来获取调用者对应的 Class 对象。
其中 Thread.currentThread().getStackTrace() 获取了当前线程的调用栈,返回结果是一个数组表示调用栈中的所有方法。
需要注意的是 getStackTrace() 返回的堆栈是反向的,第一个元素是当前方法(即调用 getStackTrace 的方法),所以调用栈的第一个元素是当前方法,第二个元素才是调用 getStackTrace 方法的上一个方法。
我们通过 getClassName 获取调用 getStackTrace 方法的上一个方法对应的类名,然后再使用 Class.forName 获取对应的 Class 对象
借助改进后的 getDeclaredField 我们成功的获取并修改了 defineClass 对应 Method 的 Class 对象的 modifiers 属性。
1 | Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); |
另外我们还可以用修改 final 变量的思路代替 Unsafe:
1 | public static void setFieldValue(Object object, String fieldName, Object value) throws Exception { |
代理
在 Java 编程中,代理模式是一种设计模式,它允许开发者在方法调用的前后插入额外的逻辑。这种技术在许多实际应用中非常有用,例如日志记录、事务管理和安全检查。Java 代理模式可以分为静态代理和动态代理。
静态代理
静态代理的概念
静态代理是指在编译时由开发者创建的代理类。代理类与被代理类实现相同的接口,并在代理类中调用被代理类的方法。代理类可以在调用方法前后添加额外的逻辑。
静态代理的实现
静态代理有两种实现方式:
- 基于接口的实现 :代理类实现与目标类相同的接口,并通过接口调用目标对象的方法。基于接口的代理实现方式灵活,可以代理多个不同类的实例,因此耦合度较低。
- 基于继承的实现 :代理类通过继承目标类来实现代理,通常会重写目标类的方法并添加额外功能。基于继承的代理只能代理特定的目标类,无法代理其他类。因此,它的耦合度较高,因为代理类和目标类紧密绑定。这种实现方式在实际中很少使用。
我们以一个简单的服务接口 Service 和它的实现类 RealService 为例,展示静态代理的实现。
1 | package com.example; |
在上述代码中定义了 Service 接口以及针对这个接口的代理类和实现类 :
- Service 接口 :定义了
perform方法。 - RealService 类 :是
Service接口的实现类,实际业务逻辑在此实现。 - ServiceProxy 类 :是静态代理类,控制对
RealService的方法调用,允许在方法调用前后添加自定义逻辑。
在 main 方法中,通过代理类 ServiceProxy 来调用实际的 perform 方法。这个过程就是静态代理。
动态代理
动态代理的概念
动态代理指在运行时为目标对象动态生成代理类并拦截方法调用的一种机制。它与静态代理的最大区别是:无需手写代理类,而是由运行时(或框架)按需生成代理字节码并加载。Java 提供了基于接口的动态代理和基于类的动态代理(通过第三方库实现,如 CGLIB)。
- 基于接口的动态代理 :
java.lang.reflect.Proxy + InvocationHandler。只要有接口,就能在运行时生成实现该接口的$Proxy...类,并把方法调用分发给InvocationHandler#invoke(...)。 - 基于类的动态代理 :借助 CGLIB / Byte Buddy 等库,通过生成目标类的子类并覆盖方法,在
intercept(...)中织入逻辑。适用于没有接口或希望对具体类增强的场景。
提示
静态代理需要为每个被代理类型手写一个代理类;一旦接口或方法签名变动,这些代理类也要同步修改,样板代码多、维护成本高、耦合度大。
动态代理则在运行时按需生成代理类:方法调用会被统一转发到一个拦截器(如 InvocationHandler/MethodInterceptor),由拦截器决定是否、何时以及如何调用目标方法,并在调用前后织入日志、鉴权、事务、限流等横切逻辑,从而显著减少样板代码并提升复用与灵活性。
JDK 动态代理只适用于接口;若要代理具体类,通常使用 CGLIB/Byte Buddy(不能代理 final 类/方法,也不能拦截构造器或静态方法)。
基于接口的动态代理
基于接口的动态代理使用 JDK 提供的 java.lang.reflect.Proxy 类和 InvocationHandler 接口创建代理。代理对象通过实现一个或多个接口,在运行时动态生成并委托给 InvocationHandler 进行方法调用。适用于目标类实现了接口的场景。
代码实现
为了生成代理类,我们使用 Java 提供的 Proxy.newProxyInstance 方法,该方法的原型如下:
1 | public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h); |
newProxyInstance 方法动态生成代理类时,依赖于以下三个参数:
被代理类的类加载器
loader- 代理类本身是 动态生成的,并且需要被 加载到 JVM 中。
- 这个
loader参数传入的是 目标类(被代理类) 的类加载器,告诉 JVM 应该使用哪个类加载器来加载这个代理类。 - 如果
loader设置为null,代理类将由调用该方法的类的类加载器加载。通常情况下,我们传入的是目标对象的类加载器(target.getClass().getClassLoader())。
被代理类实现的接口
interfaces- 生成的代理类将实现被代理类的接口。
- 这个
interfaces是一个 接口数组,因为一个类可以实现多个接口,所以动态代理类也需要实现多个接口,从而能够代理多个接口的方法。 - 该数组包含了目标类 实现的所有接口,以便生成的代理类能够符合这些接口的契约,确保我们能够调用接口中定义的方法。
InvocationHandler接口的实现hInvocationHandler是动态代理的核心,负责 拦截和转发方法调用。每当代理对象的方法被调用时,
InvocationHandler的invoke()方法会被触发。这个
h参数就是一个实现了InvocationHandler接口的对象,它定义了代理对象的方法调用应该如何处理。通过
InvocationHandler,我们可以在方法执行前或执行后进行一些自定义操作,比如日志记录、权限验证、性能监控等。
这几个参数的关系如下图所示:
提示
由于 Proxy.newProxyInstance 需要三个参数(类加载器、接口数组、调用处理器),我们可以在 InvocationHandler 的实现类 ServiceInvocationHandler 中**保存被代理对象 realService**:
1 | class ServiceInvocationHandler implements InvocationHandler { |
这样我们就可以直接推导出 Proxy.newProxyInstance 所需的三个参数:
- loader(类加载器):优先使用线程上下文类加载器(TCCL),退而求其次用
realService.getClass().getClassLoader()。 - interfaces(接口数组):使用
realService.getClass().getInterfaces()自动收集目标对象已实现的接口。 - h(调用处理器):就是当前的
this(ServiceInvocationHandler实例)。
因此我们只需定义一个 getProxyInstance() 工厂方法,就能可靠地生成代理对象。
1 | // 封装获取动态代理对象的方法 |
在调用被代理类 RealService 的方法时,代理类会将调用转发至 InvocationHandler 接口的实现类 ServiceInvocationHandler 的 invoke 方法上。该方法的原型如下:
1 | public Object invoke(Object proxy, Method method, Object[] args) throws Throwable; |
proxy:是“代理实例本身”(也就是$ProxyN对象),不是被代理的真实对象。注意
在
invoke里不要再通过proxy去调用业务方法(会**递归进入invoke**);需要调用真实实现,应当保存并调用你自己的realService。method:调用的被代理的方法对应的Method对象。提示
这里直接提供了被代理方法的
Method对象,因此动态代理的用法就十分灵活了。例如 MyBatis 框架就利用在这里利用Method对象反射解析接口中的 SQL 语句注解,然后根据传入的参数执行 SQL 语句并返回,而没有使用代理原本的功能,即 MyBatis 代理的接口没有实现类。args:调用的被代理的方法时传入的参数列表。
完整的动态代理示例代码如下:
1 | package com.example; |
原理分析
JDK 动态代理类是在运行时由 JVM 根据“接口数组 + 调用处理器”动态生成并 defineClass 的。默认这些类只存在于内存中,不会落盘。如果想把生成的 $ProxyN 写成 .class 文件,JDK 内置了调试用开关:
将系统属性 sun.misc.ProxyGenerator.saveGeneratedFiles 设置为 true 就可以将生成的动态代理类保存在当前目录下。
- JDK 8 及之前:
sun.misc.ProxyGenerator.saveGeneratedFiles=true - JDK 9 及之后:
jdk.proxy.ProxyGenerator.saveGeneratedFiles=true(注意前缀变了)
我们可以通过下面两种方式设置系统属性:
启动参数:
1
2
3
4
5# JDK 8 及之前
java -Dsun.misc.ProxyGenerator.saveGeneratedFiles=true ...
# JDK 9+(含 11/17/21 等)
java -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true ...代码里设置(务必在创建任何代理之前):
1
2
3
4
5
6
7
8
9public static void main(String[] args) {
// JDK 9+
System.setProperty("jdk.proxy.ProxyGenerator.saveGeneratedFiles", "true");
// 如需兼容 JDK 8,可同时设置老属性(多设置一个不影响):
System.setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
// ...随后再创建动态代理对象
}
建议优先用 JVM 启动参数
-D的方式,因为很多框架会很早创建代理;若在代码里再System.setProperty(...),可能已经来不及。
设置上述系统属性后,Java 会在当前工作目录(System.getProperty("user.dir"))下,按包名创建子目录,并在其中创建 $Proxy0 类的类文件。
1 | package com.example; |
$Proxy0 的构造函数在 Proxy.newProxyInstance 创建动态代理类时调用。
1 | /** |
构造函数传入我们实现的 InvocationHandler 接口,该实现类直接传递给父类也就是 Proxy 的构造函数。在 Proxy 的构造函数将其保存在成员变量 h 中。
1 | /** |
另外 $Proxy0 根据我们传入的被代理类的接口动态生成了对应的方法的代理,同时还生成了一些 Object 的方法的代理例如 equals,toString 等。
以被代理接口的 perform 方法为例,在 $Proxy0 的静态代码块中会通过反射获取被代理接口的 perform 方法。
1 | performMethod = Class.forName("com.example.Service").getMethod("perform"); |
而在 perform 方法的实现中,$Proxy0 会调用前面保存的 InvocationHandler 的实现类 h 的 invoke 方法,并依次将代理类对象 $Proxy0,perform 方法的 Method 对象,调用 perform 方法时传入的参数依次作为参数传递给 invoke。
1 | // 重写 perform 方法(来自 Service 接口) |
基于类的动态代理(CGLIB)
CGLIB(Code Generation Library)是一个功能强大的字节码生成和修改库,广泛用于 Java 中动态生成代理类。它可以在运行时动态地创建一个子类并对其进行增强(如添加代理方法、拦截器等),常常用于 AOP(面向切面编程)或其他需要动态代理的场景。
CGLIB 通过动态生成子类来实现代理功能。它不像 JDK 动态代理那样要求目标类实现接口,而是通过继承目标类来生成代理类。
另外 CGLIB 是基于字节码操作在运行时动态生成代理类,因此相比于传统的反射机制,它的性能更高。
CGLIB 动态代理的应用过程如下:
1 | import net.sf.cglib.proxy.Enhancer; |
我们需要在 Maven 中引入 cglib:
1 | <dependency> |
另外如果想看 CGLIB 动态生成的代理类的实现可以添加如下设置:
1 | System.setProperty(DebuggingClassWriter.DEBUG_LOCATION_PROPERTY, "output/"); |
命令执行
反射执行命令
在 Java 中,我们可以使用 Runtime.getRuntime() 和 ProcessBuilder 来执行外部命令(例如操作系统命令或运行其他程序)。
getRuntime
java.lang.Runtime 是一个与 Java 虚拟机(JVM)交互的接口,它提供了一些方法来执行外部命令和管理系统资源。其中 exec 方法是执行外部命令的主要方法。它会启动一个新的进程来执行指定的命令。
java.lang.Runtime 执行命令需要先获取 Runtime 对象。由于 Runtime 是单例模式,因此需要调用 Runtime 的 getRuntime 方法来获取。之后调用 Runtime 对象的 exec 方法执行命令。对应 java 语句如下:
1 | Runtime.getRuntime().exec("calc"); |
改写成反射形式如下:
1 | Class.forName("java.lang.Runtime").getMethod("exec", String.class).invoke( |
当然,也可以使用 getDeclaredConstructor 获取 Runtime 的私有构造来创建 Runtime 实例。不过高版本 JDK 调用私有构造类需要绕过。
1 | Class<?> runtimeClass = Class.forName("java.lang.Runtime"); |
ProcessBuilder
java.lang.ProcessBuilder 是 Java 中用于创建和管理操作系统进程的一个类,我们可以用来执行命令。对应 java 语句如下:
1 | (new ProcessBuilder("calc")).start(); |
改写成反射形式如下,其中 ProcessBuilder 的构造函数参数是可变参数,因此需要传入 List:
1 | Class.forName("java.lang.ProcessBuilder").getMethod("start").invoke( |
- Title: Java 安全基础
- Author: sky123
- Created at : 2024-11-11 23:50:14
- Updated at : 2025-10-28 12:50:31
- Link: https://skyi23.github.io/2024/11/11/Java 安全基础/
- License: This work is licensed under CC BY-NC-SA 4.0.