天天看點

架構師日記-從代碼到設計的性能優化指南

作者:京東雲開發者

一 前言

服務性能是指服務在特定條件下的響應速度、吞吐量和資源使用率等方面的表現。據統計,性能優化方面的精力投入,通常占軟體開發周期的10%到25%左右,當然這和應用的性質和規模有關。性能對提高使用者體驗,保證系統可靠性,降低資源使用率,甚至增強市場競争力等方面,都有着很大的影響。

性能優化是個系統性工程,宏觀上可分為網絡,服務,存儲幾個方向,每個方向又可以細分為架構,設計,代碼,可用性,度量等多個子項。本文将重點從代碼和設計兩個子項展開,談談那些提升性能的知識點。當然,很多性能提升政策都是有代價的,适用于某些特定場景,大家在學習和使用的時候,最好帶着批判的思維,決策前,做好利弊權衡。

先簡單羅列一下性能優化方向:

架構師日記-從代碼到設計的性能優化指南

二 代碼優化

2.1 關聯代碼

關聯代碼優化是通過預加載相關代碼,避免在運作時加載目标代碼,造成運作時負擔。我們知道Java有兩個類加載器:Bootstrap class loader和Application class loader。Bootstrap class loader負責加載Java API中包含的核心類,而Application class loader則負責加載自定義類。關聯代碼優化可以通過以下幾種方式來實作。

預加載關聯

預加載關聯類是指在程式啟動時預先加載目标與關聯類,以避免在運作時加載。可以通過靜态代碼塊來實作預加載,如下所示:

public class MainClass {
    static {
        // 預加載MyClass,其實作了相關功能
        Class.forName("com.example.MyClass");
    }
    // 運作相關功能的代碼
    // ...
}           

使用線程池

線程池可以讓多個任務使用同一個線程池中的線程,進而減少線程的建立和銷毀成本。使用線程池時,可以在程式啟動時建立線程池,并在主線程中預加載相關代碼。然後以異步方式使用線程池中的線程來執行相關代碼,可以提高程式的性能。

使用靜态變量

可以使用靜态變量來緩存與關聯代碼有關的對象和資料。在程式啟動時,可以預先加載關聯代碼,并将對象或資料存儲在靜态變量中。然後在程式運作時使用靜态變量中緩存的對象或資料,以避免重複加載和生成。這種方式可以有效地提高程式的性能,但需要注意靜态變量的使用,確定它們在多線程環境中的安全性。

2.2 緩存對齊

在介紹緩存對齊之前,需要先普及一些CPU指令執行的相關知識。

架構師日記-從代碼到設計的性能優化指南



  • 緩存行(Cache line):CPU讀取記憶體資料時并非一次隻讀一個位元組,一般是會讀一段64位元組(硬體決定)長度的連續的記憶體塊(chunks of memory),這些塊我們稱之為緩存行。
  • 僞共享(False Sharing):當運作在兩個不同CPU上的兩個線程寫入兩個不同的變量時,如果這兩個變量恰好存儲在同一個 CPU 緩存行中,就會發生僞共享(False Sharing)。即當第一個線程修改緩存行中其中一個變量時,其他引用此緩存行變量的線程的緩存行将會無效。如果CPU需要讀取失效的緩存行,它必須等待緩存行重新整理,這會導緻性能下降。
  • CPU停止運轉(stall):當一個核心需要等待另一個核心重新加載緩存行時(出現僞共享時),它無法繼續執行下一條指令,隻能停止運轉等待,這被稱之為stall。減少僞共享也就意味着減少了stall的發生。
  • IPC(instructions per cycle):它表示平均每個 CPU 周期執行的指令數量,很顯然該數值越大性能越好。可以基于IPC名額(比如:門檻值1.0)來簡單判斷程式是屬于通路密集型還是計算密集型。Linux系統中可以通過tiptop指令來檢視每個程序的CPU硬體資料:
架構師日記-從代碼到設計的性能優化指南

