如何利用快取機制實現JAVA類反射效能提升30倍
一次效能提高30倍的JAVA類反射效能優化實踐
文章來源:宜信技術學院 & 宜信支付結算團隊技術分享第4期-支付結算部支付研發團隊高階工程師陶紅《JAVA類反射技術&優化》
分享者:宜信支付結算部支付研發團隊高階工程師陶紅
原文首發於宜信支付結算技術團隊公號:野指標
在實際工作中的一些特定應用場景下,JAVA類反射是經常用到、必不可少的技術,在專案研發過程中,我們也遇到了不得不運用JAVA類反射技術的業務需求,並且不可避免地面臨這個技術固有的效能瓶頸問題。
通過近兩年的研究、嘗試和驗證,我們總結出一套利用快取機制、大幅度提高JAVA類反射程式碼執行效率的方法,和沒有優化的程式碼相比,效能提高了20~30倍。本文將與大家分享在探索和解決這個問題的過程中的一些有價值的心得體會與實踐經驗。
簡述:JAVA類反射技術
首先,用最簡短的篇幅介紹JAVA類反射技術。
如果用一句話來概述,JAVA類反射技術就是:
繞開編譯器,在執行期直接從虛擬機器獲取物件例項/訪問物件成員變數/呼叫物件的成員函式。
抽象的概念不多講,用程式碼說話……舉個例子,有這樣一個類:
public class ReflectObj { private String field01; public String getField01() { return this.field01; } public void setField01(String field01) { this.field01 = field01; } }
如果按照下列程式碼來使用這個類,就是傳統的“建立物件-呼叫”模式:
ReflectObj obj = new ReflectObj(); obj.setField01("value01"); System.out.println(obj.getField01());
如果按照如下程式碼來使用它,就是“類反射”模式:
// 直接獲取物件例項 ReflectObj obj = ReflectObj.class.newInstance(); // 直接訪問Field Field field = ReflectObj.class.getField("field01"); field.setAccessible(true); field.set(obj, "value01"); // 呼叫物件的public函式 Method method = ReflectObj.class.getMethod("getField01"); System.out.println((String) method.invoke(obj));
類反射屬於古老而基礎的JAVA技術,本文不再贅述。
從上面的程式碼可以看出:
- 相比較於傳統的“建立物件-呼叫”模式,“類反射”模式的程式碼更抽象、一般情況下也更加繁瑣;
- 類反射繞開了編譯器的合法性檢測——比如訪問了一個不存在的欄位、呼叫了一個不存在或不允許訪問的函式,因為編譯器設立的防火牆失效了,編譯能夠通過,但是執行的時候會報錯;
- 實際上,如果按照標準模式編寫類反射程式碼,效率明顯低於傳統模式。在後面的章節會提到這一點。
緣起:為什麼使用類反射
前文簡略介紹了JAVA類反射技術,在與傳統的“建立物件-呼叫”模式對比時,提到了類反射的幾個主要弱點。但是在實際工作中,我們發現類反射無處不在,特別是在一些底層的基礎框架中,類反射是應用最為普遍的核心技術之一。最常見的例子:Spring容器。
這是為什麼呢?我們不妨從實際工作中的具體案例出發,分析類反射技術的不可替代性。
大家幾乎每天都和銀行打交道,通過銀行進行存款、轉帳、取現等金融業務,這些動賬操作都是通過銀行核心系統(包括交易核心/賬務核心/對外支付/超級網銀等模組)完成的,因為歷史原因造成的技術路徑依賴,銀行核心系統的報文幾乎都是xml格式,而且以這種格式最為普遍:
<?xml version='1.0' encoding='UTF-8'?> <service> <sys-header> <data name="SYS_HEAD"> <struct> <data name="MODULE_ID"> <field type="string" length="2">RB</field> </data> <data name="USER_ID"> <field type="string" length="6">OP0001</field> </data> <data name="TRAN_TIMESTAMP"> <field type="string" length="9">003026975</field> </data> <!-- 其它欄位略過 --> </struct> </data> </sys-header> <!-- 其它段落略過 --> <body> <data name="REF_NO"> <field type="string" length="23">OPS18112400302633661837</field> </data> </body> </service>
和常用的xml格式進行對比:
<?xml version="1.0" encoding="UTF-8"?> <recipe> <recipename>Ice Cream Sundae</recipename> <ingredlist> <listitem> <quantity>3</quantity> <itemdescription>chocolate syrup or chocolate fudge</itemdescription> </listitem> <listitem> <quantity>1</quantity> <itemdescription>nuts</itemdescription> </listitem> <listitem> <quantity>1</quantity> <itemdescription>cherry</itemdescription> </listitem> </ingredlist> <preptime>5 minutes</preptime> </recipe>
銀行核心系統的xml報文不是用標籤的名字區分元素,而是用屬性(name屬性)區分,在解析的時候,不管是用DOM、SAX,還是Digester或其它方案,都要用條件判斷語句、分支處理,虛擬碼如下:
// …… 介面類例項 obj = new 介面類(); List<Node> nodeList = 獲取xml標籤列表 for (Node node: nodeList) { if (node.getProperty("name") == "張三") obj.set張三 (node.getValue()); else if (node.getProperty("name") == "李四") obj.set李四 (node.getValue()); // …… } // ……
顯而易見,這樣的程式碼非常粗劣、不優雅,每解析一個介面的報文,都要寫一個專門的類或者函式,堆砌大量的條件分支語句,難寫、難維護。如果報文結構簡單還好,如果有一百個甚至更多的欄位,怎麼辦?毫不誇張,在實際工作中,我遇到過一個銀行核心介面有140多個欄位的情況,而且這還不是最多的!
試水:優雅地解析XML
當我們碰到這種結構的xml、而且欄位還特別多的時候,解決問題的鑰匙就是類反射技術,基本思路是:
- 從xml中解析出欄位的name和value,以鍵值對的形式儲存起來;
- 用類反射的方法,用鍵值對的name找到欄位或欄位對應的setter(這是有規律可循的);
- 然後把value直接set到欄位,或者呼叫setter把值set到欄位。
介面類應該是這樣的結構:
- nodes是儲存欄位的name-value鍵值對的列表,MessageNode就是鍵值對,結構如下:
public class MessageNode { private String name; private String value; public String getName() { return name; } public void setName(String name) { this.name = name; } public String getValue() { return value; } public void setValue(String value) { this.value = value; } public MessageNode() { super(); } }
- createNode是在解析xml的時候,把鍵值對新增到列表的函式;
- initialize是用類反射方法,根據鍵值對初始化每個欄位的函式。
這樣,解析xml的程式碼可以變得非常優雅、簡潔。如果用Digester解析之前列舉的那種格式的銀行報文,可以這樣寫:
Digester digester = new Digester(); digester.setValidating(false); digester.addObjectCreate("service/sys-header", SysHeader.class); digester.addCallMethod("service/sys-header/data/struct/data", "createNode", 2); digester.addCallParam("service/sys-header/data/struct/data", 0, "name"); digester.addCallParam("service/sys-header/data/struct/data/field", 1); parseObj = (SysHeader) digester.parse(new StringReader(msg)); parseObj.initialize();
initialize函式的程式碼,可以寫在一個基類裡面,子類繼承基類即可。具體程式碼如下:
public void initialize() { for (MessageNode node: nodes) { try { /** * 直接獲取欄位、然後設定欄位值 */ //String fieldName = StringUtils.camelCaseConvert(node.getName()); // 只獲取呼叫者自己的field(private/protected/public修飾詞皆可) //Field field = this.getClass().getDeclaredField(fieldName); // 獲取呼叫者自己的field(private/protected/public修飾詞皆可)和從父類繼承的field(必須是public修飾詞) //Field field = this.getClass().getField(fieldName); // 把field設為可寫 //field.setAccessible(true); // 直接設定field的值 //field.set(this, node.getValue()); /** * 通過setter設定欄位值 */ Method method = this.getSetter(node.getName()); // 呼叫setter method.invoke(this, node.getValue()); } catch (Exception e) { log.debug("It's failed to initialize field: {}, reason: {}", node.getName(), e); }; } }
上面被註釋的段落是直接訪問Field的方式,下面的段落是呼叫setter的方式,兩種方法在效率上沒有差別。
考慮到JAVA語法規範(書寫bean的規範),呼叫setter是更通用的辦法,因為介面類可能是被繼承、派生的,子類無法訪問父類用private關鍵字修飾的Field。
getSetter函式很簡單,就是用Field的名字反推setter的名字,然後用類反射的辦法獲取setter。程式碼如下:
private Method getSetter(String fieldName) throws NoSuchMethodException, SecurityException { String methodName = String.format("set%s", StringUtils.upperFirstChar(fieldName)); // 獲取field的setter,只要是用public修飾的setter、不管是自己的還是從父類繼承的,都能取到 return this.getClass().getMethod(methodName, String.class); }
如果設計得好,甚至可以用一個解析函式處理所有的介面,這涉及到Digerser的運用技巧和介面類的設計技巧,本文不作深入講解。
2017年,我們在一個和銀行有關的金融增值服務專案中使用了這個解決方案,取得了非常不錯的效果,之後在公司內部推廣開來成為了通用技術架構。經過一年多的實踐,證明這套架構效能穩定、可靠,極大地簡化了程式碼編寫和維護工作,顯著提高了生產效率。
問題:類反射效能差
但是,隨著業務量的增加,2018年末在進行壓力測試的時候,發現解析xml的程式碼佔用CPU資源居高不下。進一步分析、定位,發現問題出在類反射程式碼上,在某些極端的業務場景下,甚至會佔用90%的CPU資源!這就提出了效能優化的迫切要求。
類反射的效能優化不是什麼新課題,因此有一些成熟的第三方解決方案可以參考,比如運用比較廣泛的ReflectASM,據稱可以比未經優化的類反射程式碼提高1/3左右的效能。
(參考資料:Java高效能反射工具包ReflectASM,ReflectASM-invoke,高效率java反射機制原理)
在研究了ReflectASM的原始碼以後,我們決定不使用現成的第三方解決方案,而是從底層入手、自行解決類反射程式碼的優化問題。主要基於兩點考慮:
- ReflectASM的基本技術原理,是在執行期動態分析類的結構,把欄位、函式建立索引,然後通過索引完成類反射,技術上並不高深,效能也談不上完美;
- 類反射是我們系統使用的關鍵技術,使用場景、呼叫頻率都非常高,從自主掌握和控制基礎、核心技術,實現系統的效能最優化角度考慮,應該儘量從底層技術出發,獨立、可控地完成優化工作。
思路和實踐:快取優化
前面提到ReflectASM給類的欄位、函式建立索引,藉此提高類反射效率。進一步分析,這實際上是變相地快取了欄位和函式。那麼,在我們面臨的業務場景下,能不能用快取的方式優化類反射程式碼的效率呢?
我們的業務場景需要以類反射的方式頻繁呼叫介面類的setter,這些setter都是用public關鍵字修飾的函式,先是getMethod()、然後invoke()。基於以上特點,我們用如下邏輯和流程進行了技術分析:
- 用除錯分析工具統計出每一句類反射程式碼的執行耗時,結果發現效能瓶頸在getMethod();
- 分析JAVA虛擬機器的記憶體模型和管理機制,尋找解決問題的方向。JAVA虛擬機器的記憶體模型,可以從下面兩個維度來描述:
A.類空間/物件空間維度
B.堆/棧維度
- 從JAVA虛擬機器記憶體模型可以看出,getMethod()需要從不連續的堆中檢索程式碼段、定位函式入口,獲得了函式入口、invoke()之後就和傳統的函式呼叫差不多了,所以效能瓶頸在getMethod();
- 程式碼段屬於類空間(也有資料將其描述為“函式空間”/“程式碼空間”),類被載入後,除非虛擬機器關閉,函式入口不會變化。那麼,只要把setter函式的入口快取起來,不就節約了getMethod()消耗的系統資源,進而提高了類反射程式碼的執行效率嗎?
把介面類修改為這樣的結構(標紅的部分是新增或修改):
setterMap就是快取欄位setter的HashMap。為什麼是兩層巢狀結構呢?因為這個Map是寫在基類裡面的靜態變數,每個從基類派生出的介面類都用它快取setter,所以第一層要區分不同的介面類,第二層要區分不同的欄位。如下圖所示:
當ClassLoader載入基類時,建立setterMap(內容為空):
static { setterMap = new HashMap<String, Map<String, Method>>(); }
這樣寫可以保證setterMap只被初始化一次。
Initialize()函式作如下改進:
public void initialize() { // 先檢查子類的setter是否被快取 String className = this.getClass().getName(); if (setterMap.get(className) == null) setterMap.put(className, new HashMap<String, Method>()); Map<String, Method> setters = setterMap.get(className); // 遍歷報文節點 for (MessageNode node: nodes) { try { // 檢查對應的setter是否被快取了 Method method = setters.get(node.getName()); if (method == null) { // 沒有快取,先獲取、再快取 method = this.getSetter(node.getName()); setters.put(node.getName(), method); } // 用類反射方式呼叫setter method.invoke(this, node.getValue()); } catch (Exception e) { log.debug("It's failed to initialize field: {}, reason: {}", node.getName(), e); }; } }
基本思路就是把setter快取起來,通過MessageNode的name(欄位的名字)找setter的入口地址,然後呼叫。
因為只在初始化第一個物件例項的時候呼叫getMethod(),極大地節約了系統資源、提高了效率,測試結果也證實了這一點。
驗證:測試方法和標準
1)先寫一個測試類,結構如下:
2)在建構函式中,用UUID初始化儲存鍵值對的列表nodes:
this.createNode("test001", String.valueOf(UUID.randomUUID().toString().hashCode())); this.createNode("test002", String.valueOf(UUID.randomUUID().toString().hashCode())); // ……
之所以用UUID,是保證每個例項、每個欄位的值都不一樣,避免JAVA編譯器自動優化程式碼而破壞測試結果的原始性。
3)Initialize_ori()函式是用傳統的硬編碼方式直接呼叫setter的方法初始化例項欄位,程式碼如下:
for (MessageNode node: this.nodes) { if (node.getName().equalsIgnoreCase("test001")) this.setTest001(node.getValue()); else if (node.getName().equalsIgnoreCase("test002")) this.setTest002(node.getValue()); // …… }
優化效果就以它作為對照標準1,對照標準2就是沒有優化的類反射程式碼。
4)checkUnifomity()函式用來驗證:程式碼是否用name-value鍵值對正確地初始化了各欄位。
for (MessageNode node: nodes) { if (node.getName().equalsIgnoreCase("test001") && !node.getValue().equals(this.test001)) return false; else if (node.getName().equalsIgnoreCase("test002") && !node.getValue().equals(this.test002)) return false; // …… } return true;
每一種優化方案,我們都會用它驗證例項的欄位是否正確,只要出現一次錯誤,該方案就會被否定。
5)建立100萬個TestInvoke類的例項,然後迴圈呼叫每一個例項的initialize_ori()函式(傳統的硬編碼,非類反射方法),記錄執行耗時(只記錄初始化耗時,建立例項的耗時不記錄);再建立100萬個例項,迴圈呼叫每一個例項的類反射初始化函式(未優化),記錄執行耗時;再建立100萬個例項,改成呼叫優化後的類反射初始化函式,記錄執行耗時。
6)以上是一個測試迴圈,得到三種方法的耗時資料,重複做10次,得到三組耗時資料,把記錄下的資料去掉最大、最小值,剩下的求平均值,就是該方法的平均耗時。某一種方法的平均耗時越短則認為該方法的效率越高。
7)為了進一步驗證三種方法在不同負載下的效率變化規律,改成建立10萬個例項,重複5/6兩步,得到另一組測試資料。
測試結果顯示:在確保測試環境穩定、一致的前提下,8個欄位的測試例項、初始化100萬個物件,傳統方法(硬編碼)耗時850~1000毫秒;沒有優化的類反射方法耗時23000~25000毫秒;優化後的類反射程式碼耗時600~800毫秒。10萬個測試物件的情況,三種方法的耗時也大致是這樣的比例關係。這個資料取決於測試環境的資源狀況,不同的機器、不同時刻的測試,結果都有出入,但總的規律是穩定的。
基於測試結果,可以得出這樣的結論:快取優化的類反射程式碼比沒有優化的程式碼效率提高30倍左右,比傳統的硬編碼方法提高了10~20%。有必要強調的是,這個結論偏向保守。和ReflecASM相比,效能大幅度提高也是毋庸置疑的。
第一次迭代:忽略欄位
快取優化的效果非常好,但是,這個方案真的完美無缺了麼?
經過分析,我們發現:如果資料更復雜一些,這個方案的缺陷就暴露了。比如鍵值對列表裡的值在介面類裡面並沒有定義對應的欄位,或者是沒有對應的、可以訪問的setter,效能就會明顯下降。
這種情況在實際業務中是很常見的,比如對接銀行核心介面,往往並不需要解析報文的全部欄位,很多欄位是可以忽略的,所以介面類裡面不用定義這些欄位,但解析程式碼依然會把這些鍵值對全部解析出來,這時就會給優化程式碼造成麻煩了。
分析過程如下:
1)舉例而言,如果鍵值對裡有兩個值在介面類(Interface01)並未定義,假定名字是fieldX、filedY,第一次執行initialize()函式:
初始狀態下,setterMap檢索不到Interface01類的setter快取,initialize()函式會在第一次執行的時候,根據鍵值對的名字(field01/field02/……/fieldN/fieldX/fieldY)呼叫getMethod()函式、初始化sertter引用的快取。因為fieldX和fieldY欄位不存在,找不到它們對應的setter,快取裡也沒有它們的引用。
2)第二次執行initialize()函式(也就是初始化第二個物件例項),field01/field02/……/fieldN鍵值對都能在快取中找到setter的引用,呼叫速度很快;但快取裡找不到fieldX/fieldY的setter的引用,於是再次呼叫getMethod()函式,而因為它們的setter根本不存在(連這兩個欄位都不存在),做的是無用功,setterMap的狀態沒有變化。
3)第三次、第四次……第N次,都是如此,白白消耗系統資源,執行效率必然下降。
測試結果印證了這個推斷:在TestInvoke的建構函式增加了兩個不存在對應欄位和setter的鍵值對(姑且稱之為“無效鍵值對”),進行100萬個例項的初始化測試,經過優化的類反射程式碼,耗時從原來的600~800毫秒,增加到7000~8000毫秒,效能下降10倍左右。如果增加更多的鍵值對(不存在對應欄位),效能下降更嚴重。
所以必須進一步完善優化程式碼。為了加以區分,我們把之前的優化程式碼稱為V1版;進一步完善的程式碼稱為V2版。
怎麼完善?從上面的分析不難找到思路:增加忽略欄位(ignore field)快取。
基類BaseModel作如下修改(標紅部分是新增或者修改),增加了ignoreMap:
ignoreMap的資料結構類似於setterMap,但第二層不是HashMap,而是Set,快取每個子類需要忽略的鍵值對的名字,使用Set更節約系統資源,如下圖所示:
同樣的,當ClassLoader載入基類的時候,建立ignoreMap(內容為空):
static { setterMap = new HashMap<String, Map<String, Method>>(); ignoreMap = new HashMap<String, Set<String>>(); }
Initialize()函式作如下改進:
public void initialize() { // 先檢查子類的setter是否被快取 String className = this.getClass().getName(); if (setterMap.get(className) == null) setterMap.put(className, new HashMap<String, Method>()); if (ignoreMap.get(className) == null) ignoreMap.put(className, new HashSet<String>()); Map<String, Method> setters = setterMap.get(className); Set<String> ignores = ignoreMap.get(className); // 遍歷報文節點 for (MessageNode node: nodes) { String sName = node.getName(); try { // 檢查該欄位是否被忽略 if (ignores.contains(sName)) continue; // 檢查對應的setter是否被快取了 Method method = setters.get(sName); if (method == null) { // 沒有快取,先獲取、再快取 method = this.getSetter(sName); setters.put(sName, method); } // 用類反射方式呼叫setter method.invoke(this, node.getValue()); } catch (NoSuchMethodException | SecurityException e) { log.debug("It's failed to initialize field: {}, reason: {}", sName, e); // 找不到對應的setter,放到忽略欄位集合,以後不再嘗試 ignores.add(sName); } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { log.error("It's failed to initialize field: {}, reason: {}", sName, e); try { // 不能呼叫setter,可能是虛擬機器回收了該子類的全部例項、入口地址變化,更新地址、再試一次 Method method = this.getSetter(sName); setters.put(sName, method); method.invoke(this, node.getValue()); } catch (Exception e1) { log.debug("It's failed to initialize field: {}, reason: {}", sName, e1); } } catch (Exception e) { log.error("It's failed to initialize field: {}, reason: {}", sName, e); } } }
雖然程式碼複雜了一些,但思路很簡單:用鍵值對的名字尋找對應的setter時,如果找不到,就把它放進ignoreMap,下次不再找了。另外還增加了對setter引用失效的處理。雖然理論上說“只要虛擬機器不重啟,setter的入口引用永遠不會變”,在測試中也從來沒有遇到過這種情況,但為了覆蓋各種異常情況,還是增加了這段程式碼。
繼續沿用前面的例子,分析改進後的程式碼的工作流程:
1)第一次執行initialize()函式,例項的狀態是這樣變化的:
因為fieldX和fieldY欄位不存在,找不到它們對應的setter,它們被放到ignoreMap中。
2)再次呼叫initialize()函式的時候,因為檢查到ignoreMap中存在fieldX和fieldY,這兩個鍵值對被跳過,不再徒勞無功地呼叫getMethod();其它邏輯和V1版相同,沒有變化。
還是用上面提到的TestInvoke類作驗證(8個欄位+2個無效鍵值對),V2版本雖然程式碼更復雜了,但100萬條紀錄的初始化耗時為600~800毫秒,V1版程式碼這個時候的耗時猛增到7000~8000毫秒。哪怕增加更多的無效鍵值對,V2版程式碼耗時增加也不明顯,而這種情況下V1版程式碼的效率還會進一步下降。
至此,對JAVA類反射程式碼的優化已經比較完善,覆蓋了各種異常情況,如前所述,我們把這個版本稱為V2版。
第二次迭代:逆向思維
這樣就代表優化工作已經做到最好了嗎?不是這樣的。
仔細觀察V1、V2版的優化程式碼,都是迴圈遍歷鍵值對,用鍵值對的name(和欄位的名字相同)推算setter的函式名,然後去尋找setter的入口引用。第一次是呼叫類反射的getMethod()函式,以後是從快取裡面檢索,如果存在無效鍵值對,那就必然出現空轉迴圈,哪怕是V2版程式碼,ignoreMap也不能避免這種空轉迴圈。雖然單次空轉迴圈耗時非常短,但在無效鍵值對比較多、負載很大的情況下,依然有無效的資源開銷。
如果採用逆向思維,用setter去反推、檢索鍵值對,又會如何?
先分析業務場景以及由業務場景所決定的資料結構特點:
- 介面類的欄位數量可能大於setter函式的數量,因為可能需要一些內部使用的功能性欄位,並不是從xml報文裡解析出來的;
- xml報文裡解析出的鍵值對和欄位是交集關係,多數情況下,鍵值對的數量包含了介面類的欄位,並且大概率存在一些不需要的鍵值對;
- 相比較欄位,setter函式和需要解析的鍵值對最接近於一一對應關係,出現空轉迴圈的概率最小;
- 因為介面類編寫要遵守JAVA程式設計規範,從setter函式的名字反推欄位的名字,進而檢索鍵值對,是可行、可靠的。
綜上所述,逆向思維用setter函式反推、檢索鍵值對,初始化介面類,就是第二次迭代的具體方向。
需要把介面類修改成這樣的結構(標紅的部分是新增或者修改):
1)為了便於逆向檢索鍵值對,nodes欄位改成HashMap,key是鍵值對的名字、value是鍵值對的值。
2)為了提高迴圈遍歷的速度,setterMap的第二層改成連結串列,連結串列的成員是內部類FieldSetter,結構如下:
private class FieldSetter { private String name; private Method method; public String getName() { return name; } public Method getMethod() { return method; } public void setMethod(Method method) { this.method = method; } public FieldSetter(String name, Method method) { super(); this.name = name; this.method = method; } }
setterMap的第二層繼續使用HashMap也能實現功能,但迴圈遍歷的效率,HashMap不如連結串列,所以我們改用連結串列。
3)同樣的,setterMap在基類被載入的時候建立(內容為空):
static { setterMap = new HashMap<String, List<FieldSetter>>(); }
4)第一次初始化某個介面類的例項時,呼叫initSetters()函式,初始化setterMap:
protected List<FieldSetter> initSetters() { String className = this.getClass().getName(); List<FieldSetter> setters = new ArrayList<FieldSetter>(); // 遍歷類的可呼叫函式 for (Method method: this.getClass().getMethods()) { String methodName = method.getName(); // 如果從名字推斷是setter函式,新增到setter函式列表 if (methodName.startsWith("set")) { // 反推field的名字 String fieldName = StringUtils.lowerFirstChar(methodName.substring(3)); setters.add(new FieldSetter(fieldName, method)); } } // 快取類的setter函式列表 setterMap.put(className, setters); // 返回可呼叫的setter函式列表 return setters; }
5)Initialize()函式修改為如下邏輯:
public void initialize() { // 從快取獲取介面類的setter列表 List<FieldSetter> setters = setterMap.get(this.getClass().getName()); // 如果還沒有快取、初始化介面類的setter列表 if (setters == null) setters = this.initSetters(); // 遍歷介面類的setter for (FieldSetter setter: setters) { // 用setter的名字(也就是欄位的名字)檢索鍵值對 String fieldName = setter.getName(); String fieldValue = nodes.get(fieldName); // 沒有檢索到鍵值對、或者鍵值對沒有賦值,跳過 if (StringUtils.isEmpty(fieldValue)) continue; try { Method method = setter.getMethod(); // 用類反射方式呼叫setter method.invoke(this, fieldValue); } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { log.error("It's failed to initialize field: {}, reason: {}", fieldName, e); // 不能呼叫setter,可能是虛擬機器回收了該子類的全部例項、入口地址變化,更新地址、再試一次 try { Method method = this.getSetter(fieldName); setter.setMethod(method); method.invoke(this, fieldValue); } catch (Exception e1) { log.debug("It's failed to initialize field: {}, reason: {}", fieldName, e1); } } catch (Exception e) { log.error("It's failed to initialize field: {}, reason: {}", fieldName, e); } } }
不妨把這版程式碼稱為V3……繼續沿用前面TestInvoke的例子,分析改進後代碼的工作流程:
1)第一次執行initialize()函式,例項的狀態是這樣變化的:
通過setterMap反向檢索鍵值對的值,fieldX、fieldY因為不存在對應的setter,不會被檢索,避免了空轉。
2)之後每一次初始化物件例項,都不需要再初始化setterMap,也不會消耗任何資源去檢索fieldX、fieldY,最大限度地節省資源開銷。
3)因為取消了ignoreMap,取消了V2版判斷欄位是否應該被忽略的邏輯,程式碼更簡潔,也能節約一部分資源。
結果資料顯示:用TestInvoke測試類、8個setter+2個無效鍵值對的情況下,進行100萬/10萬個例項兩個量級的對比測試,V3版比V2版效能最多提高10%左右,100萬例項初始化耗時550~720毫秒。如果增加無效鍵值對的數量,效能提高更為明顯;沒有無效鍵值對的最理想情況下,V1、V2、V3版本的程式碼效率沒有明顯差別。
至此,用快取機制優化類反射程式碼的嘗試,已經比較接近最優解了,V3版本的程式碼可以視為到目前為止最好的版本。
總結和思考:方法論
總結過去兩年圍繞著JAVA類反射效能優化這個課題,我們所進行的探索和研究,提高到方法論層面,可以提煉出一個分析問題、解決問題的思路和流程,供大家參考:
1)從實踐中來
多數情況下,探索和研究的課題並不是坐在書齋裡憑空想出來的,而是在實際工作中遇到具體的技術難點,在現實需求的驅動下發現需要研究的問題。
以本文為例,如果不是在對接銀行核心系統的時候遇到了大量的、格式奇特的xml報文,不會促使我們嘗試用類反射技術去優雅地解析報文,也就不會面對類反射程式碼執行效率低的問題,自然不會有後續的研究成果。
2)拿出手術刀,解剖一隻麻雀
在實踐中遇到了困難,首先要分析和研究面對的問題,不能著急,要有解剖一隻麻雀的精神,抽絲剝繭,把問題的根源找出來。
這個過程中,邏輯分析和實操驗證都是必不可少的。沒有高屋建瓴的分析,就容易迷失大方向;沒有實操驗證,大概率會陷入坐而論道、腦補的怪圈。還是那句話:實踐是最寶貴的財富,也是驗證一切構想的終極考官,是我們認識世界改造世界的力量源泉。但我們也不能陷入庸俗的經驗主義,不管怎麼說,這個世界的基石是有邏輯的。
回到本文的案例,我們一方面研究JAVA記憶體模型,從理論上探尋類反射程式碼效率低下的原因;另一方面也在實務層面,用實實在在的時間戳驗證了JAVA類反射程式碼的耗時分佈。理論和實踐的結合,才能讓我們找到解決問題的正確方向,二者不可偏廢。
3)頭腦風暴,勇於創新
分析問題,找到關鍵點,接下來就是尋找解決方案。JAVA程式設計師有一個很大的優勢,同時也是很大的劣勢:第三方解決方案非常豐富。JAVA生態比較完善,我們面臨的麻煩和問題幾乎都有成熟的第三方解決方案,“吃現成的”是優勢也是劣勢,很多時候,我們的創造力也因此被扼殺。所以,當面臨高價值需求的時候,應該拿出大無畏的勇氣,啃硬骨頭,做底層和原創的工作。
就本文案例而言,ReflexASM就是看起來很不錯的方案,比傳統的類反射程式碼效能提升了至少三分之一。但是,它真的就是最優解麼?我們的實踐否定了這一點。JAVA程式設計師要有吃苦耐勞、以底層技術為原點解決問題的精神,否則你就會被別人所綁架,失去尋求技術自由空間的機會。中國的軟體行業已經發展到了這個階段,提出了這樣的需求,我們應該順應歷史潮流。
4)螺旋式發展,波浪式前進
研究問題和解決問題,迭代是非常有效的工作方法。首先,要有精益求精的態度,不斷改進,逼近最優方案,迭代必不可少。其次,對於比較複雜的問題,不要追求畢其功於一役,把一個大的目標拆分成不同階段,分步實施、逐漸推進,這種情況下,迭代更是解決問題的必由之路。
我們解決JAVA類反射程式碼的優化問題,就是經過兩次迭代、寫了三個版本,才得到最終的結果,逼近了最優解。在迭代的過程中會逐漸發現一些之前忽略的問題,這就是寶貴的經驗,這些經驗在解決其他技術問題時也能發揮作用。比如HashMap的資料結構非常合理、經典,平時使用的時候效率是很高的,如果不是迭代開發、逼近極限的過程,我們又怎麼可能發現在迴圈遍歷狀態下、它的效能不如連結串列呢?
行文至此,文章也快要寫完了,細心的讀者一定會有一個疑問:自始至終,舉的例子、類的欄位都是String型別,類反射程式碼根本沒有考慮setter的引數型別不同的情況。確實是這樣的,因為我們解決的是銀行核心介面報文解析的問題,介面欄位全部是String,沒有其它資料型別。
其實,對類反射技術的研究深入到這個程度,解決這個問題、並且維持程式碼的高效率,易如反掌。比如,給FieldSetter類增加一個數據型別的欄位,初始化setterMap的時候把介面類對應的欄位的資料型別解析出來,和setter函式的入口一起快取,類反射呼叫setter時,把引數格式轉換一下,就可以了。限於篇幅、這個問題就不展開了,感興趣的讀者可以自己嘗試一下。