如何利用緩存機制實現JAVA類反射性能提升30倍

前言

在實際工作中的一些特定應用場景下,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到字段。

接口類應該是這樣的結構:

如何利用緩存機制實現JAVA類反射性能提升30倍

如何利用緩存機制實現JAVA類反射性能提升30倍

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左右的性能。

在研究了ReflectASM的源代碼以後,我們決定不使用現成的第三方解決方案,而是從底層入手、自行解決類反射代碼的優化問題。主要基於兩點考慮

  • ReflectASM的基本技術原理,是在運行期動態分析類的結構,把字段、函數建立索引,然後通過索引完成類反射,技術上並不高深,性能也談不上完美;

  • 類反射是我們系統使用的關鍵技術,使用場景、調用頻率都非常高,從自主掌握和控制基礎、核心技術,實現系統的性能最優化角度考慮,應該儘量從底層技術出發,獨立、可控地完成優化工作。

五、思路和實踐:緩存優化

前面提到ReflectASM給類的字段、函數建立索引,藉此提高類反射效率。進一步分析,這實際上是變相地緩存了字段和函數。那麼,在我們面臨的業務場景下,能不能用緩存的方式優化類反射代碼的效率呢?我們的業務場景需要以類反射的方式頻繁調用接口類的setter,這些setter都是用public關鍵字修飾的函數,先是getMethod()、然後invoke()。基於以上特點,我們用如下邏輯和流程進行了技術分析:

  • 用調試分析gongju統計出每一句類反射代碼的執行耗時,結果發現性能瓶頸在getMethod(); 
  • 分析JAVA虛擬機的內存模型和管理機制,尋找解決問題的方向。JAVA虛擬機的內存模型,可以從下面兩個維度來描述:

A.類空間/對象空間維度

如何利用緩存機制實現JAVA類反射性能提升30倍

B.堆/棧維度

如何利用緩存機制實現JAVA類反射性能提升30倍

  • 從JAVA虛擬機內存模型可以看出,getMethod()需要從不連續的堆中檢索代碼段、定位函數入口,獲得了函數入口、invoke()之後就和傳統的函數調用差不多了,所以性能瓶頸在getMethod(); 
  • 代碼段屬於類空間(也有資料將其描述爲“函數空間”/“代碼空間”),類被加載後,除非虛擬機關閉,函數入口不會變化。那麼,只要把setter函數的入口緩存起來,不就節約了getMethod()消耗的系統資源,進而提高了類反射代碼的執行效率嗎?

把接口類修改爲這樣的結構(標紅的部分是新增或修改):

如何利用緩存機制實現JAVA類反射性能提升30倍

如何利用緩存機制實現JAVA類反射性能提升30倍

setterMap就是緩存字段setter的HashMap。爲什麼是兩層嵌套結構呢?因爲這個Map是寫在基類裏面的靜態變量,每個從基類派生出的接口類都用它緩存setter,所以第一層要區分不同的接口類,第二層要區分不同的字段。如下圖所示:

如何利用緩存機制實現JAVA類反射性能提升30倍

當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(),極大地節約了系統資源、提高了效率,測試結果也證實了這一點。

基本思路就是把setter緩存起來,通過MessageNode的name(字段的名字)找setter的入口地址,然後調用。

因爲只在初始化第一個對象實例的時候調用getMethod(),極大地節約了系統資源、提高了效率,測試結果也證實了這一點。

六、驗證:測試方法和標準

1)先寫一個測試類,結構如下:

如何利用緩存機制實現JAVA類反射性能提升30倍

如何利用緩存機制實現JAVA類反射性能提升30倍

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()函數:

如何利用緩存機制實現JAVA類反射性能提升30倍

初始狀態下,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:

如何利用緩存機制實現JAVA類反射性能提升30倍

ignoreMap的數據結構類似於setterMap,但第二層不是HashMap,而是Set,緩存每個子類需要忽略的鍵值對的名字,使用Set更節約系統資源,如下圖所示:

如何利用緩存機制實現JAVA類反射性能提升30倍

同樣的,當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()函數,實例的狀態是這樣變化的:

如何利用緩存機制實現JAVA類反射性能提升30倍

因爲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函數反推、檢索鍵值對,初始化接口類,就是第二次迭代的具體方向。

需要把接口類修改成這樣的結構(標紅的部分是新增或者修改):

如何利用緩存機制實現JAVA類反射性能提升30倍

如何利用緩存機制實現JAVA類反射性能提升30倍

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()函數,實例的狀態是這樣變化的:

如何利用緩存機制實現JAVA類反射性能提升30倍

通過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的數據結構非常合理、經典,平時使用的時候效率是很高的,如果不是迭dai開發、逼近極限的過程,我們又怎麼可能發現在循環遍歷狀態下、它的性能不如鏈表呢?

行文至此,文章也快要寫完了,細心的讀者一定會有一個疑問:自始至終,舉的例子、類的字段都是String類型,類反射代碼根本沒有考慮setter的參數類型不同的情況。確實是這樣的,因爲我們解決的是銀行核心接口報文解析的問題,接口字段全部是String,沒有其它數據類型。

其實,對類反射技術的研究深入到這個程度,解決這個問題、並且維持代碼的高效率,易如反掌。比如,給FieldSetter類增加一個數據類型的字段,初始化setterMap的時候把接口類對應的字段的數據類型解析出來,和setter函數的入口一起緩存,類反射調用setter時,把參數格式轉換一下,就可以了。限於篇幅、這個問題就不展開了,感興趣的讀者可以自己嘗試一下。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章