一起來聊聊與Java中效能相關的設計模式

2022-06-08 14:29:17
本篇文章給大家帶來了關於的相關知識,其中主要介紹了關於與效能相關的設計模式,大多數設計模式只是程式碼的一種組織方式,只有部分設計模式與效能相關,包括代理模式、單例模式、享元模式、原型模式等,下面一起來看一下,希望對大家有幫助。

推薦學習:《》

程式碼的結構對應用的整體效能,有著重要的影響。結構優秀的程式碼,可以避免很多潛在的效能問題,在程式碼的擴充套件性上也有巨大的作用;結構清晰、層次分明的程式碼,也有助於幫你找到系統的瓶頸點,進行專項優化。

設計模式就是對常用開發技巧進行的總結,它使得程式設計師之間交流問題,有了更專業、便捷的方式。

事實上,大多數設計模式並不能增加程式的效能,它只是程式碼的一種組織方式。本文,我們將一一舉例講解和效能相關的幾個設計模式,包括代理模式、單例模式、享元模式、原型模式等。

代理模式

代理模式(Proxy)可以通過一個代理類,來控制對一個物件的存取。

Java 中實現動態代理主要有兩種模式:一種是使用 JDK,另外一種是使用 CGLib。 其中,JDK 方式是面向介面的,主要的相關類是 InvocationHandler 和 Proxy;CGLib 可以代理普通類,主要的相關類是 MethodInterceptor 和 Enhancer。

這個知識點面試頻率非常高。

CGLib

package cn.wja.proxy.cglibproxy;import org.springframework.cglib.proxy.MethodInterceptor;import org.springframework.cglib.proxy.MethodProxy;import java.lang.reflect.Method;public class CglibInterceptor implements MethodInterceptor {
    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        return methodProxy.invokeSuper(o, objects);
    }}
package cn.wja.proxy.cglibproxy;import cn.wja.proxy.jdkproxy.Target;import cn.wja.proxy.jdkproxy.TargetImpl;import org.springframework.cglib.proxy.Enhancer;public class CglibFactory {

    public static Target newInstance() {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(TargetImpl.class);
        enhancer.setCallback(new CglibInterceptor());
        return (Target) enhancer.create();
    }

    public static void main(String[] args) {
        Target target = newInstance();
        System.out.println(target.targetMetod(4));
    }}

JDK

package cn.wja.proxy.jdkproxy;public interface Target {
    int targetMethod(int i);}
package cn.wja.proxy.jdkproxy;public class TargetImpl implements Target {
    @Override
    public int targetMethod(int i) {
        return i * i;
    }}
package cn.wja.proxy.jdkproxy;import java.lang.reflect.InvocationHandler;import java.lang.reflect.Method;public class JdkInvocationHandler implements InvocationHandler {
    private Target target;

    public JdkInvocationHandler(Target target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        //before
        Object object = method.invoke(target, args);
        //after
        return object;
    }}
package cn.wja.proxy.jdkproxy;import java.lang.reflect.Proxy;public class JdkFactory {
    public static Target newInstance(Target target) {
        Object object = Proxy.newProxyInstance(JdkInvocationHandler.class.getClassLoader(),
                new Class<?>[]{Target.class},
                new JdkInvocationHandler(target));
        return Target.class.cast(object);
    }

    public static void main(String[] args) {
        Target t = new TargetImpl();
        Target target = newInstance(t);
        System.out.println(target.targetMethod(4));
    }}

下面是 JDK 方式和 CGLib 方式代理速度的 JMH 測試結果:

BenchmarkModeCntScoreErrorUnits
ProxyBenchmark.cglibthrpt1078499.580±1771.148ops/ms
ProxyBenchmark.jdkthrpt1088948.858±814.360ops/ms

我現在用的 JDK 版本是 1.8,可以看到,CGLib 的速度並沒有傳得那麼快(有傳言高出10 倍),相比較而言,它的速度甚至略有下降。
我們再來看下代理的建立速度,結果如下所示。可以看到,在代理類初始化方面,JDK 的吞吐量要高出 CGLib 一倍。

BenchmarkModeCntScoreErrorUnits
ProxyCreateBenchmark.cglibthrpt107281.487± 1339.779ops/ms
ProxyCreateBenchmark.jdkthrpt1015612.467± 268.362ops/ms

Spring動態代理

Spring 廣泛使用了代理模式,它使用 CGLIB 對 Java 的位元組碼進行了增強。在複雜的專案中,會有非常多的 AOP 程式碼,比如許可權、紀錄檔等切面。在方便了編碼的同時,AOP 也給不熟悉專案程式碼的同學帶來了很多困擾。