如何簡單來區分訪存密集型和計算密集型程式?

1. 如果 IPC < 1.0, 很可能是 Memory stall 占主導,多半意味着訪存密集型。

2. 如果IPC > 1.0, 很可能是計算密集型的程式。

  • CPU使用率:是指系統中CPU處于忙碌狀态的時間與總時間的比例。忙碌狀态時間又可以進一步拆分為指令(instruction)執行消耗周期cycle(%INS) 和 stalled 的周期cycle(%STL)。perf 采集了10秒内全部 CPU 的運作狀态:
架構師日記-從代碼到設計的性能優化指南



IPC計算

IPC = instructions/cycles
上圖中,可以計算出結果為:0.79
現代處理器一般有多條流水線(比如:4核心),運作 perf 的那台機器,IPC的理論值可達到4.0。
如果我們從 IPC的角度來看,這台機器隻運作到其處理器最高速度的 19.7%(0.79 / 4.0)。           

總之,通過Top指令,看到CPU使用率之後,可以進一步分析指令執行消耗周期和 stalled 周期,有這些更詳細的名額之後,就能夠知道該如何更好地對應用和系統進行調優。

  • 緩存對齊:是通過調整資料在記憶體中的分布,讓資料在被緩存時,更有利于CPU從緩存中讀取,進而避免了頻繁的記憶體讀取,提高了資料通路的速度。

緩存填充(Padding)

減少僞共享也就意味着減少了stall的發生,其中一個手段就是通過填充(Padding)資料的形式,即在适當的距離處插入一些對齊的空間來填充緩存行,進而使每個線程的修改不會髒污同一個緩存行。

/**
 * 緩存行填充測試
 *
 * @author liuhuiqing
 * @date 2023年04月28日
 */
public class FalseSharingTest {
    private static final int LOOP_NUM = 1000000000;

    public static void main(String[] args) throws InterruptedException {
        Struct struct = new Struct();
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < LOOP_NUM; i++) {
                struct.x++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < LOOP_NUM; i++) {
                struct.y++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("cost time [" + (System.currentTimeMillis() - start) + "] ms");
    }

    static class Struct {
        // 共享變量
        volatile long x;
        // 一個long占用8個位元組,此處定義7個填充資料,來保證業務資料x和y分布在不同的緩存行中
        long p1, p2, p3, p4, p5, p6, p7;
        // long[] paddings = new long[7];// 使用數組代替不會生效,思考一下,為什麼?
        // 共享變量
        volatile long y;
    }
}           

經過本地測試,這種以空間換時間的方式,即實作了緩存行資料對齊的方式,在執行效率方面,比沒有對齊之前,提高了5倍!

@Contended注解

在Java 8中,引入了@Contended注解,該注解可以用來告訴JVM對字段進行緩存對齊(将字段放入不同的緩存行),進而提高程式的性能。使用@Contended注解時,需要在JVM啟動時添加參數-XX:-RestrictContended,實作如下所示:

import sun.misc.Contended;

public class ContendedTest {

    @Contended
    volatile long a;
    @Contended
    volatile long b;

    public static void main(String[] args) throws InterruptedException {
        ContendedTest c = new ContendedTest();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000_0000L; i++) {
                c.a = i;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000_0000L; i++) {
                c.b = i;
            }
        });
        final long start = System.nanoTime();
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println((System.nanoTime() - start) / 100_0000);
    }
}           

對齊記憶體與本地變量

緩存填充是解決CPU僞共享問題的解決方案之一,在實際應用中,是否還有其它方案來解決這一問題呢?答案是有的:即對齊記憶體和本地變量。

  • 對齊記憶體:記憶體行的大小一般為64個位元組,這個大小是硬體決定的,但大多數編譯器預設情況下都以4位元組的邊界對齊,通過将變量按照記憶體行的大小對齊,可以避免僞共享問題;
  • 本地變量:在不同線程之間使用不同的變量存儲資料,避免不同的線程之間共享同一塊記憶體,Java中的ThreadLocal就是一種典型的實作方式;

