Enhancer-輕量化的位元組碼增強元件包

發表於2023-09-19

一、問題描述

當我們的業務發展到一定階段的時候,系統的複雜度往往會非常高,不再是一個簡單的單體應用所能夠承載的,隨之而來的是系統架構的不斷升級與演變。一般對於大型的To C的網際網路企業來說,整個系統都是構建於微服務的架構之上,原因是To C的業務有著天生的微服務化的訴求:需求迭代快、業務系統多、領域劃分多、鏈路呼叫關係複雜、容忍延遲低、故障傳播快。微服務化之後帶來的問題也很明顯:服務的管理複雜、鏈路的梳理複雜、系統故障會在整個鏈路中迅速傳播。

這裡我們不討論鏈路的依賴或服務的管理等問題,本次要解決的問題是怎麼防止單個系統故障影響整個系統。這是一個複雜的問題,因為服務的傳播特性,一個服務出現故障,其他依賴或被依賴的服務都會受到影響。為了找到解決問題的辦法,我們試著透過5why提問法來找答案。

PS:這裡說的系統故障,是特指由於慢呼叫、慢查詢等影響系統效能而導致的系統故障。

Q1
怎麼防止單個系統故障影響整個系統?
A:避免耽擱系統的故障的傳播。

Q2
怎麼避免故障的傳播?
A:找到系統故障的原因,解決故障。

Q3
怎麼找到故障的原因?
A:找到並最佳化系統中耗時長的方法。

Q4
怎麼找到系統中耗時長的方法?
A:透過對特定方法進行AOP攔截。

Q5
怎麼對特定方法做AOP攔截?
A:透過位元組碼增強的方式對目標方法做攔截並植入內聯程式碼。

透過5why提問法,我們得到了解決問題的方法,我們需要對目標方法做AOP攔截,統計業務方法及各個子方法的耗時,得到所有方法的耗時分佈,快速定位到比較慢的方法,最後找出業務系統的效能瓶頸在哪裡。

二、方案選型

我們知道AOP是一種編碼思想,跟OOP不同,AOP是將特定的方法邏輯,以切面的形式編織到目標方法中,這裡不再贅述AOP的思想。

如果在網上搜一下“AOP的實現方式”,你會得到大致相同的結果:AOP的實現方式是透過動態代理或Cglib代理。其實這不太準確,準確的來說,AOP可以透過代理或Advice兩種方式來實現。請注意這裡說的Advice並不是Spring所依賴的aspectj中的Advice,而是一種程式碼織入的技術,它與代理的區別在於,程式碼織入技術不需要建立代理類。

如果用圖形表示的話,可以更簡單更直觀的感受到兩者的區別。程式碼織入的方式,不會建立代理類,而是直接在目標方法的方法體的前後織入一段內聯的程式碼,以達到增強的效果,如下圖所示:

我選擇程式碼織入技術而不是AOP,原因是可以避免建立大量的代理類增加元空間的記憶體佔用,另外程式碼織入技術更底層一些,能實現的能力更強,此外內聯程式碼會隨著原方法一起執行,效能也更好。

有了具體的技術選型的方案之後,我們還需要確定該方案的建設目標,以下整理了一些基本的目標:

三、技術方案

程式碼織入的時機也有多種方式,比如Lombok是透過在編譯器對程式碼進行織入,主要依賴的是在 Javac 編譯階段利用“Annotation Processor”,對自定義的註解進行預處理後生成程式碼然後織入;其他的像CGLIB、ByteBuddy等框架是在執行時對程式碼進行織入的,主要依賴的是Java Agent技術,透過JVMTI的介面實現在執行時對位元組碼進行增強。

本次的技術方案,用一句話可以概括為:透過位元組碼增強,對指定的目標方法進行攔截,並在方法前後織入一段內聯程式碼,在內聯程式碼中計算目標方法的耗時,最後將統計到的方法資訊進行分析。

1 專案結構

整個方案的程式碼實現非常簡單,用一個圖描述如下:

專案的程式碼結構如下所示,核心程式碼非常少:

2 核心元件

