JDK Proxy與UndeclaredThrowableException不可不說的關係

背景

最近瀏覽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異同點:

  • 相同點:都包裝着異常,都可以通過getCause方法拿到底層被包裝的異常
  • 不同點: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的源碼中學到非常多的知識:擴展點的設計、整合第三方框架的思路、動態代理的運用、設計模式的運用、異常體系的構建等等。這前提是我們需要有足夠紮實的基礎,只有夯實了基礎,才能從源碼中讀懂作者的意圖,明白作者編碼時的考量及取捨,之後轉化爲自己的思想,技術纔有長遠的成長

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章