一座太远的桥

工程 | 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。那么这在编译后的类文件中意味着什么呢?我们有用户提供的方法

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 读取字节码是一个很好的解决方案,而且通常是万无一失的。然而,在安全的环境中,它可能需要对不允许的库的读取权限,这可能会带来问题。第二种解决方案是利用桥接方法中的泛型元数据来解析实现类中的哪个方法被桥接了。

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

的实现BridgeMethodResolver基本已完成;然而,我确信还有一些我们尚未考虑到的复杂情况,我将乐于听取遇到此领域任何问题的用户反馈。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

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

查看所有