其中Enhancer是增強器的入口類,在增強器啟動時會掃描所有的外掛:EnhancedPlugin。

EnhancedPlugin表示的是一個執行程式碼增強的外掛,其中定義了幾個抽象方法,需要由使用者自己實現:

/**
 * 執行程式碼增強的外掛
 *
 * @auther houyi.wh
 * @date 2023-08-15 20:12:01
 * @since 0.0.1
 */
public abstract class EnhancedPlugin {

    /**
     * 匹配特定的型別
     *
     * @return 型別匹配器
     * @since 0.0.1
     */
    public abstract ElementMatcher.Junction<TypeDescription> typeMatcher();

    /**
     * 匹配特定的方法
     *
     * @return 方法匹配器
     * @since 0.0.1
     */
    public abstract ElementMatcher.Junction<MethodDescription> methodMatcher();

    /**
     * 負責執行增強邏輯的攔截器
     *
     * @return 攔截器
     * @since 0.0.1
     */
    public abstract Class<? extends Interceptor> interceptorClass();

}

此外EnhancedPlugin中還需要指定一個Interceptor,一個Interceptor是對目標方法執行程式碼增強的攔截器,主要的攔截邏輯定義在Interceptor中。

3 增強原理

掃描到EnhancedPlugin之後,會構建ByteBuddy的AgentBuilder,主要的構建過程為:

(1)找到所有匹配的型別

(2)找到所有匹配的方法

(3)傳入執行程式碼增強的Transformer

最後透過AgentBuilder.install方法將增強的程式碼Transformer,傳遞給Instrumentation例項,實現執行時的位元組碼retransformation。

這裡的Transformer是由Advice負責實現的,而在Advice中實現了增強邏輯的dispatch,即根據不同的EnhancedPlugin可以將增強邏輯交給指定的Interceptor攔截器去實現,主要在攔截器中抽象了兩個方法。一個是beforeMethod,負責在目標方法呼叫之前進行攔截:

/**
 * 在方法執行前進行切面
 *
 * @param pluginName 繫結在該目標方法上的外掛名稱
 * @param target     目標方法所屬的物件,需要注意的是@Advice.This不能標識構造方法
 * @param method     目標方法
 * @param arguments  方法引數
 * @return 方法執行返回的臨時資料
 * @since 0.0.1
 */
@Advice.OnMethodEnter
public static <T> T beforeMethod(
        // 接收動態傳遞過來的引數
        @PluginName String pluginName,
        // optional=true,表示this註解可以接收:構造方法或靜態方法(會將this賦值為null),而不報錯
        @Advice.This(optional = true) Object target,
        // 目標方法
        @Advice.Origin Method method,
        // nullIfEmpty=true,表示可以接收空引數
        @Advice.AllArguments(nullIfEmpty = true) Object[] arguments
) {
    String[] parameterNames = new String[]{};
    T transmitResult = null;
    try {
        InstanceMethodInterceptor<T> interceptor = getInterceptor(pluginName);
        // 執行beforeMethod的攔截邏輯
        transmitResult = interceptor.beforeMethod(target, method, parameterNames, arguments);
    } catch (Throwable e) {
        InternalLogger.AutoDetect.INSTANCE.error("InstanceMethodAdvice beforeMethod occurred error", e);
    }
    return transmitResult;
}

一個是afterMethod,負責在目標方法被呼叫之後進行攔截:

/**
 * 在方法執行後進行切面
 *
 * @param pluginName     繫結在該目標方法上的外掛名稱
 * @param transmitResult beforeMethod所傳遞過來的臨時資料
 * @param originResult   目標方法原始返回結果,如果目標方法是void型,則originResult為null
 * @param throwable      目標方法丟擲的異常
 */
