远山呼唤

工程 | Rob Harrop | 2007 年 1 月 16 日 | ...

在我上一篇文章中,我介绍了一种创建策略类(可以充分利用应用程序中存在的任何泛型元数据)的技术。在那篇文章的最后,我展示了这段代码片段

EntitlementCalculator calculator = new DividendEntitlementCalculator();
calculator.calculateEntitlement(new MergerCorporateActionEvent());

你会记得DividendEntitlementCalculator被定义为

public class DividendEntitlementCalculator implements EntitlementCalculator<DividendCorporateActionEvent> {

    public void calculateEntitlement(DividendCorporateActionEvent event) {

    }
}

因此,将MergerCorporateActionEvent的实例传递给calculateEntitlement方法是不正确的DividendEntitlementCalculator类。然而,正如我在上一篇文章中提到的那样,该代码可以编译。为什么?嗯,EntitlementCalculator.calculateEntitlement()被定义为接受任何继承自CorporateActionEvent的类型,所以它应该编译。那么在这种情况下,运行时会发生什么?Java 又如何强制执行类型安全?嗯,正如你可能想象的那样,运行这段代码会得到一个ClassCastException,提示你不能将MergerCorporateActionEvent转换为DividendCoporateActionEvent。通过这种方式,Java 可以为你的应用程序强制执行类型安全——不可能让MergerCorporateActionEvent潜入一个期望DividendCorporateActionEvent出现的方法中。

这里真正的问题是:“那个ClassCastException是从哪里来的?” 答案很简单——Java 编译器会通过引入一个桥接方法来适当地添加创建和抛出它的代码。桥接方法是编译器生成的合成方法,添加到你的类中,以确保在面对泛型类型时的类型安全。

在上面显示的例子中,EntitlementCalculator.calculateEntitlement可以接受与CorporateActionEvent类型兼容的任何对象进行调用。然而,DividendEntitlementCalculator只接受与DividendCorporateActionEvent类型兼容的对象,但是,由于你可以通过DividendEntitlementCalculator调用该方法,EntitlementCalculator接口也必须接受CorporateActionEvent。那么这在编译后的 class 文件中意味着什么?我们有用户提供的方法

public void calculateEntitlement(DividendCorporateActionEvent event) {
    System.out.println(event);
}

它被翻译成这段字节码

public void calculateEntitlement(bigbank.DividendCorporateActionEvent);
  Code:
   Stack=2, Locals=2, Args_size=2
   0:   getstatic       #2; //Field java/lang/System.out:Ljava/io/PrintStream;
   3:   aload_1
   4:   invokevirtual   #3; //Method java/io/PrintStream.println:(Ljava/lang/Object;)V
   7:   return

但我们还有一个编译器生成的方法

public void calculateEntitlement(bigbank.CorporateActionEvent);
  Code:
   Stack=2, Locals=2, Args_size=2
   0:   aload_0
   1:   aload_1
   2:   checkcast       #4; //class bigbank/DividendCorporateActionEvent
   5:   invokevirtual   #5; //Method calculateEntitlement:(Lbigbank/DividendCorporateActionEvent;)V
   8:   return

它被翻译成这段 Java 代码

public void calculateEntitlement(CorporateActionEvent event) {
    calculateEntitlement((DividendCorporateActionEvent)event);
}

所以,在这里你可以清楚地看到,当传入ClassCastException时,CorporateActionEvents而不是DividendCorporateActionEvents时,它从哪里来——就是编译器生成的桥接方法

当然,这是一个非常棒的特性。我们肯定不希望 Java 语言中加入泛型会破坏我们长期以来习惯的类型安全。然而,正如这类事物通常会发生的那样——并非一切都好。当前 JDK 中桥接方法实现的主要问题在于,注解不会从被桥接的方法复制到桥接方法上。当你在使用反射时不小心获得了桥接方法,并试图解析某些注解时,这会导致各种各样的问题。

有些人可能想知道你是如何意外获得桥接方法的。这是一个有点复杂的问题。一个常见的原因(也是我们在 Spring 中最常看到它发生的地方)是当你创建 JDK 代理委托给某个对象,并试图将代理接口中的方法映射到委托对象上对应的实现方法时(通常是为了解析注解)。考虑这段代码

public static void main(String[] args) {
    EntitlementCalculator ec = createProxy(new DividendEntitlementCalculator());
    ec.calculateEntitlement(null);
}