下面我將分析一個使用 arthas 找到動態代理慢邏輯的具體原因,這種方式在複雜專案中,非常有效,你不需要熟悉專案的程式碼,就可以定位到效能瓶頸點。

首先,我們建立一個最簡單的 Bean。

package cn.wja.spring;import org.springframework.stereotype.Component;@Componentpublic class ABean {
    public void method() {
        System.out.println("****ABean method*******************");
    }}

然後,我們使用 Aspect 註解,完成切面的書寫,在前置方法裡,我們讓執行緒 sleep 了 1 秒鐘。

package cn.wja.spring;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.annotation.Before;import org.aspectj.lang.annotation.Pointcut;import org.springframework.stereotype.Component;import java.util.concurrent.TimeUnit;@Aspect@Componentpublic class MyAspect {
    @Pointcut("execution(* cn.wja.spring.ABean.*(..)))")
    public void pointcut() {
    }

    @Before("pointcut()")
    public void before() {
        System.out.println("before");
        try {
            Thread.sleep(TimeUnit.SECONDS.toMillis(1));
        } catch (InterruptedException e) {
            throw new IllegalStateException();
        }
    }}

建立一個啟動類,當存取 /aop 連結時,將會輸出 Bean 的類名稱,以及它的耗時。

package cn.wja.spring;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.scheduling.annotation.EnableAsync;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.ResponseBody;@SpringBootApplication@EnableAsync@Controllerpublic class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
    @Autowired
    private ABean aBean;

    @ResponseBody
    @GetMapping("/aop")
    public String aop() {
        long begin = System.currentTimeMillis();
        aBean.method();
        long cost = System.currentTimeMillis() - begin;
        String cls = aBean.getClass().toString();
        return cls + " | " + cost;
    }}

存取結果如下,可以看到 AOP 代理已經生效,記憶體裡的 Bean 物件,已經變成了EnhancerBySpringCGLIB 型別,呼叫方法 method,耗時達到了1005ms。

在這裡插入圖片描述
下面使用 arthas 分析這個執行過程,找出耗時最高的 AOP 方法。啟動 arthas 後,可以從列表中看到我們的應用程式,在這裡,輸入 1 進入分析介面。

在這裡插入圖片描述
在終端輸入 trace 命令,然後存取 /aop 介面,終端將列印出一些 debug 資訊,可以發現耗時操作就是 Spring 的代理類。

trace cn.wja.spring.ABean method

請新增圖片描述

單例模式

Spring 在建立元件的時候,可以通過 scope 註解指定它的作用域,用來標示這是一個prototype(多例)還是 singleton(單例)。

當指定為單例時(預設行為),在 Spring 容器中,元件有且只有一份,當你注入相關元件的時候,獲取的元件範例也是同一份。

如果是普通的單例類,我們通常將單例的構造方法設定成私有的,單例有懶漢載入和餓漢載入模式。

餓漢模式

瞭解 JVM 類載入機制的同學都知道,一個類從載入到初始化,要經歷 5 個步驟:載入、驗證、準備、解析、初始化。
在這裡插入圖片描述
其中,static 欄位和 static 程式碼塊,是屬於類的,在類載入的初始化階段就已經被執行。它在位元組碼中對應的是 方法,屬於類的(構造方法)。因為類的初始化只有一次,所以它就能夠保證這個載入動作是執行緒安全的。

根據以上原理,只要把單例的初始化動作,放在方法裡,就能夠實現餓漢模式。

private static Singleton instace = new Singleton();

理論上來說,餓漢模式它會造成資源的浪費,可能生成一些永遠不會用到的物件,因此很多教學不建議用。但實際上來說,這存粹是脫褲子放屁,如果你真的永遠用不到這個物件,你為何要建立這個類,寫一個單例模式? 我覺得對於普通專案來說,餓漢模式就完全足夠了。

飽漢模式

而物件初始化就不一樣了。通常,我們在 new 一個新物件的時候,都會呼叫它的構造方法,就是,用來初始化物件的屬性。由於在同一時刻,多個執行緒可以同時呼叫函數,我們就需要使用 synchronized 關鍵字對生成過程進行同步。

package cn.wja.singleton;public class DoubleCheckSingleton {
    private volatile static DoubleCheckSingleton instance = null;
    private DoubleCheckSingleton() {
    }

    public static DoubleCheckSingleton getInstance() {
        if (null == instance) {
            synchronized (DoubleCheckSingleton.class) {
                if (null == instance) {
                    instance = new DoubleCheckSingleton();
                }
            }
        }
        return instance;
    }}

