[ Coding七十二絕技 ] 如何利用Java異常快速分析源碼

前言

異常一個神奇的東西,讓廣大程序員對它人又愛又恨。
愛它,通過它能快速定位錯誤,經過層層磨難能學到很多逼坑大法。
恨他,快下班的時刻,週末的早晨,它踏着七彩雲毫無徵兆的來了。

今天,要聊的是它的一項神技 : 輔助源碼分析
對的,沒有聽錯,它有此功效,只不過我們被恨衝昏了頭腦,沒看到它的美。

前情鋪墊

講之前,先簡要鋪墊下需要用到的相關知識。

1

瞭解點jvm知識都應該知道每個線程有自己的JVM Stack,程序運行時,會將方法一個一個壓入棧,即棧幀,執行完再彈出棧。如下圖。不知道也沒關係,現在你也知道了,這是第一點。


Java中獲取線程的方法調用棧,可通過如下方式

 

public class Sample {

    public static void main(String[] args) {
        hello();
    }

    public static void  hello(){
       StackTraceElement[] traceElements = Thread.currentThread().getStackTrace();
       for(StackTraceElement traceElement : traceElements){
           System.err.println(traceElement.getMethodName());
       }
    }
}

輸出結果如下:

getStackTrace
hello
main

可以看到,通上面圖中的入棧過程是一致的,唯一區別是多了個getStackTrace的方法,因爲我們在hello方法內部調用了。也會入棧。

2

上面說了,是每個線程有自己的方法棧,所以如果在一個線程調用了另一個線程,那麼兩個線程有各自的方法棧。不廢話,上代碼。

public class Sample {

    public static void main(String[] args) {
        hello();

        System.err.println("--------------------");

        new Thread(){
            @Override
            public void run() {
                hello();
            }
        }.start();
    }

    public static void  hello(){
       StackTraceElement[] traceElements = Thread.currentThread().getStackTrace();
       for(StackTraceElement traceElement : traceElements){
           System.err.println("Thread:" + Thread.currentThread().getName() + " " + traceElement.getMethodName());
       }
    }
}

輸出結果如下:

Thread:main getStackTrace
Thread:main hello
Thread:main main
--------------------
Thread:Thread-0 getStackTrace
Thread:Thread-0 hello
Thread:Thread-0 run

可以看到,分別在主線程和新開的線程中調用了hello方法,輸出的調用棧是各自獨立的。

3

如果程序出現異常,會從出現異常的方法沿着調用棧逐步往回找,直到找到捕獲當前異常類型的代碼塊,然後輸出異常信息。代碼如下。

public class Sample {

    public static void main(String[] args) {
        hello();
    }

    public static void  hello(){
       int[] array = new int[0];
       array[1] = 1;
    }
}

方法執行後的異常如下

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 1
    at com.yuboon.fragment.exception.Sample.hello(Sample.java:15)
    at com.yuboon.fragment.exception.Sample.main(Sample.java:10)

對比上面第一點的執行結果,是不是有些相似。

好了,基礎知識先鋪墊到這。

基於上面的鋪墊,下來我們先快速試一把,看看效果。

小試牛刀

場景是這樣的,不知到大家是否瞭解springboot啓動時是如何加載嵌入的tomcat的,可能很多人專門看過,但估計這會也忘得差不多了。

下面我們利用異常來快速找到它的啓動加載邏輯。

what ? 異常在哪呢,我正常啓動也沒異常啊。

是滴,正常啓動是沒有,那我能不能讓它不正常啓動呢?

一個正常的情況下,異常都是被動出現的,也就是非編碼人員的主觀意願出來的。

現在我們要主動讓它出來,讓它來告訴我們一些真相。

怎麼讓springboot啓動加載tomcat時出錯,都在jar包裏,也改不了代碼啊,直接調試源碼?還是debug。不急。

我來告訴大家一個最簡單的方式,利用端口。也就是將tomcat的啓動端口改成一個已經被使用的端口,比如說你電腦現在運行着一個mysql服務,那我就讓tomcat監聽3306端口,這樣啓動一定會報端口被佔用異常。

來,我們試一下。將springboot配置文件中的服務端口改成3306,啓動。

哇哦,想要的異常出來了,多麼熟悉的畫面。

先大概解釋下這個異常信息,總體包含兩段異常信息。

第一段是springboot啓動時內部的異常棧信息,第二段是Tomcat內部加載的異常棧信息。
兩者關係就是,因爲Tomcat端口被佔用,拋出了端口被佔用異常,進而導致springboot啓動異常。兩段異常的銜接點就在整個異常信息的第一行和最後一行,即Connector.java:1008 Connector.java:1005 處。