2.3 分支預測

分支預測是CPU動态執行技術中的主要内容,是通過猜測程式中的分支語句(如if-else語句或者循環語句)的執行路徑來提高CPU執行效率的技術。其原理是根據之前的曆史記錄和統計資料,預測程式下一步要執行的指令是分支跳轉指令還是順序執行指令,進而提前加載相關資料,減少CPU等待指令執行的空閑時間。預測準确率越高,CPU的性能提升就越高。那麼如何提高預測的準确率呢?

  • 關注圈複雜度

過多的條件語句和嵌套的條件語句會導緻分支的預測難度大幅上升,進而降低分支預測的準确率和效率。一般來說,可以通過優化代碼邏輯結構、減少備援等方式來避免過多的條件語句和嵌套的條件語句。

  • 優先處理常用路徑

在編寫代碼時,應該優先處理常用路徑,以減少CPU對分支的預測,提高預測準确率和效率。例如,在if-else語句中,應該将常用的路徑放在if語句中,而将不常用的路徑放在else語句中。

2.4 寫時複制

Copy-On-Write (COW)是一種記憶體管理機制,也被稱為寫時複制。其主要思想是在需要寫入資料時,先進行資料拷貝,然後再進行操作,進而避免了對資料進行不必要的複制和操作。COW機制可以有效地降低記憶體使用率,提高程式的性能。

在建立程序或線程的時候,作業系統為其配置設定記憶體時,不是複制一個完整的實體位址空間,而是建立一個指向父程序/線程實體位址空間的虛拟位址空間,并為它們的所有頁面設定"隻讀"标志。當子程序/線程需要修改頁面時,會觸發一個缺頁異常,并将涉及到的頁面進行資料的複制,并為複制的頁面重新配置設定記憶體。子程序/線程隻能夠操作複制後的位址空間,父程序/線程的原始記憶體空間則被保留。

由于COW機制在寫入之前進行資料拷貝,是以可以有效地避免頻繁的記憶體拷貝和配置設定操作,降低了記憶體的占用率,提高了程式的性能。并且,COW機制也避免了資料的不必要複制,進而減少了記憶體的消耗和記憶體碎片的産生,提高了系統中可用記憶體的數量。

ArrayList類可以使用Copy-On-Write機制來提高性能。

// 初始化數組
private List<String> list = new CopyOnWriteArrayList<>();

// 向數組中添加元素
list.add("value");           

需要注意的是,Copy-On-Write機制适用于讀操作比寫操作多的情況,因為它假定寫操作的頻率較低,進而可以通過犧牲複制的開銷來減少鎖的操作和記憶體配置設定的消耗。

2.5 内聯優化

在Java中,每次調用方法都需要進行一些額外的操作,例如建立堆棧幀、儲存寄存器狀态等,這些額外的操作會消耗一定的時間和記憶體資源。内聯優化是一種編譯器優化技術,Java虛拟機通常使用即時編譯器(JIT)來進行方法内聯,用于提高程式的性能。内聯優化的目标是将函數的調用替換成函數本身的代碼,以減少函數調用的開銷,進而提高程式的運作效率。

需要注意的是,方法内聯并不是在所有情況下都能夠提高程式的運作效率。如果方法内聯導緻代碼複雜度增加或者記憶體占用增加,反而會降低程式的性能。是以,在使用方法内聯時需要根據具體情況進行權衡和優化。

final修飾符

final修飾符可以使方法成為不可重寫的方法。因為不可重寫,是以在編譯器優化時可以将它們的代碼嵌入到調用它們的代碼中,進而避免函數調用的開銷。使用final修飾符可以在一定程度上提高程式的性能,但同時也減弱了代碼的可擴充性。

限制方法長度

方法的長度會影響其在編譯時能否被内聯。通常情況下,長度較小的方法更容易被内聯。是以,可以在設計中将代碼分解和重構為更小的函數。這種方式并不是100%確定可以内聯,但至少提高了實作此優化的機會。内聯調優參數,如下表格:

JVM參數 預設值 (JDK 8, Linux x86_64) 參數說明
-XX:MaxInlineSize=<n> 35 位元組碼 内聯方法大小上限
-XX:FreqInlineSize=<n> 325 位元組碼 内聯熱方法的最大值
-XX:InlineSmallCode=<n> 1000位元組的原生代碼(非分層) 2000位元組的原生代碼(分層編譯) 如果最後一層的的分層編譯代碼量已經超過這個值,就不進行内聯編譯
-XX:MaxInlineLevel=<n> 9 調用層級比這個值深的話,就不進行内聯

内聯注解

在Java 5之後,引入了内聯注解@inline,使用此注解可以在編譯時通知編譯器,将該方法内聯到它的調用處。注解@inline在Java 9之後已經被棄用,可以使用@ForceInline注釋來替代,同時設定JVM參數:

-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+JVMCICompiler           
@ForceInline
public static int add(int a, int b) {
    return a + b;
}           

2.6 編碼優化

反射機制

Java反射在一定程度上會影響性能,因為它需要在運作時進行類型檢查轉換和方法查找,這比直接調用方法會更耗時。此外,反射也不會受到編譯器的優化,是以可能會導緻更慢的代碼執行速度。

要解決這個問題有以下幾種方式:

  • 盡可能使用原生方法調用,而不是通過反射調用;
  • 盡可能緩存反射調用結果,避免重複調用。例如,可以将反射結果緩存到靜态變量中,以便下次使用時直接擷取,而不必再次使用反射;
  • 使用位元組碼增強技術;

下面着重介紹一下反射結果緩存和位元組碼增強兩種方案。

  • 反射結果緩存可以大幅減少反射過程中的類型檢查,類型轉換和方法查找等動作,是降低反射對程式執行效率影響的一種優化政策。
/**
 * 反射工具類
 *
 * @author liuhuiqing
 * @date 2023年5月7日
 */
public abstract class BeanUtils {
    private static final Logger LOGGER = LoggerFactory.getLogger(BeanUtils.class);
    private static final Field[] NO_FIELDS = {};
    private static final Map<Class<?>, Field[]> DECLARED_FIELDS_CACHE = new ConcurrentReferenceHashMap<Class<?>, Field[]>(256);
    private static final Map<Class<?>, Field[]> FIELDS_CACHE = new ConcurrentReferenceHashMap<Class<?>, Field[]>(256);

    /**
     * 擷取目前類及其父類的屬性數組
     *
     * @param clazz
     * @return
     */
    public static Field[] getFields(Class<?> clazz) {
        if (clazz == null) {
            throw new IllegalArgumentException("Class must not be null");
        }
        Field[] result = FIELDS_CACHE.get(clazz);
        if (result == null) {
            Field[] fields = NO_FIELDS;
            Class<?> searchType = clazz;
            while (Object.class != searchType && searchType != null) {
                Field[] tempFields = getDeclaredFields(searchType);
                fields = mergeArray(fields, tempFields);
                searchType = searchType.getSuperclass();
            }
            result = fields;
            FIELDS_CACHE.put(clazz, (result.length == 0 ? NO_FIELDS : result));
        }
        return result;
    }

    /**
     * 擷取目前類屬性數組(不包含父類的屬性)
     *
     * @param clazz
     * @return
     */
    public static Field[] getDeclaredFields(Class<?> clazz) {
        if (clazz == null) {
            throw new IllegalArgumentException("Class must not be null");
        }
        Field[] result = DECLARED_FIELDS_CACHE.get(clazz);
        if (result == null) {
            result = clazz.getDeclaredFields();
            DECLARED_FIELDS_CACHE.put(clazz, (result.length == 0 ? NO_FIELDS : result));
        }
        return result;
    }

