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

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

前言

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

3.jpg

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

降龍.gif

前情鋪墊

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

1

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

未命名檔案.png

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 ? 異常在哪呢,我正常啟動也沒異常啊。

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

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

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

真相只有一個.gif


怎麼讓springboot啟動載入tomcat時出錯,都在jar包裡,也改不了程式碼啊,直接除錯原始碼?還是debug。不急。

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

來,我們試一下。將springboot配置檔案中的服務埠改成3306,啟動。

image.png

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

image.png

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

第一段是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,最終結合上面的異常棧,我們可以繪製出這麼一張時序圖。

tomcat.png


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

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

應用啟動的run方法呼叫了SpringApplication的一系列過載run方法之後
呼叫了SpringApplication的重新整理上下文方法和重新整理方法
再呼叫ServletWebServerApplicationContext的重新整理方法
ServletWebServerApplicationContext重新整理方法再呼叫內部的finishRefresh方法
finishRefresh呼叫內部的startWebServer方法
startWebServer內部呼叫TomcatWebServer的start方法啟動
複製程式碼

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

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

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

功法進階

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

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

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

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

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

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

e18d20c94006dfe0-9eef65073f0f6be0-335c5fd1b4bae44534eef19e66fb248b.jpg

先看張草圖

web請求草圖.png

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

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

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

爸爸去哪了.gif

確實這麼找,不好找。

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

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

答對了,請求所抵達的終點就是controller裡面宣告的方法。

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

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

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

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

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

image.png

來分析一波,上面的草圖中可以看到,執行緒中肯定會呼叫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進行替換分析。

image.png

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

image.png

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

image.png

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

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

image.png

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

結合起來畫個時序圖

springmvc.png

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

回過頭,在看下起始位置

image.png

是個執行緒,回想前情鋪墊裡的第2點,這就合理的解釋了為什麼是執行緒開頭,因為在tomcat處理請求時,開啟了執行緒,這個執行緒它有自己的JVM Stack,而這個請求處理的起點便是該執行緒的run方法。

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

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

收工

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

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

1

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

2

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

3

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

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

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

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

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

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