圖中藍色標出的類是我們程序的運行起點。點進去看實際上就是run方法處出了異常。

@SpringBootApplication
public class FragmentExceptionApplicatioin {

    public static void main(String[] args) {
        SpringApplication.run(FragmentExceptionApplicatioin.class, args);
    }
}

既然是分析springboot是如何加載tomcat的,那麼主要分析第一段就OK了,第二段異常信息暫時就可以忽略。

下面我們仔細分析分析。回想前情鋪墊裏 [ 1 ][ 3 ] 部分的內容,再加上這個異常堆棧信息,我們就從這個中找到程序的執行順序,進而分析出核心執行流程。找到源碼內部的執行邏輯。

來一步步看下
經過上面的分析,實際上我們找到了程序運行的起點,即springboot的run方法。且稱爲起始位置
下面要找到終點,就是最上面的那一行,且稱爲終點位置

at org.apache.catalina.connector.Connector.startInternal(Connector.java:1008) ~[tomcat-embed-core-9.0.21.jar:9.0.21]

有了起點和終點,我們知道,兩點之間,線段最短。哦,跑題了。
是有了起點和終點,執行過程不就在中間嗎。

再一點點看,分析類圖可以看到AbstractApplicationContext和ServletWebServerApplicationContext是父子類,所以將出現AbstractApplicationContext的地方都替換爲爲ServletWebServerApplicationContext,最終結合上面的異常棧,我們可以繪製出這麼一張時序圖。



可以清楚的看到啓動時加載的過程。如何?清不清楚。

 

簡單組織語言表述一下主體流程,細節暫不展開描述。

應用啓動的run方法調用了SpringApplication的一系列重載run方法之後
調用了SpringApplication的刷新上下文方法和刷新方法
再調用ServletWebServerApplicationContext的刷新方法
ServletWebServerApplicationContext刷新方法再調用內部的finishRefresh方法
finishRefresh調用內部的startWebServer方法
startWebServer內部調用TomcatWebServer的start方法啓動

友情提醒
分析一個陌生框架的源碼,切勿一頭扎進細節,保你進去出來後一臉懵逼。應該先找到程序的執行主線,而找到主線的方法一個是官方文檔的相關介紹,一個是debug,而最直接有效的莫過於利用異常棧。

大家可以找一款框架親自試試看。
從此再也不怕面試官問我某某框架的執行原理了。

分析源碼時有了這個主線,再去分析裏面的細節就容易得多了。再也不怕debug進去後不知調用深淺,迷失在源碼當中

功法進階

上面只是小試牛刀,下面再看一個例子,通過異常分析下springmvc的執行過程。

呀,這可怎麼搞,上面造個啓動異常,端口重用還想了半天,這個異常要怎麼造。異常出在哪裏才能看到完整的異常棧呢?

不急,根據上面的兩點之間線段最短原理,那自然是找到程序執行的起始位置終點位置了。

這個場景控制器起點貌似在調用端呀。比如pc端?移動端發了個請求過來,那裏是起點呀,我去那裏搞麼。

要這麼複雜,我也就不寫這篇文章了。

媽媽呀,那怎麼搞,我好像有點懵逼了呢!

先看張草圖


不管是nio bio 又或是aio,服務端最終執行請求,必然會分配一個線程去做。

 

既然分析的是springmvc處理過程,也就是說從瀏覽器到tomcat這段我們是不用管的,我們只需要分析服務端線程調用springmvc方法後執行的這一段就可以了。

爸爸呀,服務端執行這個在tomcat裏面呀,我怎麼找。


確實這麼找,不好找。

 

上面說了先找到起點和終點,沒說兩個都要找到呀,既然起點在tomcat裏不好找,那終點能找到嗎?

我想想,終點難道是controller裏的方法嗎?

答對了,請求所抵達的終點就是controller裏面聲明的方法。

好的終點找到了,如何報錯,一時腦袋懵逼,哎,還是不習慣主動寫個異常,一時不知道代碼怎麼寫。

好吧,那我們就用兩行代碼來主動造個異常,異常水平的高低不要求,能出錯的異常就是好異常。嗯?好像是個病句,不重要。

@RequestMapping("/hello")
public String hello(String name){
        String nullObject = null;
        nullObject.toString();
        return "hello : " + name;
}

OK,寫完了,執行時第四行必報空指針錯誤,啓動測試一下唄。

噹噹噹當,看看,異常棧又來了,這次看着異常是否親切了些。

