Phân tích thực tế: Tối ưu hóa hiệu năng với các mẫu thiết kế

Các mẫu thiết kế là tập hợp những kỹ thuật lập trình thường được sử dụng, giúp các lập trình viên trao đổi về vấn đề kỹ thuật một cách chuyên nghiệp và thuận tiện hơn. Chẳng hạn, khi nhắc đến việc module I/O sử dụng mẫu Decorator trong tài liệu trước đó, bạn có thể dễ dàng hình dung được cách tổ chức code của module đó.

Thực tế, phần lớn các mẫu thiết kế không làm tăng hiệu năng của chương trình, chúng chỉ là một cách tổ chức code. Trong bài viết này, chúng ta sẽ tìm hiểu một số mẫu thiết kế liên quan đến hiệu năng như Proxy, Singleton, Flyweight và Prototype.

Tìm nguyên nhân chậm trong Proxy động bằng Arthas

Spring sử dụng rộng rãi mẫu Proxy, với CGLIB để tăng cường bytecode Java. Trong các dự án phức tạp, có rất nhiều code AOP như xử lý quyền truy cập, ghi log. Mặc dù tiện lợi cho việc lập trình, AOP cũng gây khó khăn cho những ai chưa quen thuộc với codebase.

Dưới đây là cách sử dụng arthas để tìm nguyên nhân gây chậm trong proxy động, phương pháp này rất hiệu quả trong các dự án phức tạp mà không cần hiểu rõ code dự án.

Đầu tiên, tạo một Bean đơn giản nhất:

@Component 
public class DataService { 
    public void xuLy() { 
        System.out.println("==================="); 
    } 
} 

Sau đó, sử dụng annotation Aspect để định nghĩa pointcut, trong method trước khi gọi, chúng ta cho thread ngủ 1 giây.

@Aspect 
@Component 
public class LoggerAspect { 
    @Pointcut("execution(* com.example.demo.DataService.*(..)))") 
    public void cutPoint() { 
    }  
    @Before("cutPoint()") 
    public void beforeExecute() { 
        System.out.println("beforeExecute"); 
        try { 
            Thread.sleep(TimeUnit.SECONDS.toMillis(1)); 
        } catch (InterruptedException e) { 
            throw new IllegalStateException(); 
        } 
    } 
} 

Tạo một Controller, khi truy cập /proxy endpoint, sẽ hiển thị tên class của Bean và thời gian thực thi.

@RestController 
public class ProxyController { 
    @Autowired 
    private DataService dataService;  
    @GetMapping("/proxy") 
    public String proxy() { 
        long start = System.nanoTime(); 
        dataService.xuLy(); 
        long time = System.nanoTime() - start; 
        String clazz = dataService.getClass().toString(); 
        return clazz + " | " + time / 1_000_000 + " ms"; 
    } 
} 

Kết quả thực thi như sau, có thể thấy proxy AOP đã hoạt động, đối tượng Bean trong memory đã trở thành loại EnhancerBySpringCGLIB, gọi method xuLy mất khoảng 1023ms.

class com.example.demo.DataService$$EnhancerBySpringCGLIB$$b7e3f912 | 1023 ms

Tiếp theo, sử dụng arthas để phân tích quá trình thực thi này và tìm method AOP có thời gian chờ cao nhất. Sau khi khởi động arthas, từ danh sách ứng dụng, chọn số 2 để vào giao diện phân tích.

Nhập lệnh trace vào terminal, sau đó truy cập /proxy endpoint, terminal sẽ in ra thông tin debug và có thể phát hiện thao tác chậm chính là class proxy của Spring.

trace com.example.demo.DataService xuLy 

Mẫu Proxy

Mẫu Proxy cho phép kiểm soát việc truy cập vào một đối tượng thông qua class đại diện.

