前言
在前說明:很久沒有更新博客了,這一年在公司作了好多事情,包括代碼分析和熱部署替換等黑科技,一直沒有時間來進行落地寫出一些一文章來,甚是惋惜,趁着中午睡覺的時間補一篇介紹性的文章吧。html
首先熱部署的場景是這樣的,公司的項目很是多,真個BU事業部的項目加起來大約上幾百個項目了,有一些項目本地沒法正常啓動,因此一些同窗在修改完代碼,或者是在普通的常規任務開發過程當中都是盲改,而後去公司的代碼平臺進行發佈,噁心的事情就在這裏,有的一些項目從構建到發佈運行大約30分鐘,因此每次修改代碼到代碼見效須要30分鐘的週期,這個極大的下降了公司的開發效率,一旦惰性成習慣,改變起來將十分的困難,因此咱們極須要一個在本地修改完代碼以後,能夠秒級在服務端生效的神器,這樣,咱們的熱部署插件就誕生了。java
熱部署在業界自己就是一個難啃的骨頭,屬於逆向編程的範疇,JVM有類加載,那麼熱部署就要去作卸載後從新加載,Spring有上下文註冊,spring Bean執行初始化生命週期,熱部署就要去作類的銷燬,從新初始化,裏面設計到的細節點很是之多,業界的幾款熱部署的處理方式也不盡相同,因爲須要巨大的底層細節須要處理,因此目前上想找到一個徹底覆蓋全部功能的熱部署插件是幾乎不可能的,通常你們聽到的熱部署插件主要是國外的一些項目好比商業版本的jrebel,開源版的springloaded,以及比較粗暴的spring dev tools。當前這些項目都是現成的複雜開源項目或者是閉包的商業項目,想去自行修改匹配本身公司的項目,難度是很是之大。閒話少說,進入正文程序員
前言一:什麼是熱部署
所謂熱部署,就是在應用正在運行的時候升級軟件,卻不須要從新啓動應用。對於Java應用程序來講,熱部署就是在運行時更新Java類文件,同時觸發spring的一些列從新加載過程。在這個過程當中不須要從新啓動,而且修改的代碼實時生效web
前言二:爲何咱們須要熱部署
程序員天天本地重啓服務5-12次,單次大概3-8分鐘,天天向Cargo部署3-5次,單次時長20-45分鐘,部署頻繁頻次高、耗時長。插件提供的本地和遠程熱部署功能可以讓將代碼變動秒級生效,RD平常工做主要分爲開發自測和聯調兩個場景,下面分別介紹熱部署在每一個場景中發揮的做用:spring
前言三:熱部署難在哪,爲何業界沒有好用的開源工具
熱部署不等同於熱重啓,像tomcat或者spring boot tool dev這種熱重啓至關於直接加載項目,性能較差,增量文件熱部署難度很大,須要兼容各類中間件和用戶寫法,技術門檻高,須要對JPDA(Java Platform Debugger Architecture)、java agent、字節碼加強、classloader、spring框架、Mybatis框架等集成解決方案等各類技術原理深刻了解才能全面支持各類框架,另外須要IDEA插件開發能力,造成總體的產品解決方案。如今有了熱部署,代碼就是任人打扮的小姑娘!sql
前言四:爲何咱們不用spring boot devtools
有一些朋友問我,爲何不直接使用spring boot devtools,有兩方面緣由吧,第一它僅僅只使用在spring boot項目中,對於普通的java項目以及spring xml項目是不支持的,最主要的第二點它的熱加載方案實際上和tomcat熱加載是同樣的,只不過它的熱加載經過嵌套classloader的方式來完成,這個classloader每次只加載class file變動的class二進制文件,這樣就會來帶一個問題,在很是龐大的項目面前(啓動大約10min+)這種狀況,它就顯得很蒼白。這歸根結底的緣由是在於他的reload範圍實在是太大了,對於一些小項目還能夠,可是一些比較龐大的項目實際使用效果仍是很是感人的。apache
一、總體設計方案
二、走進agent
instrument 規範:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html?is-external=true編程
Class VirtualMachine:https://docs.oracle.com/javase/8/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html#loadAgent-java.lang.String-api
Interface ClassFileTransformer:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/ClassFileTransformer.html數組
2.一、JVM啓動前靜態Instrument
Javaagent是java命令的一個參數。參數 javaagent 能夠用於指定一個 jar 包,而且對該 java 包有2個要求:
-
這個 jar 包的 MANIFEST.MF 文件必須指定 Premain-Class 項。
-
Premain-Class 指定的那個類必須實現 premain() 方法。
premain 方法,從字面上理解,就是運行在 main 函數以前的的類。當Java 虛擬機啓動時,在執行 main 函數以前,JVM 會先運行-javaagent所指定 jar 包內 Premain-Class 這個類的 premain 方法 。
在命令行輸入 java能夠看到相應的參數,其中有 和 java agent相關的:
-agentlib:<libname>[=<選項>] 加載本機代理庫 <libname>, 例如 -agentlib:hprof 另請參閱 -agentlib:jdwp=help 和 -agentlib:hprof=help -agentpath:<pathname>[=<選項>] 按完整路徑名加載本機代理庫 -javaagent:<jarpath>[=<選項>] 加載 Java 編程語言代理, 請參閱 java.lang.instrument
該包提供了一些工具幫助開發人員在 Java 程序運行時,動態修改系統中的 Class 類型。其中,使用該軟件包的一個關鍵組件就是 Javaagent。從名字上看,彷佛是個 Java 代理之類的,而實際上,他的功能更像是一個Class 類型的轉換器,他能夠在運行時接受從新外部請求,對Class類型進行修改。
agent加載時序圖
從本質上講,Java Agent 是一個遵循一組嚴格約定的常規 Java 類。 上面說到 javaagent命令要求指定的類中必需要有premain()方法,而且對premain方法的簽名也有要求,簽名必須知足如下兩種格式:
public static void premain(String agentArgs, Instrumentation inst) public static void premain(String agentArgs)
JVM 會優先加載 帶 Instrumentation 簽名的方法,加載成功忽略第二種,若是第一種沒有,則加載第二種方法。這個邏輯在sun.instrument.InstrumentationImpl
2.二、Instrumentation類經常使用API
public interface Instrumentation { //增長一個Class 文件的轉換器,轉換器用於改變 Class 二進制流的數據,參數 canRetransform 設置是否容許從新轉換。 void addTransformer(ClassFileTransformer transformer, boolean canRetransform); //在類加載以前,從新定義 Class 文件,ClassDefinition 表示對一個類新的定義, 若是在類加載以後,須要使用 retransformClasses 方法從新定義。addTransformer方法配置以後,後續的類加載都會被Transformer攔截。 對於已經加載過的類,能夠執行retransformClasses來從新觸發這個Transformer的攔截。類加載的字節碼被修改後,除非再次被retransform,不然不會恢復。 void addTransformer(ClassFileTransformer transformer); //刪除一個類轉換器 boolean removeTransformer(ClassFileTransformer transformer); //是否容許對class retransform boolean isRetransformClassesSupported(); //在類加載以後,從新定義 Class。這個很重要,該方法是1.6 以後加入的,事實上,該方法是 update 了一個類。 void retransformClasses(Class<?>... classes) throws UnmodifiableClassException; //是否容許對class從新定義 boolean isRedefineClassesSupported(); //此方法用於替換類的定義,而不引用現有的類文件字節,就像從源代碼從新編譯以進行修復和繼續調試時所作的那樣。 //在要轉換現有類文件字節的地方(例如在字節碼插裝中),應該使用retransformClasses。 //該方法能夠修改方法體、常量池和屬性值,但不能新增、刪除、重命名屬性或方法,也不能修改方法的簽名 void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException; //獲取已經被JVM加載的class,有className可能重複(可能存在多個classloader) @SuppressWarnings("rawtypes") Class[] getAllLoadedClasses(); }
2.三、instrument原理:
instrument的底層實現依賴於JVMTI(JVM Tool Interface),它是JVM暴露出來的一些供用戶擴展的接口集合,JVMTI是基於事件驅動的,JVM每執行到必定的邏輯就會調用一些事件的回調接口(若是有的話),這些接口能夠供開發者去擴展本身的邏輯。JVMTIAgent是一個利用JVMTI暴露出來的接口提供了代理啓動時加載(agent on load)、代理經過attach形式加載(agent on attach)和代理卸載(agent on unload)功能的動態庫。而instrument agent能夠理解爲一類JVMTIAgent動態庫,別名是JPLISAgent(Java Programming Language Instrumentation Services Agent),也就是專門爲java語言編寫的插樁服務提供支持的代理。
2.3.一、啓動時加載instrument agent過程:
-
建立並初始化 JPLISAgent;
-
監聽 VMInit 事件,在 JVM 初始化完成以後作下面的事情:
-
建立 InstrumentationImpl 對象 ;
-
監聽 ClassFileLoadHook 事件 ;
-
調用 InstrumentationImpl 的loadClassAndCallPremain方法,在這個方法裏會去調用 javaagent 中 MANIFEST.MF 裏指定的Premain-Class 類的 premain 方法 ;
-
-
解析 javaagent 中 MANIFEST.MF 文件的參數,並根據這些參數來設置 JPLISAgent 裏的一些內容。
2.3.二、運行時加載instrument agent過程:
經過 JVM 的attach機制來請求目標 JVM 加載對應的agent,過程大體以下:
-
建立並初始化JPLISAgent;
-
解析 javaagent 裏 MANIFEST.MF 裏的參數;
-
建立 InstrumentationImpl 對象;
-
監聽 ClassFileLoadHook 事件;
-
調用 InstrumentationImpl 的loadClassAndCallAgentmain方法,在這個方法裏會去調用javaagent裏 MANIFEST.MF 裏指定的Agent-Class類的agentmain方法。
2.3.三、Instrumentation的侷限性
大多數狀況下,咱們使用Instrumentation都是使用其字節碼插樁的功能,或者籠統說就是類重定義(Class Redefine)的功能,可是有如下的侷限性:
-
premain和agentmain兩種方式修改字節碼的時機都是類文件加載以後,也就是說必需要帶有Class類型的參數,不能經過字節碼文件和自定義的類名從新定義一個原本不存在的類。
-
類的字節碼修改稱爲類轉換(Class Transform),類轉換其實最終都回歸到類重定義Instrumentation#redefineClasses()方法,此方法有如下限制:
-
新類和老類的父類必須相同;
-
新類和老類實現的接口數也要相同,而且是相同的接口;
-
新類和老類訪問符必須一致。 新類和老類字段數和字段名要一致;
-
新類和老類新增或刪除的方法必須是private static/final修飾的;
-
能夠修改方法體。
-
除了上面的方式,若是想要從新定義一個類,能夠考慮基於類加載器隔離的方式:建立一個新的自定義類加載器去經過新的字節碼去定義一個全新的類,不過也存在只能經過反射調用該全新類的侷限性。
2.四、那些年JVM和Hotswap之間的相愛相殺
圍繞着method body的hotSwap JVM一直在進行改進
1.4開始JPDA引入了hotSwap機制(JPDA Enhancements),實現了debug時的method body的動態性
參照:https://docs.oracle.com/javase/8/docs/technotes/guides/jpda/enhancements1.4.html
1.5開始經過JVMTI實現的java.lang.instrument (Java Platform SE 8 ) 的premain方式,實現了agent方式的動態性(JVM啓動時指定agent)
參照:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html
1.6又增長了agentmain方式,實現了運行時動態性(經過The Attach API 綁定到具體VM)。
參照:https://blogs.oracle.com/corejavatechtips/the-attach-api
其基本實現是經過JVMTI的retransformClass/redefineClass進行method body級的字節碼更新,ASM、CGLib之類基本都是圍繞這些在作動態性。
可是針對Class的hotSwap一直沒有動做(好比Class添加method,添加field,修改繼承關係等等),爲何?由於複雜度高而且沒有過高的回報。
2.五、如何解決Instrumentation的侷限性
因爲JVM限制,JDK7和JDK8都不容許都改類結構,好比新增字段,新增方法和修改類的父類等,這對於spring項目來講是致命的,假設小龔同窗想修改一個spring bean,新增了一個@Autowired字段,這種場景在實際應用時不少,因此咱們對這種場景的支持必不可少。
那麼咱們是如何作到的呢,下面有請大名鼎鼎的dcevm,dcevm(DynamicCode Evolution Virtual Machine)是java hostspot的補丁(嚴格上來講是修改),容許(並不是無限制)在運行環境下修改加載的類文件.當前虛擬機只容許修改方法體(method bodies),decvm,能夠增長 刪除類屬性、方法,甚至改變一個類的父類、dcevm 是一個開源項目,聽從GPL 2.0、更多關於dcevm的介紹:
https://www.cnblogs.com/redcreen/archive/2011/06/03/2071169.html
https://www.slideshare.net/wangscu/hotspot-hotswap-who-and-who-are-best-freinds
https://www.cnblogs.com/redcreen/archive/2011/06/14/2080718.html
https://dl.acm.org/doi/10.1145/2076021.2048129
http://ssw.jku.at/Research/Papers/Wuerthinger11PhD/
http://ssw.jku.at/Research/Papers/Wuerthinger10a/
https://dl.acm.org/doi/10.1145/1868294.1868312
https://dl.acm.org/doi/10.1145/1890683.1890688
三、熱部署技術解析
3.一、文件監聽
熱部署啓動時首先會在本地和遠程預約義兩個目錄,/var/tmp/xxx/extraClasspath和/var/tmp/xxx/classes,extraClasspath爲咱們自定義的拓展classpath url,classes爲咱們監聽的目錄,當有文件變動時,經過idea插件來部署到遠程/本地,觸發agent的監聽目錄,來繼續下面的熱加載邏輯,爲何咱們不直接替換用戶的classPath下面的資源文件呢,由於業務方考慮到war包的api項目,和spring boot項目,都是以jar包來啓動的,這樣咱們是沒法直接修改用戶的class文件的,即便是用戶項目咱們能夠修改,直接操做用戶的class,也會帶來一系列的安全問題,因此咱們採用了拓展classPath url來實現文件的修改和新增,而且有這麼一個場景,多個業務側的項目引入了相同的jar包,在jar裏面配置了mybatis的xml和註解,這種狀況咱們沒有辦法直接來修改jar包中源文件,經過拓展路徑的方式能夠不須要關注jar包來修改jar包中某一文件和xml,是否是很炫酷,同理這種方法能夠進行整個jar包的熱替換(方案設計中)。下面簡單介紹一下核心監聽器,
3.二、jvm class reload
JVM的字節碼批量重載邏輯,經過新的字節碼二進制流和舊的class對象生成ClassDefinition定義,instrumentation.redefineClasses(definitions),來觸發JVM重載,重載事後將觸發初始化時spring插件註冊的transfrom,下一章咱們簡單講解一下spring是怎麼重載的。
新增class咱們如何保證能夠加載到classloader上下文中?因爲項目在遠程執行,因此運行環境複雜,有多是jar包方式啓動(spring boot),也有多是普通項目,也有多是war web項目,針對這種狀況咱們作了一層classloader url拓展
User classLoader是框架自定義的classLoader統稱,例如Jetty項目是WebAppclassLoader,其中Urlclasspath爲當前項目的lib文件件下,例如spring boot項目也是從當前項目中BOOT-INF/lib/,等等,不一樣框架的自定義位置稍有不一樣。因此針對這種狀況 咱們必須拿到用戶的自定義classloader,若是常規方式啓動的,好比普通spring xml項目藉助plus發佈,這種沒有自定義classloader,是默認AppClassLoader,因此咱們在用戶項目啓動過程當中藉助agent字節碼加強的方式來獲取到真正的用戶classloader。
咱們作的事情:找到用戶使用的子classloader以後經過反射的方式來獲取classloader中的元素Classpath,其中classPath中的URL就是當前項目加載class時須要的全部運行時class環境,而且包括三方的jar包依賴等。
咱們獲取到URL數組,把咱們自定義的拓展classpath目錄加入到URL數組的首位,這樣當有新增class時,咱們只須要將class文件放到拓展classpath對應的包目錄下面便可,當有其餘bean依賴新增的class時,會從當前目錄下面查找類文件。
爲何不直接對Appclassloader進行增強?而是對框架的自定義classloader進行增強
考慮這樣一個場景,框架自定義類加載器中有ClassA,而後這個時候用戶新增了一個Class B須要熱加載,B class裏面有A的引用關係,若是咱們加強AppClassLoader時,初始化B實例時ClassLoader.loadclass首先從UserClassLoader開始找classB,依靠雙親委派原則,B是被Appclassloader加載的,由於B依賴了類A,因此當前AppClassLoader加載B必定是找不到的,這個時候彙報ClassNotFoundException。也就是說咱們對類加載器拓展必定要拓展最上層的類加載器,這樣纔會達到咱們想要的效果。
3.三、spring bean重載
spring bean reload過程當中,bean的銷燬和重啓流程,其中細節點涉及的比較多。主要內容以下圖展現:
首先當修改java class D時,經過spring classpathScan掃描校驗當前修改的bean是不是spring bean(註解校驗)而後觸發銷燬流程(BeanDefinitionRegistry.removeBeanDefinition)此方法會將當前spring 上下文中的 bean D 和依賴 spring bean D的 Bean C 一併銷燬,可是做用範圍僅僅在當前spring 上下文,若C被子上下文中的Bean B 依賴,是沒法更新子上下文中的依賴關係的,此時,當有流量打進來,Bean B中關聯的Bean C仍是熱部署以前的對象,因此熱部署失敗,因此咱們在spring初始化過程當中,須要維護一個父子上下文的對應關係,當子上下文變時若變動範圍涉及到Bean B時,須要從新更新子上下文中的依賴關係,因此當有多上下文關聯時須要維護多上下文環境,而且當前上下文環境入口須要reload。入口指:spring mvc controller,Mthrift和pigeon,對不一樣的流量入口,咱們採用不一樣的reload策略。RPC框架入口主要操做爲解綁註冊中心,從新註冊,從新加載啓動流程等,對Spring mvc controller主要是解綁和註冊url Mappping來實現流量入口類的變化切換
3.四、spring xml重載
當用戶修改/新增spring xml時,須要對xml中全部bean進行重載
從新reload以後,將spring 銷燬後重啓。
注意:xml修改方式改動較大,可能涉及到全局的Aop的配置以及前置和後置處理器相關的內容,影響範圍爲全局,因此目前只放開普通的xml bean標籤的新增/修改,其餘能力酌情逐步放開。
3.五、mybatis xml 重載
四、遠程反編譯
在代碼中經過插件右鍵-遠程反編譯便可查看當前classpath下面最新編譯的最新class文件,這是如何辦到的的呢,核心代碼以下:
agentString+= "try {\n" + "\t\t\tjava.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();\n" + "\t\t\tjava.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.config.PluginManager\" );\n" + "\t\t\tjava.lang.reflect.Method method = clazz.getDeclaredMethod ( \"enhanceUserClassLoader\",new java.lang.Class[0]);\n" + "\t\t\tmethod.setAccessible ( true );\n" + "\t\t\tmethod.invoke ( null, new Object[0]);\n" + "\t\t} catch (java.lang.Exception e){\n" + "\t\t\te.printStackTrace ( );\n" + "\t\t}";
上面代碼是在用戶側啓動DefaultListableBeanFactory時,初始化全部bean以後完成的,在方法preInstantiateSingletons以後會對當前用戶側classloader進行反向持有+ 路徑加強。
public static void enhanceUserClassLoader(){ if(springbootClassLoader != null){ LOGGER.info ( "對用戶classloader進行加強,springbootClassLoader:" + springbootClassLoader ); URLClassLoaderHelper.prependClassPath ( springbootClassLoader ); LOGGER.info ( "對用戶classloader進行加強成功,springbootClassLoader:" + springbootClassLoader ); } }
經過使用代碼啓動時反射加強classloader,下面來看看核心方法prependClassPath
public static void prependClassPath(ClassLoader classLoader){ LOGGER.info ( "用戶classloader加強,classLoader:" + classLoader ); if(!(classLoader instanceof URLClassLoader)){ return; } URL[] extraClasspath = PropertiesUtil.getExtraClasspath (); prependClassPath( (URLClassLoader) classLoader,extraClasspath); }
其中URL[] extraClasspath = PropertiesUtil.getExtraClasspath ();這裏獲取的是用戶自定義的classpath,每次新增修改class以後都會放進去最新的資源文件。
public static void prependClassPath(URLClassLoader classLoader, URL[] extraClassPath) { synchronized (classLoader) { try { Field ucpField = URLClassLoader.class.getDeclaredField("ucp"); ucpField.setAccessible(true); URL[] origClassPath = getOrigClassPath(classLoader, ucpField); URL[] modifiedClassPath = new URL[origClassPath.length + extraClassPath.length]; System.arraycopy(extraClassPath, 0, modifiedClassPath, 0, extraClassPath.length); System.arraycopy(origClassPath, 0, modifiedClassPath, extraClassPath.length, origClassPath.length); Object urlClassPath = createClassPathInstance(modifiedClassPath); ExtraURLClassPathMethodHandler methodHandler = new ExtraURLClassPathMethodHandler(modifiedClassPath); ((Proxy)urlClassPath).setHandler(methodHandler); ucpField.set(classLoader, urlClassPath); LOGGER.debug("Added extraClassPath URLs {} to classLoader {}", Arrays.toString(extraClassPath), classLoader); } catch (Exception e) { LOGGER.error("Unable to add extraClassPath URLs {} to classLoader {}", e, Arrays.toString(extraClassPath), classLoader); } } }
只需關注
URL[] origClassPath = getOrigClassPath(classLoader, ucpField);
URL[] modifiedClassPath = new URL[origClassPath.length + extraClassPath.length];
System.arraycopy(extraClassPath, 0, modifiedClassPath, 0, extraClassPath.length);
System.arraycopy(origClassPath, 0, modifiedClassPath, extraClassPath.length, origClassPath.length);這幾行代碼
首先獲取到用戶側classloader中URLClassPath的URLS,而後在經過反射的方式將用戶配置的extclasspath的路徑設置到URLS數組中的首位,這樣每次調用URLClassLoader的findResource方法都會獲取到最新的資源文件了。
五、咱們支持的功能
功能點 |
是否支持 |
---|---|
修改方法體內容 |
✅ |
新增方法體 |
✅ |
新增非靜態字段 |
✅ |
新增靜態字段 |
✅ |
spring bean中新增@autowired註解 |
✅ |
在spring 掃描包base package下,新增帶@Service的bean,而且注入 |
✅ |
新增xml |
✅ |
增長修改靜態塊 |
✅ |
新增修改匿名內部類 |
✅ |
新增修改繼承類 |
✅ |
新增修改接口方法 |
✅ |
新增泛型方法 |
✅ |
修改 annotation sql(Mybatis) |
✅ |
修改 xml sql(Mybatis) |
✅ |
增長修改靜態塊 |
✅ |
匿名內部類新增,修改 |
✅ |
內部類新增,修改 |
✅ |
新增,刪除extend父類,implement 接口 |
✅ |
父類或接口新增方法,刪除方法 |
✅ |
泛型方法,泛型類 |
✅ |
多文件熱部署 |
✅ |
spring boot項目 |
✅ |
war包項目 |
✅ |
修改spring xml (只修改bean標籤) |
✅ |
✅ |
|
pigeon服務框架 |
✅ |
@Transactional 註解新增/修改,註解參數修改 |
✅ |
序列化 框架支持 | ✅ |
dubbo alibaba | ✅ |
dubbo apache | ✅ |
dubbox | ✅ |
motan | ✅ |
刪除繼承的class |
❌ |
枚舉 字段修改 |
❌ |
修改static字段值 |
❌ |
其餘功能迭代挖掘ing |
☺ |
六、強大到使人窒息的多文件熱部署以及源碼交流
因爲篇幅緣由和文采捉急,沒有辦法完整的寫出熱部署過程當中遇到的各類各樣稀奇古怪和沒法解釋的問題,和其中的坎坷經歷。更多的功能需求迭代建議和agent源碼技術交流能夠加入QQ羣來詳細交流,QQ羣號:825199617