    /**
     * 數組合并
     *
     * @param array1
     * @param array2
     * @param <T>
     * @return
     */
    public static <T> T[] mergeArray(final T[] array1, final T... array2) {
        if (array1 == null || array1.length < 1) {
            return array2;
        }
        if (array2 == null || array2.length < 1) {
            return array1;
        }
        Class<?> compType = array1.getClass().getComponentType();
        int newArrLength = array1.length + array2.length;
        T[] newArr = (T[]) Array.newInstance(compType, newArrLength);
        int firstArrayLen = array1.length;
        System.arraycopy(array1, 0, newArr, 0, firstArrayLen);
        try {
            System.arraycopy(array2, 0, newArr, firstArrayLen, array2.length);
        } catch (ArrayStoreException ase) {
            final Class<?> type2 = array2.getClass().getComponentType();
            if (!compType.isAssignableFrom(type2)) {
                throw new IllegalArgumentException("Cannot store " + type2.getName() + " in an array of "
                        + compType.getName(), ase);
            }
            throw ase;
        }
        return newArr;
    }
}           
  • 位元組碼增強技術,一般使用第三方庫來實作,例如Javassist或Byte Buddy,在運作時生成位元組碼,進而避免使用反射。

為什麼動态位元組碼生成方式相比反射也可以提高執行效率呢?

  1. 動态位元組碼生成的方式在編譯期就已經将類型資訊确定下來,無需進行類型檢查和轉換;
  2. 動态位元組碼生成的方式可以直接調用方法,無需查找,提高了執行效率;
  3. 動态位元組碼生成的方式隻需要在生成位元組碼時擷取一次Method對象,多次調用時可以直接使用,避免了重複擷取Method對象的開銷;

這裡就不再舉例說明了,感興趣的同學可以自行查閱資料進行深入學習。

異常處理

有效的處理異常可以保證程式的穩定性和可靠性。但異常的處理對性能還是有一定的影響的,這一點常常被人忽視。影響性能的具體表現為:

  • 響應延遲:當異常被抛出時,Java虛拟機需要查找并執行相應的異常處理程式,這會導緻一定的延遲。如果程式中存在大量的異常處理,這些延遲可能會累積,導緻程式的整體性能下降。
  • 記憶體占用:異常處理需要在堆棧中建立異常對象,這些對象需要占用記憶體。如果程式中存在大量的異常處理,這些異常對象可能會占用大量的記憶體,導緻程式的整體記憶體占用量增加。
  • CPU占用:異常處理需要執行額外的代碼,這會導緻CPU占用率增加。如果程式中存在大量的異常處理,這些額外的代碼可能會導緻CPU占用率過高,導緻程式的整體性能下降。

一些基準測試顯示,異常處理可能會導緻程式的性能下降幾個百分點。在Java虛拟機規範中提到,在沒有異常發生的情況下,基于堆棧的方法調用可能比基于異常的方法調用快2-3倍。此外,一些實驗表明,在異常處理程式中使用大量的try-catch語句,可能會導緻性能下降10倍以上。

為避免這些問題,在編寫代碼時謹慎地使用異常處理機制,并確定對異常進行适當的記錄和報告,避免過度使用異常處理機制。

日志處理

先看以下代碼:

LOGGER.info("result:" + JsonUtil.write2JsonStr(contextAdContains) + ", logid = " + DigitThreadLocal.getLogId());           

以上示例代碼中,類似的日志列印方式很常見,難道有什麼問題嗎?

  • 性能問題:每次使用+進行字元串拼接時,都會建立一個新的字元串對象,這可能會導緻記憶體配置設定和垃圾回收的開銷增加;
  • 可讀性問題:使用+進行字元串拼接時,代碼可能會變得難以閱讀和了解,特别是在需要連接配接多個字元串時;
  • 如果日志級别調整到ERROR模式,我們希望日志的字元串内容不需要進行加工計算,但這種寫法,即使日志處于不需要列印的模式,日志内容也進行了無效計算;