Trong Java, có hai cách chính để tạo proxy động: sử dụng JDK hoặc sử dụng CGLib.

  • JDK yêu cầu các class phải implement interface, các class liên quan chính là InvocationHandler và Proxy.
  • CGLib có thể proxy các class thường, các class liên quan chính là MethodInterceptor và Enhancer.

Kiến thức này có tần suất xuất hiện cao trong các buổi phỏng vấn, repository mẫu có đầy đủ code của hai cách triển khai này.

Dưới đây là kết quả JMH test về tốc độ proxy của hai cách JDK và CGLib:

Benchmark               Mode  Cnt      Score       Error   Units 
ProxyBenchmark.cglib   thrpt   10  78499.580 ± 1771.148  ops/ms 
ProxyBenchmark.jdk     thrpt   10  88948.858 ±  814.360  ops/ms 

Java JDK phiên bản 1.8 đang được sử dụng, có thể thấy tốc độ CGLib không nhanh như nhiều người đồn đại (có tin đồn nhanh gấp 10 lần), thực tế tốc độ của nó còn chậm hơn một chút. Hãy xem tốc độ tạo proxy, kết quả như sau. Có thể thấy về khởi tạo proxy class, throughput của JDK cao gấp đôi CGLib.

Benchmark                     Mode  Cnt      Score       Error   Units 
ProxyCreateBenchmark.cglib   thrpt   10   7281.487 ± 1339.779  ops/ms 
ProxyCreateBenchmark.jdk     thrpt   10  15612.467 ±  268.362  ops/ms 

Tóm lại, tốc độ tạo và thực thi của JDK dynamic proxy và CGLib proxy trong các phiên bản Java mới không khác biệt nhiều. Spring chọn CGLib chủ yếu vì nó có thể proxy các class thường.

Mẫu Singleton

Khi tạo component trong Spring, có thể sử dụng annotation scope để xác định scope là prototype (nhiều instance) hay singleton (một instance).

Khi đặt là singleton (mặc định), trong Spring container, component chỉ có duy nhất một instance, khi inject component liên quan, bạn cũng nhận được cùng một instance.

Với các class singleton thông thường, thường đặt constructor ở chế độ private, singleton có hai kiểu lazy loading và eager loading.

Những ai hiểu cơ chế load class của JVM đều biết, một class từ lúc load đến lúc initialization trải qua 5 bước: Loading, Verification, Preparation, Resolution, Initialization.

Trong đó, các trường static và khối static thuộc về class, được thực thi ở giai đoạn initialization. Nó tương ứng với method trong bytecode, thuộc về class (constructor). Vì initialization của class chỉ xảy ra một lần, nên có thể đảm bảo thread-safe cho thao tác này.

Dựa trên nguyên lý trên, chỉ cần đặt thao tác khởi tạo singleton vào method sẽ tạo ra eager mode.

private static SingletonExample instance = new SingletonExample();  

Eager mode hiếm khi được sử dụng trong code, nó gây lãng phí tài nguyên khi tạo ra nhiều đối tượng có thể không bao giờ được dùng. Còn khởi tạo đối tượng khác, thông thường khi new một đối tượng mới sẽ gọi constructor để khởi tạo các thuộc tính. Vì cùng lúc có nhiều thread gọi hàm, cần sử dụng từ khóa synchronized để đồng bộ quá trình tạo.

Hiện tại, mẫu singleton được công nhận rộng rãi về cả thread-safe và hiệu suất chính là double check. Nhiều interviewer sẽ yêu cầu bạn viết và phân tích nguyên lý của double check.

Trên hình là code quan trọng của double check, có bốn điểm chính:

  • Kiểm tra lần đầu, khi instance bằng null thì vào logic tạo instance, ngược lại trả về ngay.
  • Thêm synchronized lock, đây là class-level lock.
  • Kiểm tra lần hai mới là quan trọng. Nếu không có kiểm tra này, có thể nhiều thread vào khối synchronized và tạo ra nhiều instance.
  • Điểm quan trọng cuối là từ khóa volatile. Trong một số Java phiên bản thấp, do việc sắp xếp lại instruction, có thể singleton được new ra nhưng chưa kịp chạy constructor đã bị thread khác sử dụng. Từ khóa này có thể ngăn chặn việc sắp xếp lại bytecode instruction, khi viết double check nên thêm volatile.