private static EntitlementCalculator createProxy(EntitlementCalculator calculator) {
    InvocationHandler handler = new TransactionLoggingInvocationHandler(calculator);
    return (EntitlementCalculator) Proxy.newProxyInstance(calculator.getClass().getClassLoader(),
                                                                calculator.getClass().getInterfaces(), handler);
}

private static class TransactionLoggingInvocationHandler implements InvocationHandler {

    private final EntitlementCalculator delegate;

    public TransactionLoggingInvocationHandler(EntitlementCalculator delegate) {
        this.delegate = delegate;
    }

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Method delegateMethod = delegate.getClass().getMethod(method.getName(), method.getParameterTypes());
        Transactional annotation = delegateMethod.getAnnotation(Transactional.class);
        if(annotation != null) {
            System.out.println("Executing transactional method: " + delegateMethod);
        } else {
            System.out.println("Executing non-transactional method: " + delegateMethod);
        }
        return method.invoke(delegate, args);
    }
}

这里我们为一个给定的EntitlementCalculator对象创建代理,该代理将记录被代理对象上的方法是否是事务性的。如果我们将DividendEntitlementCalculator类注解如下,我们可以期望代理在调用calculateEntitlement时记录我们正在执行一个事务性方法,该调用来自main.

@Transactional
public void calculateEntitlement(DividendCorporateActionEvent event) {
    System.out.println(event);
}

然而,执行上面的例子会得到以下结果

Executing non-transactional method: public volatile void bigbank.DividendEntitlementCalculator.calculateEntitlement(bigbank.CorporateActionEvent)

注意,这与DividendEntitlementCalculator上我们正在调用的方法不对应。当然,这显然是会发生的情况;这里的重点是接口方法和委托方法的签名不同的。一个是用父类型定义的,在本例中是CorporateActionEvent,另一个是用子类型定义的,在本例中是DividendCorporateActionEvent。你还会注意到,我们实际上得到了桥接方法——因为它的签名确实与接口方法的签名匹配(根据定义)。

查找委托方法的一个更好的解决方案可能是使用传入参数的类型,而不是接口方法上的类型。当面对使用继承的参数时,你可以简单地在参数的类型层次结构中向上搜索类型匹配。不幸的是,这种方法不能可靠地工作。考虑以下接口的情况

public interface Foo<T> {
    void bar(T t);
}

然后是这个实现

public class FooImpl implements Foo<Number>{

    public void bar(Number t) {
    }

    public void bar(Serializable t) {
    }
}

如果你在解析委托方法时使用传入InvocationHandler的具体参数类型,当面对类型为Integer的参数时,你会选择哪个方法?你无法判断(从接口方法中)类型参数是Number,并且由于这两个方法都与Integer类型兼容,因此在一般情况下始终无法解析到正确的方法。

解决这个问题的方法只有两种(据我所知)。第一种方法是使用像 ASM 这样的库来读取桥接方法的字节码,并找出它调用了哪个方法。使用 ASM 读取字节码是一个很好的解决方案,通常是万无一失的。然而,在安全环境中,它可能需要对不允许访问的库具有读取权限,这可能会带来问题。第二种解决方案是利用桥接方法中的泛型元数据来解析实现类中的哪个方法正在被桥接。

在上面的例子中,我们可以看到接口方法是bar,由T参数化。我们可以使用Class.getGenericInterfaces()的泛型接口元数据(FooImpl)来确定T被实现为Number。由此可以轻松得知被桥接的方法是bar(Number)而不是bar(Serializable)。不幸的是,当遇到涉及多个带有边界的类型参数的复杂继承体系时,这种方法会变得越来越复杂。幸运的是,这个逻辑被封装在 Spring 的BridgeMethodResolver类中。这是 Spring 解决 Java 开发人员面临的困难基础设施问题并将其集成到应用程序堆栈中的一个完美例子。在 Spring 中进行任何注解查找时,都会透明地解析桥接方法。

的实现BridgeMethodResolver在很大程度上已经完成;然而,我相信我们还没有考虑到一些复杂的情况,我很乐意听取在该领域遇到任何问题的用户的意见。

订阅 Spring 电子报

通过 Spring 电子报保持联系

订阅

领先一步

VMware 提供培训和认证,助你快速提升。

了解更多

获取支持

Tanzu Spring 通过一个简单的订阅,即可获得 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看全部