@Advice.OnMethodExit(onThrowable = Throwable.class)
public static <T> void afterMethod(
        // 接收動態傳遞過來的引數
        @PluginName String pluginName,
        // beforeMethod傳遞過來的臨時資料
        @Advice.Enter T transmitResult,
        // typing=DYNAMIC,表示可以接收void型別的方法
        @Advice.Return(typing = Assigner.Typing.DYNAMIC) Object originResult,
        // 目標方法自己丟擲的執行時異常,可以在方法中進行捕獲,看具體的需求
        @Advice.Thrown Throwable throwable
) {
    try {
        InstanceMethodInterceptor<T> interceptor = getInterceptor(pluginName);
        // 執行afterMethod的攔截邏輯
        interceptor.afterMethod(transmitResult, originResult);
    } catch (Throwable e) {
        InternalLogger.AutoDetect.INSTANCE.error("InstanceMethodAdvice afterMethod occurred error", e);
    }
}

Advice的特點是:不會更改目標類的位元組碼結構,比如:不會增加欄位、方法,不會修改方法的引數等等。

四、方案實現

該增強元件是一個輕量化的通用的增強包,幾乎可以實現你能想到的任意功能,本次我們的需求是要採集特定目標方法的方法耗時,以便分析出方法的效能瓶頸。

1 定義外掛

基於該元件我們需要實現兩個類:一個是外掛,一個是攔截器。

外掛中主要實現的是兩個方法:匹配特定的型別,匹配特定的方法。

這裡的型別匹配或方法匹配,是採用的ByteBuddy的ElementMatcher,它是一個非常靈活的匹配器,在ElementMatchers中有很多內建的匹配實現,只要你能想到的匹配方式,透過它幾乎都能實現匹配。

匹配特定的型別目前我定義了兩種匹配方式,一種是根據類名(或者包名),一種是根據方法上的註解,具體的程式碼實現如下:

public class MethodCallPlugin extends EnhancedPlugin {

    private final List<String> anyClassNameStartWith;
    private final List<String> anyAnnotationNameOnMethod;

    /**
     * 方法呼叫攔截外掛
     *
     * @param anyClassNameStartWith     任何包路徑,或者全限定類名
     * @param anyAnnotationNameOnMethod 任何方法上的註解的全限定名稱
     */
    public MethodCallPlugin(List<String> anyClassNameStartWith, List<String> anyAnnotationNameOnMethod) {
        boolean nameStartWithInvalid = anyClassNameStartWith == null || anyClassNameStartWith.isEmpty();
        boolean annotationNameOnMethodInvalid = anyAnnotationNameOnMethod == null || anyAnnotationNameOnMethod.isEmpty();
        if (nameStartWithInvalid && annotationNameOnMethodInvalid) {
            throw new IllegalArgumentException("anyClassNameStartWith and anyAnnotationNameOnMethod can't be both empty");
        }
        this.anyClassNameStartWith = anyClassNameStartWith;
        this.anyAnnotationNameOnMethod = anyAnnotationNameOnMethod;
    }

    @Override
    public ElementMatcher.Junction<TypeDescription> typeMatcher() {
        ElementMatcher.Junction<TypeDescription> anyTypes = none();
        if (anyClassNameStartWith != null && !anyClassNameStartWith.isEmpty()) {
            for (String classNameStartWith : anyClassNameStartWith) {
                // 根據類的字首或者全限定類名進行匹配
                anyTypes = anyTypes.or(nameStartsWith(classNameStartWith));
            }
        }
        if (anyAnnotationNameOnMethod != null && !anyAnnotationNameOnMethod.isEmpty()) {
            ElementMatcher.Junction<MethodDescription> methodsWithAnnotation = none();
            for (String annotationNameOnMethod : anyAnnotationNameOnMethod) {
                // 根據方法上是否有特定註解進行匹配
                methodsWithAnnotation = methodsWithAnnotation.or(isAnnotatedWith(named(annotationNameOnMethod)));
            }
            anyTypes = anyTypes.or(declaresMethod(methodsWithAnnotation));
        }
        return anyTypes;
    }
}

匹配特定方法的邏輯就比較簡單了,可以匹配除了構造方法之外的任意方法:


public class MethodCallPlugin extends EnhancedPlugin {

    @Override
    public ElementMatcher.Junction<MethodDescription> methodMatcher() {
        return any().and(not(isConstructor()));
    }
}

2 實現攔截器

型別匹配和方法都匹配到之後,就需要實現方法增強的攔截器了:

我們需要獲取方法呼叫的資訊,包括方法名、呼叫堆疊及深度、呼叫的耗時,所以我們需要定義三個ThreadLocal用來儲存方法呼叫的堆疊:

/**
 * 方法呼叫資訊的攔截器
 * 在方法呼叫之前進行攔截,將方法呼叫資訊封裝後,放入堆疊中,
 * 在方法呼叫之後,從堆疊中將所有方法取出來,按照進入堆疊的順序進行排序,
 * 得到方法呼叫資訊的列表,最後將該列表交給{@link MethodCallHandler}進行處理
 * 如果使用者指定了自己的{@link MethodCallHandler}則優先使用使用者自定義的Handler進行處理
 * 否則使用SDK內建的{@link MethodCallHandler.PrintLogHandler}進行處理,即將方法呼叫資訊列印到日誌中
 *
 * @auther houyi.wh
 * @date 2023-08-16 10:16:48
 * @since 0.0.1
 */
public class MethodCallInterceptor implements InstanceMethodInterceptor<MethodCall> {

    /**
     * 當前方法進入方法棧的順序
     * 用以最後一個方法出棧後,進行方法呼叫棧的排序
     *
     * @since 0.0.1
     */
    private static final ThreadLocal<AtomicInteger> methodEnterStackOrderThreadLocal = new TransmittableThreadLocal<AtomicInteger>() {
        @Override
        protected AtomicInteger initialValue() {
            return new AtomicInteger(0);
        }
    };

    /**
     * 當前方法呼叫棧
     *
     * @since 0.0.1
     */
    private static final ThreadLocal<Deque<MethodCall>> methodStackThreadLocal = new ThreadLocal<Deque<MethodCall>>() {
        @Override
        protected Deque<MethodCall> initialValue() {
            return new ArrayDeque<>();
        }
    };

    /**
     * 當前方法棧中所有方法呼叫的資訊
     *
     * @since 0.0.1
     */
    private static final ThreadLocal<List<MethodCall>> methodCallThreadLocal = new ThreadLocal<List<MethodCall>>() {
        @Override
        protected ArrayList<MethodCall> initialValue() {
            return new ArrayList<>();
        }
    };
    
}

這裡主要使用了三個ThreadLocal來儲存方法呼叫過程中的資料:方法的完整堆疊、方法進入堆疊的順序、方法的呼叫資訊列表,為什麼使用ThreadLocal而不是TransmittableThreadLocal,這裡先按下不表,後面我們透過具體的例子來分析下原因。

緊接著,我們需要定義方法進入前的攔截邏輯,將方法呼叫資訊壓入堆疊中:


@Override
public MethodCall beforeMethod(Object target, Method method, String[] parameters, Object[] arguments) {
    // 排除掉各種非法攔截到的方法
    if (target == null) {
        return null;
    }
    String methodName = target.getClass().getName() + ":" + method.getName() + "()";
    Deque<MethodCall> methodCallStack = methodStackThreadLocal.get();
    // 當前方法進入整個方法呼叫棧的順序
    int methodEnterOrder = methodEnterStackOrderThreadLocal.get().addAndGet(1);
    // 當前方法在整個方法棧中的深度
    int methodInStackDepth = methodCallStack.size() + 1;

    MethodCall methodCall = MethodCall.Default.of()
            .setMethodName(methodName)
            .setCallTime(System.nanoTime())
            .setThreadName(Thread.currentThread().getName())
            .setCurrentMethodEnterStackOrder(methodEnterOrder)
            .setCurrentMethodInStackDepth(methodInStackDepth);
    // 將當前方法的呼叫資訊壓入呼叫棧
    methodCallStack.push(methodCall);
    return methodCall;
}

最後在方法退出時,我們需要從ThreadLocal中取出方法呼叫資訊,並做相關的處理:

@Override
public void afterMethod(MethodCall transmitResult, Object originResult) {
    if (target == null) {
        return null;
    }
    Deque<MethodCall> methodCallStack = methodStackThreadLocal.get();
    MethodCall lastMethodCall = methodCallStack.pop();
    // 毫秒單位的耗時
    double costTimeInMills = (double) (System.nanoTime() - lastMethodCall.getCallTime()) / 1000000.0;
    lastMethodCall.setCostInMills(costTimeInMills);

    List<MethodCall> methodCallList = methodCallThreadLocal.get();
    methodCallList.add(lastMethodCall);
    // 如果堆疊空了,則說明最頂層的方法已經退出了
    if (methodCallStack.isEmpty()) {
        // 對方法呼叫列表進行排序
        sortMethodCallList(methodCallList);
        // 獲取MethodCallHandler對MethodCall的資訊進行處理
        MethodCallHandler methodCallHandler = Configuration.Global.getGlobal().getMethodCallHandler();
        methodCallHandler.handle(methodCallList);

        // 方法退出時,將ThreadLocal中儲存的內容清空掉,而不是將ThreadLocal remove,
        // 因為如果每次方法退出時,都將ThreadLocal都清空,當下一個方法再進入時又需要初始化新的ThreadLocal,效能會有損耗
        methodCallStack.clear();
        methodCallList.clear();
        // 將臨時儲存的方法呼叫順序清空
        methodEnterStackOrderThreadLocal.get().set(0);
    }
}

private void sortMethodCallList(List<MethodCall> methodCallList) {
    methodCallList.sort(new Comparator<MethodCall>() {
        @Override
        public int compare(MethodCall o1, MethodCall o2) {
            // 根據每個方法進入方法棧的順序進行排序
            return Integer.compare(o1.getCurrentMethodEnterStackOrder(), o2.getCurrentMethodEnterStackOrder());
        }
    });
}

需要注意的是,這裡我定義了一個MethodCallHandler介面,該介面可以實現對採集到的方法呼叫資訊的處理,使用者可以自定義自己的MethodCallHandler。元件中也提供了預設的實現,即將採集到的方法呼叫資訊列印到日誌中:

五、方案測試

1 普通方法

我們定義一個方法呼叫的測試樣例類,其中定義了很多普通的方法,如下所示:


public class MethodCallExample {
    public void costTime1() {
        System.out.println("costTime1");
        randomSleep();
        innerCostTime1();
    }
    public void costTime2() {
        System.out.println("costTime2");
        randomSleep();
        innerCostTime2();
    }
    public void costTime3() {
        System.out.println("costTime3");
        randomSleep();
    }
    public void innerCostTime1() {
        System.out.println("innerCostTime1");
        randomSleep();
    }
    public void innerCostTime2() {
        System.out.println("innerCostTime2");
        randomSleep();
    }
    private void randomSleep() {
        Random random = new Random();
        try {
            Thread.sleep(random.nextInt(100));
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

啟動Enhancer,並呼叫測試樣例中的方法:

public static void main(String[] args) {
    MethodCallPlugin plugin = new MethodCallPlugin(Collections.singletonList("com.shizhuang.duapp.enhancer.example"), null);
    Enhancer enhancer = Enhancer.Default.INSTANCE;
    enhancer.enhance(Configuration.of().setPlugins(Collections.singletonList(plugin)));

    MethodCallExample example = new MethodCallExample();
    example.costTime1();
    example.costTime2();
    example.costTime3();
        
    try {
        // 這裡主要是防止主執行緒提前結束
        Thread.sleep(5000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}

執行後,可以得到如下的結果:

從結果上看已經可以滿足絕大多數的情況了,我們拿到了每個方法的呼叫耗時,以及整個方法的呼叫堆疊資訊。

但是這裡的方法都是同步方法,如果有非同步方法,會怎麼樣呢?

2 非同步方法

我們將其中一個方法改成非同步執行緒執行:


private void randomSleep() {
    new Thread(() -> {
        Random random = new Random();
        try {
            Thread.sleep(random.nextInt(100));
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();
}

再次執行後,得到如下的結果:

從結果中可以看到,因為randomSleep方法中透過Thread變成了非同步執行,而增強器攔截到的randomSleep實際是Thread.start()的方法耗時,Thread內部的Runnable的方法耗時沒有采集到。

3 表示式

為什麼Runnable的方法耗時沒有采集到呢?原因是Runnable內部是一個lambda表示式,生成的是一個匿名方法,而匿名方法的預設是無法被攔截到的。

具體的原因可以參考這篇文章

ByteBuddy的作者解釋了lambda的特殊性,包括為什麼無法對lambda做instrument,以及ByteBuddy為了實現對lambda表示式的攔截做了一些支援。

不過只在OpenJDK8u40版本以上才能生效,因為之前版本的JDK在invokedynamic指令上有bug。

我們開啟這個Lambda的策略開關:

可以攔截到lambda表示式生成的匿名方法了:

如果我們不開啟Lambda的策略開關,也可以將匿名方法實現為具名方法:

private void randomSleep() {
    new Thread(() -> {
        doSleep();
    }).start();
}

private void doSleep() {
    Random random = new Random();
    try {
        Thread.sleep(random.nextInt(100));
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}

甚至可以攔截到lambda方法中的具名方法:

4 TransmittableThreadLocal

上面我提了一個問題,為什麼攔截器中儲存方法呼叫資訊的ThreadLocal不用TransmittableThreadLocal,而是用普通的ThreadLocal,這裡我們把攔截器中的程式碼改一下:

執行後發現效果如下:

可以看到非同步方法和主方法合併到一起了,原因是我們儲存方法呼叫堆疊資訊使用了TransmittableThreadLocal,而TTL是會在主子執行緒中共享變數的,當主執行緒中的costTime1方法還未退出堆疊時,子執行緒中的doSleep方法已經進入堆疊了,所以導致堆疊資訊一直未清空,而我們是在每個方法退出時判斷當前執行緒中的堆疊是否為空,如果為空則說明方法呼叫的最頂層方法已經退出了,但是TTL導致堆疊不為空,只有當所有方法執行完畢後堆疊才為空,所以出現了這樣的情況。所以這裡儲存方法呼叫堆疊的ThreadLocal需要用原生的ThreadLocal。

5 串聯主子執行緒

那麼怎麼實現一個方法的主方法在不同的主子執行緒中串起來呢?

透過常規的共享堆疊的方案無法實現主子執行緒中的方法的串聯,那麼可以透過TraceId來實現方法的串聯,鏈路追蹤的技術方案中提供了TraceId和rpcId兩字欄位,分別用來表示一個請求的唯一鏈路以及每個方法在該鏈路中的順序(透過rpcId來表示)。這裡我們只需要利用鏈路追蹤裡面的TraceId來串聯同一個方法即可。具體的原理可以描述如下:

由於不同的鏈路追蹤的實現方式不同,我這裡定義了一個Tracer介面,由使用者指定具體的Tracer實現:


/**
 * 鏈路追蹤器
 *
 * @auther houyi.wh
 * @date 2023-08-22 14:59:50
 * @since 0.0.1
 */
public interface Tracer {

    /**
     * 獲取鏈路id
     *
     * @return 鏈路id
     * @since 0.0.1
     */
    String getTraceId();

    /**
     * 一個空的實現類
     * @since 0.0.1
     */
    enum Empty implements Tracer {
        INSTANCE;

        @Override
        public String getTraceId() {
            return "";
        }
    }
}

然後在Configuration中設定該Tracer:


// 啟動程式碼增強
Enhancer enhancer = Enhancer.Default.INSTANCE;
Configuration config = Configuration.of()
      // 指定自定義的Tracer
      .setTracer(yourTracer)
      .xxx() // 其他配置項
      ;
enhancer.enhance(config);

需要注意的是,如果不指定Tracer,則會預設使用內建的空實現:

六、效能測試

該元件的主要是透過攔截器進行程式碼增強,因為我們需要對攔截器的beforeMethod和afterMethod進行效能測試,通常常規的效能測試,是透過JMH基準測試工具來做的。

我們定義一個基準測試的類:

/*
 * 因為 JVM 的 JIT 機制的存在,如果某個函式被呼叫多次之後,JVM 會嘗試將其編譯成為機器碼從而提高執行速度。
 * 所以為了讓 benchmark 的結果更加接近真實情況就需要進行預熱
 * 其中的引數 iterations 是預熱輪數
 */
@Warmup(iterations = 1)
/*
 * 基準測試的型別:
 * Throughput:吞吐量,指1s內可以執行多少次操作
 * AverageTime:呼叫時間,指1次呼叫所耗費的時間
 */
@BenchmarkMode({Mode.AverageTime, Mode.Throughput})
/*
 * 測試的一些度量
 * iterations:進行測試的輪次
 * time:每輪進行的時長
 * timeUnit:時長單位
 */
@Measurement(iterations = 2, time = 1)
/*
 * 基準測試結果的時間型別。一般選擇秒、毫秒、微秒。
 */
@OutputTimeUnit(TimeUnit.MILLISECONDS)
/*
 * fork出幾個進場進行測試。
 * 如果 fork 數是 2 的話,則 JMH 會 fork 出兩個程式來進行測試。
 */
@Fork(value = 2)
/*
 * 每個程式中測試執行緒的個數。
 */
@Threads(8)
/*
 * State 用於宣告某個類是一個“狀態”,然後接受一個 Scope 引數用來表示該狀態的共享範圍。
 * 因為很多 benchmark 會需要一些表示狀態的類,JMH 允許你把這些類以依賴注入的方式注入到 benchmark 函式里。
 * Scope 主要分為三種:
 * Thread - 該狀態為每個執行緒獨享。
 * Group - 該狀態為同一個組裡面所有執行緒共享。
 * Benchmark - 該狀態在所有執行緒間共享。
 */
@State(Scope.Benchmark)
public class MethodCallInterceptorBench {

    private MethodCallInterceptor methodCallInterceptor;
    private Object target;
    private Method method;
    private String[] parameters;
    private Object[] arguments;

    @Setup
    public void prepare() {
        methodCallInterceptor = new MethodCallInterceptor();
        target = new MethodCallExample();
        try {
            method = target.getClass().getMethod("costTime1");
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
        parameters = null;
        arguments = null;
    }

    @Benchmark
    public void testMethodCallInterceptor_beforeMethod() {
        methodCallInterceptor.beforeMethod(target, method, parameters, arguments);
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(MethodCallInterceptorBench.class.getSimpleName())
                .build();

        new Runner(opt).run();
    }

}

基準測試的結果如下:

針對beforeMethod方法做了吞吐量和平均耗時的測試,每次呼叫的平均耗時為0.592ms,而吞吐量則為1ms內可以執行82.99次呼叫。

七、使用方式

引入該Enhancer元件的依賴:

<dependency>
    <groupId>com.shizhuang.duapp</groupId>
    <artifactId>commodity-common-enhancer</artifactId>
    <version>${commodity-common-enhancer-version}</version>
</dependency>

使用很簡單,只需要在專案啟動之後,呼叫程式碼增強的方法即可,對現有的業務程式碼幾乎無侵入。

不指定配置資訊,直接啟動:


public class CommodityAdminApplication {
    public static void main(String[] args) {
        SpringApplication.run(CommodityAdminApplication.class, args);
        // 啟動程式碼增強
        Enhancer enhancer = Enhancer.Default.INSTANCE;
        enhancer.enhance(null);
    }
}

指定配置資訊啟動:


public class CommodityAdminApplication {
    public static void main(String[] args) {
        SpringApplication.run(CommodityAdminApplication.class, args);
        // 啟動程式碼增強
        Enhancer enhancer = Enhancer.Default.INSTANCE;
        Configuration config = Configuration.of()
              .setPlugins(Collections.singletonList(plugin))
              .xxx() // 其他配置項
              ;
        enhancer.enhance(config);
    }
}

1 實現方法耗時過濾

比如你只想對方法耗時大於xx毫秒的方法進行分析,你可以在定義的MethodCallHandler中引入ark配置,然後過濾出耗時大於xx毫秒的方法,如:

enum MyCustomHandler implements MethodCallHandler {
    INSTANCE;
    
    private double maxCostTime() {
        // 這裡可以透過動態配置想要分析的方法耗時的最小值
        return 500;
    }

    @Override
    public void handle(List<MethodCall> methodCallList) {
        logger.info("=========================================================================");
        // 檢查方法耗時超過xx時,才列印
        MethodCall firstMethodCall = methodCallList.stream().findFirst().orElse(null);
        if (firstMethodCall == null) {
            return;
        }
        // 方法耗時
        double costInMills = firstMethodCall.getCostInMills();
        int currentMethodEnterStackOrder = firstMethodCall.getCurrentMethodEnterStackOrder();
        // 如果整體的方法小於500毫秒,則直接放棄
        if (currentMethodEnterStackOrder == 1 && costInMills < maxCostTime()) {
            return;
        }
        // 然後在這裡實現方法耗時的列印
        logger.info(getMethodCallInfo(methodCallList));
    }
}

2 實現整體開關控制

比如你想透過動態開關來控制對方法耗時的統計分析,可以實現MethodCallSwither介面,然後在Configuration中傳入自定義的MethodCallSwitcher,如下所示:

請注意,如果使用者不指定MethodCallSwitcher,SDK會使用內建的MethodCallSwitcher.NeverStop 實現,表示永遠不會停止採集。


/**
 * 是否停止採集MethodCall的開關
 *
 * @auther houyi.wh
 * @date 2023-08-27 18:56:47
 * @since 0.0.1
 */
public interface MethodCallSwitcher {

    /**
     * 是否停止對方法的MethodCall的採集
     * 如果返回true,則會停止對方法MethodCall的採集
     *
     * @return true:停止採集 false:繼續採集
     */
    boolean stopScratch();

    /**
     * 永遠不停止採集
     */
    enum NeverStop implements MethodCallSwitcher {
        INSTANCE;

        @Override
        public boolean stopScratch() {
            // 一直進行採集
            return false;
        }
    }

}

八、擴充套件能力

使用者如果想要實現自己的擴充套件能力,只需要實現EnhancedPlugin,以及Interceptor即可。

1 實現自定義外掛

透過如下方式實現自定義外掛:


public MyCustomePlugin extends EnhancedPlugin {
    
    @Override
    public ElementMatcher.Junction<TypeDescription> typeMatcher() {
      // 實現型別匹配
    }
    
    @Override
    public ElementMatcher.Junction<MethodDescription> methodMatcher() {
      // 實現方法匹配
    }
    
    @Override
    public Class<? extends Interceptor> interceptorClass() {
      // 指定攔截器
      return MyInterceptor.class;
    }
}

2 實現攔截器

// 臨時傳遞資料的物件
public class Carrier {

}

public class MyInterceptor implements InstanceMethodInterceptor<Carrier> {

    @Override
    public Carrier beforeMethod(Object target, Method method, String[] parameters, Object[] arguments) {
         // 實現方法呼叫前攔截
    }
    @Override
    public void afterMethod(Carrier transmitResult, Object originResult) {
        // 實現方法呼叫後攔截
    }
}

3 啟動外掛

最後在專案啟動時,啟用自定義的外掛,如下所示:

public class CommodityAdminApplication {
    public static void main(String[] args) {
        SpringApplication.run(CommodityAdminApplication.class, args);
        // 啟動程式碼增強
        Enhancer enhancer = Enhancer.Default.INSTANCE;
        Configuration config = Configuration.of()
              // 指定自定義的外掛
              .setPlugins(Collections.singletonList(new MyCustomePlugin()))
              .xxx() // 其他配置項
              ;
        enhancer.enhance(config);
    }
}

九、總結與規劃

本篇文章我們介紹了在專案中遇到的效能診斷的需求和場景,並提供了一種透過插樁的方式對具體方法進行分析的技術方案,介紹了方案中遇到的難點以及解決方法,以及實際使用過程中可能存在的擴充套件場景。

未來我們將使用Enhancer在執行時動態的獲取應用系統的效能分析資料,比如透過對某些效能有問題的嫌疑程式碼進行增強,提取到效能分析的資料後,最後結合Grafana大盤,展示出系統的效能大盤。

*文 / 逅弈

本文屬得物技術原創,更多精彩文章請看:得物技術官網

未經得物技術許可嚴禁轉載,否則依法追究法律責任!

相關文章