Java

JDK Proxy与UndeclaredThrowableExc

2020-02-22  本文已影响0人  ZX_周雄

背景

最近浏览Sentinel的wiki,其中有一段描述:

特别地,若 blockHandler 和 fallback 都进行了配置,则被限流降级而抛出 BlockException 时只会进入 blockHandler 处理逻辑。若未配置 blockHandler、fallback 和 defaultFallback,则被限流降级时会将 BlockException 直接抛出(若方法本身未定义 throws BlockException 则会被 JVM 包装一层 UndeclaredThrowableException)

这段话大概表述的意思是,当使用Sentinel的注解@SentinelResource来定义资源,可以通过属性blockHandler、fallback分别定义限流降级逻辑与业务异常fallback逻辑。若blockHandler、fallback 和 defaultFallback都未定义,当出现限流降级异常时会将BlockException直接抛出,但是,如果方法定义本身未声明throws BlockException,抛出的异常就并非是BlockException,而是JVM将BlockException包装成UndeclaredThrowableException之后抛出

如果没有相关的知识储备,我相信看到此处,大家会一脸茫然:JVM为什么要包装BlockException,不包装行不行?JVM除了会包装BlockException,还会包装其他什么异常?

因此,本文将探索两个问题:

  1. JVM会将哪类异常包装为UndeclaredThrowableException
  2. JVM包装这类异常的原因

案例

下面将引入一个案例来帮助理解,如果看到案例后就想起来怎么回事,相信上面的问题也就能回答上来了

案例如下:

interface FooService {
    void foo() throws IllegalAccessException;
}

class FooServiceImpl implements FooService {
    @Override
    public void foo() throws IllegalAccessException {
        throw new IllegalAccessException("Let's say it's an exception");
    }
}

class FooProxy implements InvocationHandler {
    private Object target;

    FooProxy(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        return method.invoke(target, args);
    }
}
  1. 定义一个接口,接口有个方法foo,方法声明抛出一个java.lang.IllegalAccessException
  2. 定义一个实现类实现步骤1的接口及foo方法,方法体很简单,直接抛出一个IllegalAccessException
  3. 定义一个实现类实现InvocationHandler,接口方法invoke中直接进行反射调用

看到第3步的InvocationHandler,相信写过JDK Proxy的朋友们已经很熟悉,这是JDK动态代理的使用姿势

接下来,就是创建代理对象并调用方法

public static void main(String[] args) {
    // 创建代理对象
    FooService proxy = (FooService) Proxy.newProxyInstance(FooService.class.getClassLoader(),
            new Class[]{FooService.class}, new FooProxy(new FooServiceImpl()));
    
    try {
        // 调用代理对象的foo方法
        proxy.foo();
    } catch (IllegalAccessException e) {
        // 代码能进入此处吗
    }
}

当应用程序调用proxy.foo()时,会调用到FooProxy#invoke方法,方法内是反射直接调用FooServiceImpl#foo,而该方法的方法体是throw new IllegalAccessException("Let's say it's an exception");,直接抛出了一个IllegalAccessException。那么请问,main方法里catch IllegalAccessException,能成功吗,即代码能进入catch块吗?


答案是:不能

源码分析

这或许会超出一些认知。理论上,如果在方法上声明抛出A异常,且方法体内真实抛出了,那么在方法调用处catch A异常,是能catch住的,如下所示:

FooService fooService = new FooServiceImpl();
try {
    fooService.foo();
} catch (IllegalAccessException e) {
    // 代码能进入此处
}

直接new一个FooServiceImpl,并调用它的foo方法,此时能够catch住IllegalAccessException,代码能进入catch块。为何通过代理调用,就不行了呢?

相信到此处,已经隐隐约约能感觉到是动态代理在作祟

这时,需要看一下Java Doc对java.lang.reflect.InvocationHandler#invoke的描述

The exception's type must be assignable either to any of the exception types declared in the throws clause of the interface method or to the unchecked exception types java.lang.RuntimeException or java.lang.Error. If a checked exception is thrown by this method that is not assignable to any of the exception types declared in the throws clause of the interface method, then an UndeclaredThrowableException containing the exception that was thrown by this method will be thrown by the method invocation on the proxy instance.