特别實在請求量和日志列印量比較高的場景下,日志内容的序列化和寫檔案操作,對服務的耗時影響可以達到10%,甚至更多。

臨時對象

臨時對象通常是指在方法内部建立的對象。大量建立臨時對象會導緻Java虛拟機頻繁進行垃圾回收,進而影響程式的性能。也會占用大量的記憶體空間,進而導緻程式崩潰或者出現記憶體洩漏等問題。

為了避免大量建立臨時對象,在編碼時,可以采取以下措施:

  1. 字元串拼接中,使用StringBuilder或StringBuffer進行字元串拼接,避免使用連接配接符,每次都建立新的字元串對象;
  2. 在集合操作中,盡量使用批量操作,如addAll、removeAll等,避免頻繁的add、remove操作,觸發數組的擴容或者縮容;
  3. 在正規表達式中,可以使用Pattern.compile()方法預編譯正規表達式,避免每次都建立新的Matcher對象;
  4. 盡量使用基本資料類型,避免使用包裝類,因為包裝類的建立和銷毀都會産生臨時對象;
  5. 盡量使用對象池的方式建立和管理對象,比如使用靜态工廠方法建立對象,避免使用new關鍵字建立對象,因為靜态工廠方法可以重用對象,避免建立新的臨時對象;

臨時對象的生命周期應該盡可能短,以便及時釋放記憶體資源。臨時對象的生命周期過長通常是由以下原因引起的:

  1. 對象未被正确地釋放:如果在方法執行完畢後,臨時對象沒有被正确地釋放,就會導緻記憶體洩漏風險;
  2. 對象過度共享:如果臨時對象被過度共享,就可能會導緻多個線程同時通路同一個對象,進而導緻線程安全問題和性能問題;
  3. 對象建立過于頻繁:如果在方法内部頻繁地建立臨時對象,就會導緻記憶體開銷過大,可能會引起性能甚至記憶體溢出問題;

為避免臨時對象的生命周期過長,建議采取以下措施:

  1. 及時釋放對象:在方法執行完畢後,應該及時釋放臨時對象(比如主動将對象設定為null),以便回收記憶體資源;
  2. 避免過度共享:在多線程環境下,應該避免過度共享臨時對象,可以使用局部變量或ThreadLocal等方式來避免共享問題;
  3. 對象池技術:使用對象池技術可以避免頻繁建立臨時對象,進而降低記憶體開銷。對象池可以預先建立一定數量的對象,并在需要時從池中擷取對象,使用完畢後再将對象放回池中;

小結

正所謂:“不積跬步,無以至千裡;不積小流,無以成江海”。以上列舉的編碼細節,都會直接或間接的影響服務的執行效率,隻是影響多少的問題。現實中,有時候我們不必過于苛求,但它們有一個共同的注腳:極客精神。

三 設計優化

3.1 緩存

合理使用緩存可以有效提高應用程式的性能,縮短資料通路時間,降低對資料源的依賴性。緩存可以進行多層級的設計,舉例,為了提高運作效率,CPU就設計了L1-L3三級緩存。在應用設計的時候,我們也可以按照業務訴求進行層設計。常見的分層設計有本地緩存(L1),遠端分布式緩存(L2)兩級。

本地緩存可以減少網絡請求、節約計算資源、減少高負載資料源通路等優勢,進而提高應用程式的響應速度和吞吐量。常見的本地緩存中間件有:Caffeine、Guava Cache、Ehcache。當然你也可以在使用類似Map容器,在應用程式中建構自己的緩存結構。 分布式緩存相比本地緩存的優勢是可以保證資料一緻性、隻保留一份資料,減少資料備援、可以實作資料分片,實作大容量資料的存儲。常見的分布式緩存有:Redis、Memcached。

實作一個簡單的LRU本地緩存示例如下:

/**
 * Least recently used 記憶體緩存過期政策:最近最少使用
 * Title: 帶容量的<b>線程不安全的</b>最近通路排序的Hashmap
 * Description: 最後通路的元素在最後面。<br>
 * 如果要線程安全,請使用<pre>Collections.synchronizedMap(new LRUHashMap(123));</pre> <br>
 *
 * @author: liuhuiqing
 * @date: 20123/4/27
 */
public class LRUHashMap<K, V> extends LinkedHashMap<K, V> {

    /**
     * The Size.
     */
    private final int maxSize;

    /**
     * 初始化一個最大值, 按通路順序排序
     *
     * @param maxSize the max size
     */
    public LRUHashMap(int maxSize) {
        //0.75是預設值,true表示按通路順序排序
        super(maxSize, 0.75f, true);
        this.maxSize = maxSize;
    }

    /**
     * 初始化一個最大值, 按指定順序排序
     *
     * @param maxSize     最大值
     * @param accessOrder true表示按通路順序排序,false為插入順序
     */
    public LRUHashMap(int maxSize, boolean accessOrder) {
        //0.75是預設值,true表示按通路順序排序,false為插入順序
        super(maxSize, 0.75f, accessOrder);
        this.maxSize = maxSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return super.size() > maxSize;
    }

}           

3.2 異步

異步可以提高程式的性能和響應能力,使其能更高效地處理大規模資料或并發請求。其底層原理涉及到作業系統的多線程、事件循環、任務隊列以及回調函數等關鍵技術,除此之外,異步的思想在應用架構設計方面也有廣泛的應用。正常的多線程,消息隊列,響應式程式設計等異步處理方案這裡就不再展開介紹了,這裡介紹兩個大家可能容易忽視但實用技能:非阻塞IO和 協程。

非阻塞IO

Java Servlet 3.0規範中引入了異步Servlet的概念,可以幫助開發者提高應用程式的性能和并發處理能力,其原理是非阻塞IO使用單線程同時處理多個請求,避免了線程切換和阻塞的開銷,特别是在讀取大檔案或者進行複雜耗時計算場景時,可以避免阻塞其他請求的處理。Spring MVC架構中也提供了相應的異步處理方案。

•使用Callable方式實作異步處理

@GetMapping("/async/callable")
public WebAsyncTask<String> asyncCallable() {
    Callable<String> callable = () -> {
        // 執行異步操作
        return "異步任務已完成";
    };
    return new WebAsyncTask<>(10000, callable);
}           

•使用DeferredResult方式實作異步處理

@GetMapping("/async/deferredresult")
public DeferredResult<String> asyncDeferredResult() {
    DeferredResult<String> deferredResult = new DeferredResult<>(10000L);
    // 異步處理完成後設定結果
    deferredResult.setResult("DeferredResult異步任務已完成");
    return deferredResult;
}           

協程

我們知道線程的建立、銷毀都十分消耗系統資源,是以有了線程池,但這還不夠,因為線程的數量是有限的(千級别),線程會阻塞作業系統線程,無法盡可能的提高吞吐量。因為使用線程的成本很高,是以才會有了虛拟線程,它是使用者态線程,成本是相當低廉的,排程也完全由使用者進行控制(JDK 中的排程器),它同樣可以進行阻塞,但不用阻塞作業系統線程,充分提高了硬體使用率,高并發也上了一個量級。

很長一段時間,協程概念并非作為JVM内置的功能,而是通過第三方庫或架構實作的。目前比較常用的協程實作庫有Quasar、Kilim等。但在Java19版本中,引入了虛拟線程(Virtual Threads )的支援(處于Preview階段)。

虛拟線程是java.lang.Thread的一個實作,可以使用java.lang.Thread.Builder接口建立

Thread thread = Thread.ofVirtual()
     .name("Virtual Threads")
     .unstarted(runnable);           

也可以通過一個線程工廠類進行建立:

ThreadFactory factory = Thread.ofVirtual().factory();           

虛拟線程運作的載體必須是線程,同一個線程中可以運作多個虛拟線程執行個體。

3.3 并行

