欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 健康 > 美食 > Java 泛型

Java 泛型

2025/6/29 18:10:00 来源:https://blog.csdn.net/sinat_34715587/article/details/144172568  浏览:    关键词:Java 泛型

Java泛型是一种类型参数化范式,有三种表现形式(分别是泛型方法,泛型类,泛型接口),允许开发者使用类型参数替代明确类型,实例化时再指明具体类型,达到了代码重用的目的,类似 C++ 模板的概念,但 Java 泛型是伪泛型。

Java 编译器对泛型应用了强大的类型检测,如果代码违反了类型安全就会报错,可以在编译时暴露大多数泛型的编码错误。但总有一部分编码错误,比如泛型类型擦除的坑,在运行时才会暴露。想要排掉这些坑,只能认知先行。

Java 在编译时用类型擦除来实现泛型。擦除时使用 Object 或者界定类型替代泛型,同时在要调用具体类型方法或者成员变量时插入强转代码,为了保证多态特性,Java 编译器还会为泛型类的子类生成桥接方法。类型信息在编译阶段被擦除之后,程序在运行期间无法获取类型参数所对应的具体类型。

类型擦除,是编译时进行的一种机制,它确保运行时不知道泛型类型参数的任何信息。声明了泛型的 .java 源代码,在编译生成 .class 文件之后,泛型相关的信息就消失了。可以认为,源代码中泛型相关的信息,就是提供给编译器用的。泛型信息对 Java 编译器可见,对 Java 虚拟机不可见,即泛型不影响运行时性能。

Java 编译器通过如下方式实现类型擦除,

  • 用 Object 或者界定类型替代泛型,产生的字节码中只包含了原始的类,接口和方法;
  • 在恰当的位置插入强制转换代码来确保类型安全;
  • 在继承了泛型类或接口的类中插入桥接方法来保留多态性。

参考Java 官方手册tutorial/java/generics/erasure.html原文、Type Erasure in Java原文

类型擦除 Case 分析

定义实现 Comparable 接口的 User 类,类型参数为 User,实现 compareTo 方法如下,

package sora.example.instances;class User implements Comparable<User> {String name;@Overridepublic int compareTo(User other) {return this.name.compareTo(other.name);}
}> javac User.java
> jad User.class

JDK 中 Comparable 接口源码内容如下,

package java.lang;
public interface Comparable<T>{int compareTo(T o);
}> jad Comparable.class

先反编译 Comparable 接口,Comparable 接口的字节码文件可以在 $JRE_HOME/lib/rt.jar 中找到,将它复制出来。使用JAD (Java Decompiler Download Mirror)反编译这个Comparable.class 文件。反编译出来的内容在 Comparable.jad 文件中,文件内容如下,

// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3)
// Source File Name:   Comparable.javapackage java.lang;// Referenced classes of package java.lang:
//            Objectpublic interface Comparable
{public abstract int compareTo(Object obj);
}

对比 Comparable.java 和反编译代码 Comparable.jad 的内容,反编译后的内容中已经没有了类型变量 T 。compareTo 方法中的参数类型 T 也被替换成了 Object。这就符合上面提到的第 1 条擦除原则。这里演示的是用 Object 替换类型参数,使用界定类型替换类型参数的例子可以反编译一下 Collections.class 试试,里面使用了大量的泛型。

类似地,User.java经过编译和反编译,User.jad 文件内容如下,

// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3) 
// Source File Name:   User.javapackage sora.example.instances;class Userimplements Comparable
{User(){}public int compareTo(User user){return name.compareTo(user.name);}public volatile int compareTo(Object obj){return compareTo((User)obj);}String name;
}

对比编辑的源代码 User.java 和反编译出来的代码 User.jad,容易发现:类型参数没有了,多了一个无参构造方法,多了一个 compareTo(Object obj) 方法,这个就是桥接方法,还可以发现参数 obj 被强转成 User 再传入 compareTo(User user) 方法。通过这些内容可以看到擦除规则 2 和规则 3 的实现方式。

强转规则比较好理解,因为泛型被替换成了 Object,要调用具体类型的方法或者成员变量,当然需要先强转成具体类型才能使用。那么插入的桥接方法该如何理解呢?

如果我们只按照下面方式去使用 User 类,这样确实不需要参数类型为 Object 的桥接方法。

User user = new User();
User other = new User();
user.comparetTo(other);

但是,Java 中的多态特性允许我们使用一个父类或者接口的引用指向一个子类对象。

Comparable<User> user = new User();

而按照 Object 替换泛型参数原则,Comparable 接口中只有 compareTo(Object) 方法,假设没有桥接方法,显然如下代码是不能运行的。所以 Java 编译器需要为子类(泛型类的子类或泛型接口的实现类)中使用了泛型的方法额外生成一个桥接方法,通过这个方法来保证 Java 中的多态特性。