接下来,再看一下Java Doc对UndeclaredThrowableException的描述

Thrown by a method invocation on a proxy instance if its invocation handler's invoke method throws a checked exception (a Throwable that is not assignable to RuntimeException or Error) that is not assignable to any of the exception types declared in the throws clause of the method that was invoked on the proxy instance and dispatched to the invocation handler.
An UndeclaredThrowableException instance contains the undeclared checked exception that was thrown by the invocation handler, and it can be retrieved with the getUndeclaredThrowable() method. UndeclaredThrowableException extends RuntimeException, so it is an unchecked exception that wraps a checked exception.
As of release 1.4, this exception has been retrofitted to conform to the general purpose exception-chaining mechanism. The "undeclared checked exception that was thrown by the invocation handler" that may be provided at construction time and accessed via the getUndeclaredThrowable() method is now known as the cause, and may be accessed via the Throwable.getCause() method, as well as the aforementioned "legacy method."

这两段描述总结一下,大概说了三个事:

  1. 在JDK Proxy的调用中,如果实际运行时(InvocationHandler#invoke)抛出了某个受检异常(checked exception),但该受检异常并未在被代理对象接口定义中进行声明,那么这个异常就会被JVM包装成UndeclaredThrowableException进行抛出。这句话另一层含义是,JDK Proxy的调用中,要么抛出接口定义中声明的受检异常,要么抛出非受检异常,要么抛出Error,否则都被会被JVM包装成UndeclaredThrowableException
  2. UndeclaredThrowableException本身是个非受检异常(RuntimeException及其子类)
  3. 可以通过UndeclaredThrowableException#getUndeclaredThrowable拿到被包装的受检异常;JDK1.4以后,通过Throwable#getCause也可以拿到被包装的受检异常,而且这是被建议的方式,因为前者已经过时了

细心的朋友或许有疑问,FooProxy#invoke方法实现只是一个反射调用,method.invoke(target, args)抛出了IllegalAccessException(受检异常),该异常在被代理对象的接口定义中声明了呀,不满足上面说的第1点,不被包装成UndeclaredThrowableException,在main方法里应该能catch住才是,为什么catch不住呢?


换一个考虑的方向,尝试在FooProxy#invoke中catch IllegalAccessException

// code block 2
// FooProxy

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
        method.invoke(target, args);
    } catch (IllegalAccessException e) {
        // 方法能进入处此吗
    }
    return null;
}

看一下Method#invoke的接口定义,与Java Doc对异常声明的描述

public Object invoke(Object obj, Object... args) throws IllegalAccessException, IllegalArgumentException, InvocationTargetException

InvocationTargetException – if the underlying method throws an exception

Method#invoke声明抛出了3个受检异常,其中有一个异常是InvocationTargetException,该异常抛出的条件是:底层方法本身抛出了一个异常

再看看Java Doc对InvocationTargetException的描述

InvocationTargetException is a checked exception that wraps an exception thrown by an invoked method or constructor.
As of release 1.4, this exception has been retrofitted to conform to the general purpose exception-chaining mechanism. The "target exception" that is provided at construction time and accessed via the getTargetException() method is now known as the cause, and may be accessed via the Throwable.getCause() method, as well as the aforementioned "legacy method."

翻译大意是:InvocationTargetException本身是个是个受检异常,它包装着底层方法抛出的异常

它与UndeclaredThrowableException异同点:

code block 2中,method.invoke(target, args)对应调用的是FooServiceImpl#foo,该方法即底层方法抛出了一个IllegalAccessException,经过反射调用后,会被包装成InvocationTargetException之后再抛出,所以catch IllegalAccessException失败,需要catch InvocationTargetException才能成功


回到开始的案例,在FooProxy#invoke方法内部反射调用中我们没有catch,因此直接将InvocationTargetException向上抛给InvocationHandler#invoke,InvocationTargetException又被包装成UndeclaredThrowableException抛给了调用方。已经经过两层的包装,我们在main方法里catch IllegalAccessException当然会失败!