并行處理的思想在大資料,多任務,流水線處理,模型訓練等各個方面發揮着重要作用,包括前面介紹的異步(多線程,協程,消息等),也是建立在并行的基礎上。在應用層面,典型的場景有:

  • 分布式計算架構中的MapReduce就是采用一種分而治之的思想設計出來的,将複雜或計算量大的任務,切分成一個個小的任務,小任務分别在不同的線程或伺服器上并行的執行,最終再彙總每個小任務的結果。
  • 邊緣計算(Edge Computing)是一種分布式計算範式,它将計算、存儲和網絡服務的部分功能從雲資料中心延伸至離資料源更近的地方,即網絡的邊緣。這種計算方式能夠實作低延遲、節省帶寬、提高資料安全性以及實時處理與分析等優勢。

在代碼實作方面,做好解耦設計,接下來就可以進行并行設計了,比如:

  • 多個請求可以通過多線程并行處理,每個請求的不同處理階段;
  • 如查詢階段,可以采用協程并行執行;
  • 存儲階段,可以采用消息訂閱釋出的方式進行處理;
  • 監控統計階段,就可以采用NIO異步的方式進行名額資料檔案的寫入;
  • 請求/響應采用非阻塞IO模式;

3.4 池化

池化就是初始預設資源,降低每次擷取資源的消耗,如建立線程的開銷,擷取遠端連接配接的開銷等。典型的場景就是線程池,資料庫連接配接池,業務處理結果緩存池等。

以資料庫連接配接池為例,其本質是一個 socket 的連接配接。為每個請求打開和維護資料庫連接配接,尤其是動态資料庫驅動的應用程式的請求,既昂貴又浪費資源。為什麼這麼說呢?以MySQL資料庫建立連接配接(TCP協定)為例,建立連接配接總共分三步:

  • 建立TCP連接配接,通過三次握手實作;
  • 伺服器發送給用戶端「握手資訊」 ,用戶端響應該握手消息;
  • 用戶端「發送認證包」 ,用于使用者驗證,驗證成功後,伺服器傳回OK響應,之後開始執行指令;

簡單粗略統計,完成一次資料庫連接配接,用戶端和伺服器之間需要至少往返7次,總計平均耗時大約在200ms左右,這對于很對C端服務來說,幾乎是不能接受的。

落實到代碼編寫層面,也可以借助這一思想來優化我們的程式執行性能。

  1. 公用的資料可以全局隻定義一份,比如使用枚舉,static修飾的容器對象等;
  2. 根據實際情況,提前設定List,Map等容器對象的初始化容量大小,防止後面的擴容,對性能的影響;
  3. 亨元設計模式的應用等;

3.5 預處理

一般需要池化的内容,都是需要預處理的,比如為了保證服務的穩定性,線程池和資料庫連接配接池等需要池化的内容在JVM容器啟動時,處理真正請求之前,對這些池化内容進行預處理,等到真正的業務處理請求過來時,可以正常的快速處理。除此之外,預處理還可以展現在系統架構層面。

  1. 為了提高響應性能,将部分業務資料提前預加載到記憶體中;
  2. 為了減輕CPU壓力,将計算邏輯提前執行,直接将計算後的結果資料儲存下來,直接供調用方使用;
  3. 為了降低網絡帶寬成本,将傳輸資料通過壓縮算法進行壓縮處理,到了目标服務,在進行解壓,獲得原始資料;
  4. Myibatis為了提高SQL語句的安全性和執行效率,也引入了預處理的概念;

四 總結

性能優化是程式開發過程中繞不過去一個課題,本文聚焦代碼和設計兩個方面,從CPU硬體到JVM容器,從緩存設計到資料預處理,全面的展現了性能優化的實施方向和落地細節。闡述的過程沒有追求各個方向的面面俱到,但都給到了一些場景化案例,來輔助了解和思考,起到抛磚引玉的效果。

作者:京東零售 劉慧卿

内容來源:京東雲開發者社群

繼續閱讀