概述
反射是 java 编程语言所具备的一种高级特性:对于任何一个类,都能知道这个类的属性和方法;对于任何对象,都能调用它的方法和属性,我们把这种动态获取属性信息以及动态调用对象方法的方式称为 反射机制。由于功能的强大性,反射在很多开源框架或是类库中获得非常广泛的使用,本篇博客我就来简单整理一下 java 反射相关的知识。
一、Java 反射
本篇博客我打算分以下几个模块展开:
- Java Class 类
- Java 反射
1、Java Class 类
Java 反射基于 Class 类对象实现,这里我首先整理 Class 类对象相关知识。
Java 是面向对象程序设计语言,在程序运行过程中时时刻刻伴随着对象的工作。对于如此庞大数量的对象,JVM 需要确定每个对象的类型和对应类型所包含的信息。Java 代码就是通过 Class 类对象来保存对应类型信息的,并且每个对象的对象头模块包含指针(引用)指向 Class 类对象确定类型。其中 Class 是一个实实在在的类,它在源码中表示如下:
public final class Class<T> implements java.io.Serializable,
GenericDeclaration,
Type,
AnnotatedElement {
private static final int ANNOTATION= 0x00002000;
private static final int ENUM = 0x00004000;
private static final int SYNTHETIC = 0x00001000;
private static native void registerNatives();
static {
registerNatives();
}
private Class(ClassLoader loader) {
classLoader = loader;
}
// 省略部分方法
}
每个类都存在一个 Class 类对象和它对应,每当我们编写并编译一个新类时就会创建一个新的 Class 对象,并且这个对象将会被保存在同名 .class 文件中(编译后的字节码文件实际就是 Class 对象)。需要特别注意的一点是:JVM 中所有同类型对象共享一个 Class 类对象。下面我通过抽象示图描述这三者之间的关系:
简单来说,每个通过关键字 class 标识的类,都仅存一个 Class 对象记录它的类型信息,无论创建多少个该类的实例对象,所有对象共用这一个 Class 类对象。
1-1、Class 对象的加载
由于 Class 类只包含私有的构造方法,因此对应对象只能由 JVM 来创建以及加载。关于 JVM 何时加载 Class 对象我做了如下总结:
- 所有类在第一次使用时,JVM 加载对应类对象
- 当程序调用某个类的静态变量或静态方法时,JVM 会加载对应类对象
由此也就可以看出,JVM 并没有在运行前加载所有类对象,而是按需加载。其中每个类对象只会被加载一次,类加载器在加载前会首先判断当前类对象是否已经被加载。如果还没有被加载,默认会根据类名查找 .class 文件,在正式加载 .class 文件前会校验文件格式是否正常,在确保文件正常不存在安全性隐患后,才会将该类对象加载到内存。
Class 对象被加载到内存后,就可以根据该类型创建对象。下面我通过简单的代码演示类加载的过程:
public class ReflexTest {
static {
System.out.println("执行当前类的静态方法");
}
public ReflexTest() {
System.out.println("执行当前类的构造方法");
}
public static void main(String[] args) {
new ReflexTest();
new ReflexTest();
}
}
执行结果:
执行当前类的静态方法
执行当前类的构造方法
执行当前类的构造方法
从执行结果可以看出,第一次创建对象时,JVM 加载 ReflexTest 类对象,加载过程中执行静态方法,加载完成后执行该类的构造方法。第二次创建对象时,此时 Class 类对象已经被加载,因此直接执行构造方法即可。
###/1-2、如何获取 Class 对象
Class 类对象被加载到内存后,除了在创建对象时提供类型数据外,还可以在代码层使用,这种在代码层调用 Class 类对象的方式就叫 反射。这里我先简单列举常见的获取 Class 对象的几种方式:
- Class.forName()
- getClass()
- Class 字面常量
Class.forName()
该方法会返回某个类的 Class 对象,方法参数为类的路径,具体示例如下:
package com.qhd.worker;
class A {
static {
System.out.println("JVM 加载A类的 Class 对象");
}
}
@Test
public void test() throws ClassNotFoundException {
Class.forName("com.qhd.worker.A");
}
执行结果:
JVM 加载A类的 Class 对象
需要注意的一点是:该方法需要捕获 ClassNotFoundException 异常。因为方法参数是 String 类型的,可能出现类不存在的情况。
该方法最大的好处是没有限制,只要知道对应类的路径就可以获取该类的 Class 对象,缺点是如果类路径发生改变,则必须通过修改代码完成,无法做到一劳永逸。
getClass()
该方法是一个实例对象方法,继承于 Object 类,下面我们具体看示例:
@Test
public void test2() throws ClassNotFoundException {
A a = new A();
a.getClass();
}
该方法最大的好处就是使用方便,但缺点也非常明显:需要使用实例对象,如果当前方法中没有权限创建对应示例对象的话,就不能通过该方法获取对象信息。
Class 字面常量
Class 字面常量相比上述两种方法更简单以及高效,下面我们具体看示例:
@Test
public void test3() throws ClassNotFoundException {
Class c = A.class;
}
除了可以获取普通类的 Class 对象外,字面常量同样适用于基础类型数据,并且基础类型的包装类存在属性 Type 指向类对象,其等价关系如下:
int.class = Integer.TYPE;
long.class = Long.TYPE;
char.class = Character.TYPE;
boolean.class = Boolean.TYPE;
float.class = Float.TYPE;
void.class = Void.TYPE;
...
需要特别注意的一点是:通过 Class 字面常量获取的 Class 类对象不会初始化该类。下面我们主要来验证一下,在正式开始前,我们先大概了解类从加载到回收的全过程:
- 加载:类加载的第一步,查找 .class 文件,并将该文件读取进内存,创建 Class 对象
- 验证:验证字节码的安全性和完成性
- 准备:为静态资源分配存储空间
- 解析:解析这个类中所有对其它类的引用
- 初始化:类加载的最后一步,如果该类含有父类,首先执行父类的静态代码块以及为静态资源分配空间
也就是说,当我们通过字面常量获取 Class 对象时,只会触发上述过程中的加载阶段,后续步骤并不会触发。下面我通过一个简单的案例加以证明:
class Demo1 {
static final int num = 1;
static {
System.out.println("Demo1 类被初始化");
}
}
class Demo2 {
static int num = 2;
static {
System.out.println("Demo2 类被初始化");
}
}
public static void main(String[] args) throws ClassNotFoundException {
System.out.println("使用字面常量获取Class类");
Class c = Demo1.class;
System.out.println("使用Class.forName()获取Class类");
Class c2 = Class.forName("Demo2");
}
执行结果:
使用字面常量获取Class类
使用Class.forName()获取Class类
Demo2 类被初始化
从输出结果可以看出,使用字面常量获取 Class 对象时,静态方法并没有执行。也就是说没有执行到类加载过程中的初始化那一步。
1-2、Java 类初始化
前面我们验证了通过字面常量获取 Class 类对象时不会初始化类。除此之外,通过类名访问不同类型的静态常量也可能不初始化类,具体我们看示例:
class Demo3 {
static final int num = 2;
static final int num2 = new Random().nextInt();
static {
System.out.println("Demo3 类被初始化");
}
}
public static void main(String[] args) throws ClassNotFoundException {
System.out.println("访问编译期静态常量");
int temp = Demo3.num;
System.out.println("访问非编译期静态常量");
temp = Demo3.num2;
}
执行结果:
访问编译期静态常量
访问非编译期静态常量
Demo3 类被初始化
从输出结果可以看出,访问编译期静态常量不会导致类的初始化,只有访问非编译期静态常量时,类加载才会执行到初始化那一步。这是因为 num 属性在编译阶段通过 常量传播优化 的方式保存在了常量池中,后序访问该常量实际上都是在访问常量池对象。而 num2 属性虽然也是常量,但它的值不能在编译期确定,只能等到类初始化完成后才能确定。这也是导致上述执行结果的主要原因。
初始化是类加载的最后一步,完成这个阶段后类对象才会真正加载到内存中,此时才可以通过类对象创建实例对象、引用类对象的静态变量。JVM 规定在以下几种情况一定会执行初始化操作:
- 使用 new 关键字创建实例对象时,初始化类
- 调用类的静态变量或是调用类的静态方法时,初始化类
- 对类进行反射调用时,初始化类
- 初始化类并且该类的父类还没有初始化时,首先初始化父类
- 虚拟机启动,执行 main() 方法前必须初始化当前类
1-3、Class 类对象的类型转换
回到 Class 类,通过前面 Java Class 类的源码可以看出,它本身支持泛型。如果我们在创建 Class 对象时声明泛型类型的话,在具体给类对象添加值时会多一步判断操作。具体代码如下:
Class intType = int.class;
Class<Double> doubleType = double.class;
// 代码检测无异常
intType = double.class;
// 编译器报错
doubleType = int.class;
从代码可以看出,没有使用泛型的 Class 对象可以赋任何类对象,如果存在错误只会在运行期检测出来。一旦声明了泛型类型,编译器会在编译器检查类型是否匹配,确保没有出现类型错误。
除了不同类型外,使用泛型时,子类类对象也不能通过父类类对象赋值,具体我们看代码:
class Father {
}
class Son extends Father {
}
@Test
public void test4() {
// 编译期报错
Class