來分析一波,上面的草圖中可以看到,線程中肯定會調用springmvc的代碼,tomcat的一些處理我們可以忽略,直接從異常棧中找org,springframework包開頭的類信息。可以看到FrameworkServlet類是由tomcat進入springmvc框架的第一個類。調用它的是HttpServlet,再順着網上看,就可以看到DispatcherServlet,在未使用springboot之前,我們使用springmvc框架還需要在web.xml中添加配置

<servlet>
      <servlet-name>springmvc</servlet-name>
      <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
      <init-param>
          <param-name>contextConfigLocation</param-name>
          <param-value>classpath:spring-mvc.xml</param-value>
      </init-param>
  </servlet>
  <servlet-mapping>
      <servlet-name>springmvc</servlet-name>
      <url-pattern>/*</url-pattern>
  </servlet-mapping>

通過類關係分析,發現三者是繼承關係,DispatcherServlet爲最終子類。所以在隨後的異常棧分析中,我們可以使用子類去替換父類。也就是異常棧中出現FrameworkServlet、HttpServlet均可使用DispatcherServlet進行替換分析。

如此我們便找到了起始位置,那接下來的問題就是順着DispatcherServlet繼續往下分析。
下來需要確定真正的終點位置,上面不是確定了嗎?
上面所確定的終止位置並不是真正的終點位置,看下面這段異常

發現是個反射調用的異常,那就可以知道Controller的方法是通過反射調用的,我們排除JDK自身存在BUG的這種問題,所以這裏其實也可以忽略,那麼真正的終點位置就是調用反射代碼執行方法的那一行,在哪呢?在這

至此我們就可以鎖定終點位置是InvocableHandlerMethod.doInvoke

那麼剩下需要具體分析的過程如下圖,也就是搞清楚這幾個方法間的調用關係,處理邏輯,基本上就搞清楚了springmvc是如何接受處理一個請求的邏輯。

再次分析處理類的類圖圖發現
RequestMappingHandlerAdapter爲AbstractHandlerMethodAdapter的子類。
ServletInvocableHandlerMethod爲InvocableHandlerMethod的子類。
同上面一樣,存在父子關係,用最終子類替換父類進行分析。
所以異常棧中出現AbstractHandlerMethodAdapter的地方都可使用RequestMappingHandlerAdapter進行替換。
異常棧中出現InvocableHandlerMethod的地方都可使用ServletInvocableHandlerMethod進行替換。

結合起來畫個時序圖bstractHandlerMethodAdapt

這樣看執行過程是不清楚了許多。簡要語言表述此處就免了。

回過頭,在看下起始位置

是個線程,回想前情鋪墊裏的第 [ 2 ] 點,這就合理的解釋了爲什麼是線程開頭,因爲在tomcat處理請求時,開啓了線程,這個線程它有自己的JVM Stack,而這個請求處理的起點便是該線程的run方法。

具體代碼內部細節根據實際情況具體分析,需要注意的是子類上的方法有些繼承自父類或直接調用的父類,分析的時候爲了結構清晰我們將父類全部換成了子類,所以這個在具體分析代碼的時候需要注意直接看子類可能會找不到一些方法,需要結合父類去看,這裏就不帶大家一行一行去分析了,不然我該寫到天亮去了,此文的關鍵是提供一種思路。

等等,這只是請求接受到處理,數據是如何組裝返回前臺的,響應處理呢? 怎麼沒看到,確實。這個流程裏沒有,那如何能看到請求響應的處理流程能,很簡單,只需要在數據返回時造個異常就行了。怎麼造?自己不妨琢磨琢磨先。

收工

希望通過此文能幫你在源碼分析的道路上走的容易些,也希望大家在看到異常不光有恨意,還帶有一絲絲愛意,那我寫這篇文章的目的就達到了。

再送大家修煉此功法的三點關鍵祕訣

1

此功法法成功的關鍵是找到正確的異常棧輸出位置,通常情況下是程序執行邏輯終點的那個方法。

2

多找幾個框架,多找幾個場景,去適應這種思路,所謂孰能生巧。

3

注意抽象類和其子類,分析時出現抽象類的地方都可使用子類進行替換

友情提醒
此功法還可用在項目業務場景下,剛接手了新的項目,不知如何下手,找不到執行邏輯?debug半天還是沒有頭緒,不妨試試此法。

它踩着七彩雲走了,留給我們無盡的遐想。不行,我得趕緊找個框架試一波。

此文風,第一次嘗試,如果覺得不錯不妨動動手指點個小贊,鼓勵下作者,我會努力多寫幾篇。

如果覺得一般,麼關係,我還有屌絲系列,少女系列,油膩男系列等風格。

此文結束,然而精彩故事未完……..

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