Có thể thấy, cách viết double check phức tạp, nhiều điểm cần lưu ý, thực tế đây là anti-pattern, không còn được khuyến khích sử dụng, cũng không khuyến khích dùng trong code của bạn. Nhưng nó có thể kiểm tra hiểu biết về concurrency của ứng viên, nên câu hỏi này thường được hỏi.

Khuyến nghị sử dụng enum để implement singleton lazy loading, đoạn code như sau:

Cuốn Effective Java cũng khuyến nghị cách này.

public class EnumSingleton { 
    private EnumSingleton() { 
    } 
    public static EnumSingleton getInstance() { 
        return Holder.INSTANCE.value; 
    } 
    private enum Holder { 
        INSTANCE; 
        private final EnumSingleton value; 
        Holder() { 
            value = new EnumSingleton(); 
        } 
    } 
} 

Mẫu Flyweight

Flyweight là mẫu thiết kế hiếm hoi được tối ưu hóa đặc biệt cho hiệu năng, nó sử dụng kỹ thuật chia sẻ để tái sử dụng đối tượng tối đa. Flyweight thường sử dụng ID duy nhất để kiểm tra và trả về đối tượng tương ứng, rất phù hợp để lưu trữ với HashMap hoặc các collection tương tự.

Mô tả trên rất quen thuộc, trong các bài trước đã thấy nhiều ứng dụng của Flyweight như object pooling trong bài về pool hay object reuse trong bài về tái sử dụng đối tượng lớn.

Các mẫu thiết trừu tượng hóa cách lập trình hàng ngày của chúng ta, từ các góc độ khác nhau để giải thích đều có thể tìm thấy điểm chung trong tư tưởng thiết kế. Ví dụ, Singleton là trường hợp đặc biệt của Flyweight, nó đạt được tái sửu dụng đối tượng thông qua việc chia sẻ một instance duy nhất.

Đáng chú ý, cùng một đoạn code, cách giải thích khác nhau sẽ tạo ra hiệu ứng khác nhau. Ví dụ đoạn code sau:

Map<String,Algorithm> algos = new HashMap<>(); 
algos.put("a", new AlgorithmA()); 
algos.put("b", new AlgorithmB()); 

Nếu nhìn từ góc độ tái sử dụng đối tượng, đây là Flyweight. Nếu nhìn từ góc độ chức năng của đối tượng, đây là Strategy. Vì vậy khi thảo luận về design pattern, cần chú ý sự khác biệt về ngữ cảnh.

Mẫu Prototype

Prototype gần với tư duy copy-paste, có thể tạo một instance đầu tiên, sau đó tạo các đối tượng mới từ instance đó. Trong Java, điển hình nhất là method clone của class Object.

Nhưng trong lập trình thực tế, method này ít được sử dụng. Prototype mà chúng ta đề cập ở phần Proxy không được implement bằng clone mà sử dụng kỹ thuật phức tạp hơn là reflection.

Một lý do quan trọng là clone nếu chỉ copy đến level hiện tại sẽ chỉ được shallow copy. Trong thực tế, đối tượng thường rất phức tạp, để implement deep copy cần viết rất nhiều code trong method clone, không tiện bằng việc gọi new.

Để implement deep copy, còn có các phương tiện serialization như implement Serializable hoặc chuyển đổi object thành JSON.

Vì vậy, trong thực tế, Prototype trở thành một tư tưởng, không phải công cụ để tăng tốc độ tạo đối tượng.

Đăng vào ngày 27 tháng 6 lúc 22:40