Comparable<User> user = new User();
Object other = new User();
user.compareTo(other);

而普通类中的泛型方法在进行类型擦除时不会产生桥接方法。例如,

class Dog{<T> void eat(T[] food){}
}

类型擦除之后变成了,

class Dog
{Dog(){}void eat(Object aobj[]){}
}

使用泛型

定义一个泛型方法,声明了一个类型变量,它可以应用于参数、返回值和方法内的代码逻辑。

class GenericMethod{public <T> T[] sort(T[] elements){return elements;}
}

定义一个泛型类,也需要声明类型变量,只不过位置放在了类名后面,作用的范围包括了当前中的成员变量类型、方法参数类型、方法返回类型,以及方法内的代码中。

子类继承泛型类时或者实例化泛型类的对象时,需要指定具体的参数类型或者声明一个参数变量。如下,SubGenericClass 继承了泛型类 GenericClass,其中类型变量 ID 的值为 Integer,同时子类声明了另一个类型变量 E,并将E 填入了父类声明的 T 中。

class GenericClass<ID, T>{}class SubGenericClass<T> extends GenericClass<Integer, T>{}

定义一个泛型接口,也需要在接口名后面声明类型变量,作用于接口中的抽象方法返回类型和参数类型。子类在实现泛型接口时需要填入具体的数据类型或者填入子类声明的类型变量。

interface GenericInterface<T> {T append(T seg);
}

泛型经过类型擦除多出桥接方法的坑

需求希望在类字段内容变动时记录日志,想到定义一个泛型父类,并在父类中定义一个统一的日志记录方法,子类可以通过继承重用这个方法。代码上线后没问题,但出现日志重复记录问题。开始时,怀疑是日志框架问题,排查后才发现是泛型的问题。

实现逻辑:Parent 是泛型类,子类 Child1 继承父类,没有提供父类泛型参数;定义一个 String 参数的 setValue 方法,通过 super.setValue 调用父类方法实现日志记录(希望覆盖父类的 setValue 实现)。调用子类方法用的是反射,实例化 Child1 类型后,通过 getClass().getMethods 方法获得所有的方法,按照方法名过滤出 setValue 方法进行调用,传入字符串 test 作为参数,

class Parent<T> {//用于记录value更新的次数,模拟日志记录的逻辑AtomicInteger updateCount = new AtomicInteger();private T value;//重写toString,输出值和值更新次数@Overridepublic String toString() {return String.format("value: %s updateCount: %d", value, updateCount.get());}//设置值public void setValue(T value) {this.value = value;System.out.println("Parent.setValue called");updateCount.incrementAndGet();}
}class Child1 extends Parent {public void setValue(String value) {System.out.println("Child1.setValue called");super.setValue(value);}
}Child1 child1 = new Child1();
Arrays.stream(child1.getClass().getMethods()).filter(method -> method.getName().equals("setValue")).forEach(method -> {try {method.invoke(child1, "test");} catch (Exception e) {e.printStackTrace();}});
System.out.println(child1.toString());

运行代码后可以看到,虽然 Parent 的 value 字段正确设置了 test,但父类的 setValue 方法调用了两次,计数器也显示 2 而不是 1。

Child1.setValue called
Parent.setValue called
Parent.setValue called
value: test updateCount: 2

显然,两次 Parent 的 setValue 方法调用,是因为 getMethods 方法找到了两个名为 setValue 的方法,分别是父类和子类的 setValue 方法。这个 case 中,子类方法重写父类方法失败的原因,包括两方面,一是子类没有指定 String 泛型参数,父类的泛型方法 setValue(T value) 在泛型擦除后是setValue(Object value),子类中入参是 String 的 setValue 方法被当作了新方法;二是子类的 setValue 方法没有增加 @Override 注解,因此编译器没能检测到重写失败的问题。这就说明重写子类方法时,标记 @Override 是一个好习惯。但开发认为问题出在反射 API 使用不当,却没意识到重写失败。他查文档后发现,getMethods 方法能获得当前类和父类的所有 public 方法,而 getDeclaredMethods 只能获得当前类所有的public、protected、package和private方法。于是就用 getDeclaredMethods 替代了 getMethods,

Arrays.stream(child1.getClass().getDeclaredMethods()).filter(method -> method.getName().equals("setValue")).forEach(method -> {try {method.invoke(child1, "test");} catch (Exception e) {e.printStackTrace();}});

这样虽然能解决重复记录日志的问题,但没有解决子类方法重写父类方法失败的问题,得到如下输出,

Child1.setValue called
Parent.setValue called
value: test updateCount: 1

