1. 程式人生 > >[ Coding七十二絕技 ] 如何利用Java異常快速分析原始碼

[ 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半天還是沒有頭緒,不妨試試此法。

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

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

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

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