如上面是 double check 的關鍵程式碼,我們介紹一下四個關鍵點:

  • 第一次檢查,當 instance 為 null 的時候,進入物件範例化邏輯,否則直接返回。
  • 加同步鎖,這裡是類鎖。
  • 第二次檢查才是關鍵。如果不加這次判空動作,可能會有多個執行緒進入同步程式碼塊,進而生成多個範例。
  • 最後一個關鍵點是 volatile 關鍵字。在一些低版本的 Java 裡,由於指令重排的緣故,可能會導致單例被 new 出來後,還沒來得及執行建構函式,就被其他執行緒使用。 這個關鍵字,可以阻止位元組碼指令的重排序,在寫 double check 程式碼時,習慣性會加上 volatile。

可以看到,double check 的寫法繁雜,注意點很多,它現在其實是一種反模式,已經不推薦使用了,我也不推薦你用在自己的程式碼裡。但它能夠考察面試者對並行的理解,所以這個問題經常被問到。

推薦使用 enum 實現懶載入的單例,《Effective Java》這本書也同樣推薦了該方式。程式碼片段如下:

package cn.wja.singleton;public class EnumSingleton {
    private EnumSingleton() {
    }

    public static EnumSingleton getInstance() {
        return Holder.HOLDER.instance;
    }

    private enum Holder {
        HOLDER;
        private final EnumSingleton instance;
        Holder() {
            instance = new EnumSingleton();
        }
    }

    public static void main(String[] args) {
        System.out.println(getInstance());
    }}

如果要藉助spring框架那就更簡單了:

package cn.wja.singleton;import org.springframework.context.annotation.Scope;import org.springframework.stereotype.Component;@Component@Scope("singleton")public class SpringBean {
    //具體內容}

享元模式

享元模式(Flyweight)專門針對效能優化的設計模式,它通過共用技術,最大限度地複用物件。享元模式一般會使用唯一的標識碼進行判斷,然後返回對應的物件,使用 HashMap 一類的集合儲存非常合適。

上面的描述,我們非常熟悉,因為本專欄的之前的博文中,我們就能看到很多享元模式的身影,比如博文 淺談Java中的池化技術 裡的池化物件和博文 如何處理Java中的大物件 裡的物件複用等。

案例:Integer

在Java中,我們常見的Integer,為了提升效率,在建立[1,127]範圍內的物件時也用了享元模式。通過下面的測試程式碼可以驗證。

@Testpublic void myTest() throws Exception{
    Integer a=1;
    Integer b=1;
    System.out.println(a == b ? "a b同一個物件" : "a b不是同一個物件");

    Integer c=128;
    Integer d=128;
    System.out.println(c == d ? "c d同一個物件" : "c d不是同一個物件");}

在這裡插入圖片描述

多視角看問題

設計模式對這我們平常的編碼進行了抽象,從不同的角度去解釋設計模式,都會找到設計思想的一些共通點。比如,單例模式就是享元模式的一種特殊情況,它通過共用單個範例,達到物件的複用。

值得一提的是,同樣的程式碼,不同的解釋,會產生不同的效果。比如下面這段程式碼:

Map<String,Strategy> strategys = new HashMap<>(); strategys.put("a",new AStrategy()); strategys.put("b",new BStrategy());

如果我們從物件複用的角度來說,它就是享元模式;如果我們從物件的功能角度來說,那它就是策略模式。所以大家在討論設計模式的時候,一定要注意上下文語境的這些差別。

原型模式

原型模式(Prototype)比較類似於複製貼上的思想,它可以首先建立一個範例,然後通過這個範例進行新物件的建立。在 Java 中,最典型的就是 Object 類的 clone 方法。

但編碼中這個方法很少用,我們上面在代理模式提到的 prototype,並不是通過 clone 實現的,而是使用了更復雜的反射技術。

一個比較重要的原因就是 clone 如果只拷貝當前層次的物件,實現的只是淺拷貝。在現實情況下,物件往往會非常複雜,想要實現深拷貝的話,需要在 clone 方法裡做大量的編碼,遠遠不如呼叫 new 方法方便。

實現深拷貝,還有序列化等手段,比如實現 Serializable 介面,或者把物件轉化成 JSON。

所以,在現實情況下,原型模式變成了一種思想,而不是加快物件建立速度的工具。

推薦學習:《》

以上就是一起來聊聊與Java中效能相關的設計模式的詳細內容,更多請關注TW511.COM其它相關文章!