案例隐蔽性(潜藏的BUG)就在于此:FooProxy#invoke方法实现是一个反射调用,Method#invoke方法定义上声明了3个受检异常,但由于InvocationHandler#invoke(FooProxy#invoke)方法声明抛出了个异常的老祖宗Throwable,任何在invoke方法里的代码调用,都不需要throw,也不需要catch就能通过编译。在IDE大行其道的今天,或许许多人不一定能马上反应过来Method#invoke方法本身声明了受检异常,而这些受检异常其实是非常重要的,JDK的开发者希望调用方去细心处理,而不是避而不见。但不巧的是,瞎猫碰上了死耗子,本应重视处理受检异常的方法(Method#invoke)碰上了声明抛出Throwable的方法调用(InvocationHandler#invoke),犹如干柴碰上烈火,一点即燃,Boom

大家可以检查一下,自己写的代码有没有如同案例般埋雷(潜藏BUG多少年了?)

验证

上文提到

JDK Proxy的调用中,要么抛出接口定义中声明的受检异常,要么抛出非受检异常,要么抛出Error,否则都被会被JVM包装成UndeclaredThrowableException。

这是Java Doc给我们的保证。另一方面,我们也可以从源码的角度加深理解,想办法获取JDK Proxy生成的类

sun.misc.ProxyGenerator类中我们看到一个属性saveGeneratedFiles,它表示的含义是:是否要把生成的代理类输出到文件,默认false,可以通过设置sun.misc.ProxyGenerator.saveGeneratedFiles=true来改变默认行为

可以通过启动参数-Dsun.misc.ProxyGenerator.saveGeneratedFiles=true将参数键值对设置到系统属性,也可以通过System.setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");来设置系统属性。此处选择第二种方式:

public static void main(String[] args) {
    // 设置JDK Proxy生成的代理类输出到文件中
    System.setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
    FooService proxy = (FooService) Proxy.newProxyInstance(FooService.class.getClassLoader(),
            new Class[]{FooService.class}, new FooProxy(new FooServiceImpl()));

    try {
        proxy.foo();
    } catch (IllegalAccessException e) {
        // 代码能进入此处吗
    }
}

运行程序,将生成的.class文件放到idea打开,如下:

final class $Proxy0 extends Proxy implements FooService {
    // ...(省略)
    public final void foo() throws IllegalAccessException {
        try {
            super.h.invoke(this, m3, (Object[])null);
        } catch (RuntimeException | IllegalAccessException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }
    // ...(省略)
}

可以看到,反射调用抛出了InvocationTargetException,不满足第一个catch块的条件(非受检异常、非接口定义中声明的受检异常、非Error),但满足了第二个catch块的条件(Throwable的子类),因此被包装成了UndeclaredThrowableException再度抛出到上层调用处,符合我们上文说的现象

同时,控制台打印的异常堆栈如下:

Exception in thread "main" java.lang.reflect.UndeclaredThrowableException
    at com.example.demo.$Proxy0.foo(Unknown Source)
    at com.example.demo.MyTest.main(MyTest.java:18)
Caused by: 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:498)
    at com.example.demo.FooProxy.invoke(MyTest.java:46)
    ... 2 more
Caused by: java.lang.IllegalAccessException: Let's say it's an exception
    at com.example.demo.FooServiceImpl.foo(MyTest.java:33)
    ... 7 more

UndeclaredThrowableException确实是包装了InvocationTargetException,InvocationTargetException包装了最原始的IllegalAccessException,我们在main方法试图catch最底层的IllegalAccessException当然会失败

解决方案

分析了catch异常失败的原因之后,接下来要探寻解决之道

如果有熟悉Spring工作机制的朋友应该马上会联想到,Spring中使用了大量的动态代理机制,也必然存在大量的反射调用,我们可以从中借鉴与学习,优秀的框架是如何处理动态代理中反射调用抛出的异常的

Spring 对于JDK Proxy的运行逻辑入口位于org.springframework.aop.framework.JdkDynamicAopProxy#invoke,接着能够找到对于反射调用的代码在AopUtils#invokeJoinpointUsingReflection

