在討論完性能優化的方面和策略之后,這次我們的文章更偏向技術層面,來分享下如何開發一個自己的性能分析工具(基于JVM)。
『新』知識
考慮到咱們大多數還是開發業務為主,所以Java里面一些『鮮為人知』的API可能很多人都不知道,這里就簡單介紹一番,如果想深究的,就自己谷歌一下吧。
JVMTI(JVM Tool Interface)是 Java 虛擬機所提供的 native 編程接口,即底層的相關調試接口調用,我們熟知的Java調試其實也是基于它。
Instrumentation,雖然Java提供了JVMTI,但是對應的agent需要用C/C++開發,對Java開發者而言并不是非常友好。因此在Java SE 5的新特性中加入了Instrumentation機制。有了Instrumentation,開發者可以構建一個基于Java編寫的Agent來監控或者操作JVM了,比如替換或者修改某些類的定義等。
[圖片上傳失敗...(image-cb712e-1650620049744)]
有了上面兩個知識,其實我們就可以開發一個簡單的Agent了,Instrumentation可以理解為JVM層面的AOP(Aspect Oriented Programming),通過應用啟動時掛載Agent,我們可以對每一個class字節碼進行查看和修改。
ASM ASM是一種通用Java字節碼操作和分析框架,它可以用于修改現有的class文件或動態生成class文件,結合Instrumentation我們可以做到掛載Agent的時候,對字節碼進行修改,加上我們需要的性能監控手段。ASM的學習是有難度的,需要對字節碼有所了解,但由于其性能優秀,被各種工具作為修改字節碼的首選,比如大家熟悉的Cglib。
Javassist 依舊是一個字節碼的修改工具,但對初學者更加友好,不需要過多了解字節碼層面,可以書寫Java語法片段對已有class字節進行修改,缺點是過于模板化,難以優化,并且功能有限。我們做性能分析工具,本身是要盡可能減少插入字節碼對現有代碼的影響,并且注入的速度也要盡可能快,所以一般都會選擇ASM作為首選項。
好了,介紹完Instrumentation和ASM,我們是不是就可以滿足制作性能分析工具的前提條件了呢?你看我們通過Instrumentation進行JVM層面的AOP,再通過ASM對JAVA的字節碼進行修改,就可以著手完成性能分析最重要的埋點環節了。
看起來沒有錯,但是誰也不希望我們增強修改過的代碼一直存在內存中,分析一次就對環境造成不可逆的破壞吧。Instrumentation可以通過addTransformer添加字節碼轉換器,也可以將字節碼恢復原樣(只需要removeTransformer再retransformClasses就可以恢復了),但javaAgent畢竟是個單獨的jar包,它也會有一些依賴,將其加載進來必然會引發新的Class加載甚至是Class的沖突。那么新的問題就出來了,javaAgent如何不對現有的類有影響呢?
- ClassLoader 類加載器,我們可以采用一個新的類加載器,專門加載javaAgent里面的類庫,這樣就可以解決agent的類引發沖突的問題,在舊版本JDK中我們很難對ClassLoader做卸載,并且類的卸載是很麻煩的事情,限制很多,好在我們現在多數用的都是jdk1.8,只要遵循類卸載的規則,對ClassLoader進行清理還是很輕松的。
額外的類加載器實現了業務代碼和Agent代碼類的隔離,使它們可以安全引用包,并且可以對Agent的類進行卸載,但這樣同時引入了一個新的問題。類是隔離的,我在對業務代碼進行增強時,如何向agent代碼傳遞信息?增強的代碼一定是被加載在AppClassLoader里,如何與AgentClassLoader進行通訊呢?
- BootStrapClassLoader 啟動類加載器,該ClassLoader是JVM在啟動時創建的,理解這一部分知識,就一定要理解ClassLoader的雙親委派機制。我們可以創建一個非常簡單的Spy類和一個SpyHandler接口,Spy類定義好一些靜態方法用于代碼增強時調用,而SpyHandler則是定義一些用于通訊傳參的接口。我們將這兩個類打成jar包,并通過Instrumentation的appendToBootstrapClassLoaderSearch接口,在agent加載時引入BootStrapClassLoader類中,這樣我們在各個ClassLoader中都能訪問Spy類和SpyHandler接口了。
通過上面的介紹,我們現在可以動手做一個自己的APM工具了,通過Instrumentation+ASM,我們可以實現Class文件的修改增強,甚至可以修改JDK自帶的類比如String,通過自定義的ClassLoader我們可以隔離Agent的類和業務的類,通過打入BootStrap的Spy,我們可以實現跨ClassLoader之間的通訊。
萬事俱備,我們現在可以開始動手實現一個自己的APM工具了吧!
打住,其實上面這些功能不需要自己一一實現,我們不需要重復制造輪子,來自阿里開源項目JVM-SANDBOX此時華麗登場。這個項目屏蔽了ASM難以使用的缺點,也簡化了Instrumentation打樁過程,并且實現了ClassLoader的隔離,也有了BootStrapClassLoader中的Spy類,我們在此框架的基礎上進行開發更為簡單。
[圖片上傳失敗...(image-7a4a0f-1650620049744)]
原圖鏈接:https://github.com/alibaba/jvm-sandbox/wiki/img/jvm-sandbox-classloader.png
集『大』成
我們擁有了JVM-SANDBOX這一利器,似乎節約了我們很多的時間,我們現在終于可以著手性能分析了。
那么怎么進行性能分析呢?
我們可以引入Zipkin或者Jaeger作為收集者和UI展現,根據自己的喜好選擇一個好用的開源工具。通過sandbox提供的功能,我們可以很方便編寫埋點代碼,將我們的鏈路追蹤工具集成到Agent里面,最終實現無侵入的定制化鏈路追蹤。
通過集成ZipkinClient或者JaegerClient我們可以進行埋點收集,我們似乎把一些功能以搭積木的方式組裝起來,解決了一個頗為復雜的實現,這就是開源的魅力所在。其實在實際的過程中我們還遇到了一些困難,比如如何追蹤異步調用,如何追蹤跨線程的調用,如何處理線程池,如何處理ForkJoin?
其中最為復雜的是如何處理那些跨線程的派發,我們如何將鏈路的上下文在多個線程中傳遞。JDK的InheritableThreadLocal類可以完成父線程到子線程的值傳遞。但對于使用線程池等會池化復用線程的執行組件的情況,線程由線程池創建好,并且線程是池化起來反復使用的;這時父子線程關系的ThreadLocal值傳遞已經沒有意義,應用需要的實際上是把 任務提交給線程池時的ThreadLocal值傳遞到任務執行時。
說起來可能不好理解,總得來說無論是ThreadLocal還是InheritableThreadLocal都無法處理線程池或者ForkJoin帶來的線程復用的副作用,即無法有效準確安全的傳遞鏈路的上下文,不信大家可以試一試。
那么怎么解決這個問題呢?沒錯,就是修改JDK源碼,讓線程池在進行調度的時候具有安全準確傳遞上下文信息的能力,比如對Runnable和Callable接口進行增強處理,讓其可以攜帶線程的上下文。如果要對JDK的代碼進行增強,我們需要非常熟悉線程調度、線程池、Forkjoin的源碼,還需要小心處理值的傳遞確保安全,聽起來就很危險,也很困難。不用擔心我們不是第一次遇到這種問題的人,我們再次搬來了阿里的開源產品TTL,這個庫解決的就是上面描述的問題。
但是找到開源產品也并不一定能解決所有的問題,transmittable-thread-local雖然能夠解決線程復用時傳值的問題,但是它的實現對JDK代碼進行了『過分』的修改,以至于Instrumentation不能進行動態增強,它需要在啟動時未加載到ClassLoader的時候對JDK的源碼進行增強,并不能對已加載的JDK源碼進行動態增強,也就是說這種增強只能發生在一開始,不能發生在中間時間,且不可卸載。
這是因為Instrumentation的redefineClasses這個方法存在限制:重定義不得添加、移除、重命名字段或方法;不得更改方法簽名、繼承關系(不然那些商業的熱重載技術怎么賺錢。。)。而TTL的增強違反了這個原則,我們需要對其修改,并集成到Agent中。這個改造比較無趣也不好解說,可以直接看改造后的JVM-SANDBOX,我們為了后續使用方便,將TTL庫直接用BootStrapClassLoader加載了進去。
開源
最終開源的性能分析工具可以在這里找到:https://github.com/tmtbe/PVisualization,配合改造后的JVM-SANDBOX,可以實現360度無死角的性能鏈路追蹤分析,開發埋點也非常便捷,也無需考慮任何線程池的問題。有興趣的小伙伴可以試著使用一下,如有項目需要可以直接找我支持。
[圖片上傳失敗...(image-7b5fbd-1650620049744)]
原圖鏈接:https://github.com/tmtbe/PVisualization/raw/master/source/img.png
文/Thoughtworks張錦程
原文鏈接:https://insights.thoughtworks.cn/performance-turning-practice-3/