其实这治标不治本,其他人使用 Child1 时还是会发现有两个 setValue 方法,非常容易让人困惑。

那就重新实现 Child2,继承 Parent 的时候提供 String 作为泛型T类型,并使用 @Override 关键字注释 setValue 方法,实现了真正有效的方法重写,

class Child2 extends Parent<String> {@Overridepublic void setValue(String value) {System.out.println("Child2.setValue called");super.setValue(value);}
}

但很可惜,修复代码上线后,还是出现了日志重复记录,

Child2.setValue called
Parent.setValue called
Child2.setValue called
Parent.setValue called
value: test updateCount: 2

可以看到,这次是 Child2 类的 setValue 方法被调用了两次。开发惊讶地说,肯定是反射出 Bug了,通过 getDeclaredMethods 查找到的方法一定是来自 Child2 类本身;而且怎么看 Child2 类中也只有一个 setValue 方法,为什么还会重复呢?

调试一下可以发现,Child2 类其实有2个 setValue 方法,入参分别是 String 和 Object。

如果不通过反射来调用方法,确实很难发现这个问题,其实这就是泛型类型擦除导致的问题。

Java泛型类型在编译后擦除为 Object。虽然子类指定了父类泛型T类型是 String,但编译后 T 会被擦除成为 Object,所以父类 setValue 方法的入参是 Object,value也是 Object。如果子类 Child2 的 setValue 方法要覆盖父类的 setValue 方法,那入参也必须是 Object。所以,编译器会生成一个bridge 桥接方法,使用 javap 命令来反编译生成 Child2 类的 class 字节码,

javap -c Child2.class
Compiled from "GenericAndInheritanceApplication.java"
class org.geekbang.time.commonmistakes.advancedfeatures.demo3.Child2 extends org.geekbang.time.commonmistakes.advancedfeatures.demo3.Parent<java.lang.String> {org.geekbang.time.commonmistakes.advancedfeatures.demo3.Child2();Code:0: aload_01: invokespecial #1                  // Method org/geekbang/time/commonmistakes/advancedfeatures/demo3/Parent."<init>":()V4: return
​
​public void setValue(java.lang.String);Code:0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;3: ldc           #3                  // String Child2.setValue called5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V8: aload_09: aload_110: invokespecial #5                  // Method org/geekbang/time/commonmistakes/advancedfeatures/demo3/Parent.setValue:(Ljava/lang/Object;)V13: return
​
​public void setValue(java.lang.Object);Code:0: aload_01: aload_12: checkcast     #6                  // class java/lang/String5: invokevirtual #7                  // Method setValue:(Ljava/lang/String;)V8: return
}

可以看到入参为 Object 的 setValue 方法在内部调用了入参为 String 的 setValue 方法,也就是代码里实现的那个方法。如果编译器没有实现这个桥接方法,那么 Child2 子类重写的是父类经过泛型类型擦除后、入参是 Object 的 setValue 方法。这两个方法的参数,一个是 String 一个是 Object,明显不符合 Java 语义,

class Parent {
​AtomicInteger updateCount = new AtomicInteger();private Object value;public void setValue(Object value) {System.out.println("Parent.setValue called");this.value = value;updateCount.incrementAndGet();}
}
​
class Child2 extends Parent {@Overridepublic void setValue(String value) {System.out.println("Child2.setValue called");super.setValue(value);}
}

使用 jclasslib 工具打开 Child2 类,同样可以看到入参为 Object 的桥接方法上标记了public + synthetic + bridge三个属性。synthetic 代表由编译器生成的不可见代码,bridge 代表这是泛型类型擦除后生成的桥接代码,

解决方案:用 method 的 isBridge 方法判断是不是桥接方法,getDeclaredMethods 方法获取到所有方法后,根据方法名 setValue 和非 isBridge 两个条件过滤,才能实现唯一过滤;使用 Stream 时,如果希望只匹配 0 或 1 项的话,可以考虑配合 ifPresent 来使用 findFirst 方法。

Arrays.stream(child2.getClass().getDeclaredMethods()).filter(method -> method.getName().equals("setValue") && !method.isBridge()).findFirst().ifPresent(method -> {try {method.invoke(chi2, "test");} catch (Exception e) {e.printStackTrace();}
});

这样就可以得到正确输出了,

Child2.setValue called
Parent.setValue called
value: test updateCount: 1

总结下结论,使用反射查询类方法清单时要注意两点,getMethods 和 getDeclaredMethods 是有区别的,前者可以查询到父类方法,后者只能查询到当前类;反射进行方法调用要注意过滤桥接方法。

参考:

【java】Java 中泛型的实现原理_java 泛型怎么才行只接收字节码-CSDN博客

Java 泛型擦除 - 简书

Have Fun

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词