// org.springframework.aop.support.AopUtils

public static Object invokeJoinpointUsingReflection(Object target, Method method, Object[] args) throws Throwable {

    // Use reflection to invoke the method.
    try {
        ReflectionUtils.makeAccessible(method);
        return method.invoke(target, args);
    }
    catch (InvocationTargetException ex) {
        // Invoked method threw a checked exception.
        // We must rethrow it. The client won't see the interceptor.
        // 重点在此处:抛出被包装的原始异常
        throw ex.getTargetException();
    }
    catch (IllegalArgumentException ex) {
        throw new AopInvocationException("AOP configuration seems to be invalid: tried calling method [" +
                method + "] on target [" + target + "]", ex);
    }
    catch (IllegalAccessException ex) {
        throw new AopInvocationException("Could not access method [" + method + "]", ex);
    }
}

处理的重点在于:catch住反射调用抛出的InvocationTargetException,取出被包装的原始异常并将之抛出给InvocationHandler#invoke,结合上边生成的代理类$Proxy0源码可知,异常会进入第一个catch块,且再度被原样抛出到上层调用处,此时main方法就能抓住原始异常

参考Spring的解决方案将FooProxy改造一下,其它不动,再次执行程序

class FooProxy implements InvocationHandler {
    private Object target;

    FooProxy(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
            return method.invoke(target, args);
        } catch (InvocationTargetException ex) {
            throw ex.getTargetException();
        }
    }
}

main方法果然能够正常catch IllegalAccessException

相信到了此处,本文开篇提的问题也已经有了答案,就不再赘述


总结

本文从Sentinel wiki的一段话引出UndeclaredThrowableException与InvocationTargetException,他们都有包装异常的能力,前者在动态代理执行场景中被抛出,后者在反射调用场景中被抛出;前者包装受检异常,后者可包装任意异常。当他们两个相遇的时候,尤其容易引起BUG,需要引起注意,解决方案可参考Spring的做法,抓住包装类异常后,提取底层被包装的异常并抛出

题外话

本篇内容基本是Java基础,涉及到异常体系(受检异常、非受检异常)、JDK Proxy、反射等,这些知识点本身从使用角度考虑并不难,但是交织在一起结合使用的时候,或许会出现意料之外的行为,之所以是意料之外,大抵是因为基础不够扎实,当初学习Java基础的时候并没有阅读相关的Java Doc,"能跑就行"是当今大多数人的想法与行为,常常是某度或Google搜索一个demo搬过来run一下,没报错且行为符合预期,就算"学会了"。这样走捷径的行为,对于有志于往技术方向发展,或者对技术有追求之士其实是一种伤害,俗话说“出来混总是要还的”,当年没学到的知识,在后续的进阶学习过程中总要补回来,而后期学习的成本却越发的高。

举个例子,如果学习Spring源码时,看到这样一段代码:

try {
    ReflectionUtils.makeAccessible(method);
    return method.invoke(target, args);
} catch (InvocationTargetException ex) {
    throw ex.getTargetException();
}

脑子中没有InvocationTargetException与UndeclaredThrowableException的相关概念,那么看过也就只是看过,体会不到作者的用意以及编程要点,阅完即忘,这样的源码学习,其实是相对低效的,这也是不同的人读同一份源码,有人犹如看天书,有人似懂非懂,有人娓娓道来这儿设计精彩那儿编码巧妙,收获各不相一

我一直很敬佩Spring Framework的作者,他们无论是设计能力,还是编码能力都堪称一流,才能以IoC + AOP为基本出发点,构建了整个Spring的生态体系,让许许多多的形态各异的第三方组件,都能很好地与Spring整合在一起,使得Spring成为Java Web领域的绝对王者。也因此我们能从Spring的源码中学到非常多的知识:扩展点的设计、整合第三方框架的思路、动态代理的运用、设计模式的运用、异常体系的构建等等。这前提是我们需要有足够扎实的基础,只有夯实了基础,才能从源码中读懂作者的意图,明白作者编码时的考量及取舍,之后转化为自己的思想,技术才有长远的成长

上一篇下一篇

猜你喜欢

热点阅读