Phân Tích Trường Hợp: Tối Ưu Hiệu Năng Bằng Các Mẫu Thiết Kế

Các mẫu thiết kế là sự tổng hợp các kỹ thuật phát triển phổ biến, giúp các lập trình viên giao tiếp về vấn đề một cách chuyên nghiệp và tiện lợi. Ví dụ, khi chúng ta đề cập đến mô-đun I/O sử dụng mẫu Decorator trong bài "Phân tích lý thuyết: Tối ưu hiệu năng có quy luật rõ ràng, bàn về các điểm切入 phổ biến", bạn có thể dễ dàng hình dung về cách tổ chức mã của mô-đun đó.

Thực tế, hầu hết 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à cách tổ chức mã. Trong bài viết này, chúng ta sẽ đi sâu vào một số mẫu thiết kế liên quan đến hiệu năng, bao gồm mẫu Proxy, Singleton, Flyweight, Prototype và các mẫu khác.

Tìm hiểu nguyên nhân làm chậm logic trong proxy động

Spring sử dụng rộng rãi mẫu Proxy, nó sử dụng CGLIB để tăng cường bytecode của Java. Trong các dự án phức tạp, có rất nhiều mã AOP, chẳng hạn như các khía cạnh về quyền, nhật ký và các mối quan hệ khác. Mặc dù giúp việc mã hóa trở nên thuận tiện, AOP cũng mang lại nhiều thách thức cho những người không quen thuộc với mã dự án.

Dưới đây, tôi sẽ phân tích cách sử dụng Arthas để tìm ra nguyên nhân làm chậm logic trong proxy động, phương pháp này rất hiệu quả trong các dự án phức tạp, bạn không cần phải quen thuộc với mã dự án để xác định các điểm nghẽn hiệu năng.

Đầu tiên, chúng ta tạo một Bean đơn nhất nhất (xem mã trong kho).

@Component 
public class TaiKhoanService { 
    public void xuLy() { 
        System.out.println("Đang xử lý giao dịch..."); 
    } 
} 

Sau đó, chúng ta sử dụng chú thích Aspect để viết các khía cạnh, trong phương thức tiền xử lý, chúng ta cho luồng ngủ 1 giây.

@Aspect 
@Component 
public class GiaoDichAspect { 
    @Pointcut("execution(* com.viettel.spring.TaiKhoanService.*(..)))") 
    public void diemCat() { 
    }  
    @Before("diemCut()") 
    public void truocXuLy() { 
        System.out.println("Bắt đầu kiểm tra giao dịch"); 
        try { 
            Thread.sleep(TimeUnit.SECONDS.toMillis(1)); 
        } catch (InterruptedException e) { 
            throw new IllegalStateException(); 
        } 
    } 
} 

Tạo một Controller, khi truy cập đường dẫn /giao-dich, sẽ xuất tên lớp của Bean và thời gian thực thi.

@Controller 
public class GiaoDichController { 
    @Autowired 
    private TaiKhoanService taiKhoanService;  
    @ResponseBody 
    @GetMapping("/giao-dich") 
    public String thucHienGiaoDich() { 
        long batDau = System.currentTimeMillis(); 
        taiKhoanService.xuLy(); 
        long thoiGian = System.currentTimeMillis() - batDau; 
        String tenLop = taiKhoanService.getClass().toString(); 
        return tenLop + " | " + thoiGian + "ms"; 
    } 
} 

Kết quả thực thi như sau, có thể thấy AOP proxy đã có hiệu lực, đối tượng Bean trong bộ nhớ đã trở thành loại EnhancerBySpringCGLIB, việc gọi phương thức xuLy mất đến 1023ms.

class com.viettel.spring.TaiKhoanService$$EnhancerBySpringCGLIB$$b3c7f28a | 1023 

Dưới đây sử dụng arthas để phân tích quá trình thực thi này, tìm ra phương thức AOP tốn nhiều thời gian nhất. Sau khi khởi động arthas, bạn có thể thấy ứng dụng của mình từ danh sách, tại đây nhập 2 để vào giao diện phân tích.

Trong terminal nhập lệnh trace, sau đó truy cập giao diện /giao-dich, terminal sẽ in ra một số thông tin debug, có thể thấy thao tác tốn thời gian nhất chính là lớp proxy của Spring.

trace com.viettel.spring.TaiKhoanService xuLy 

Mẫu Proxy

Mẫu Proxy (Proxy) cho phép kiểm soát việc truy cập vào một đối tượng thông qua một lớp proxy.

Java hiện thực hóa proxy động chủ yếu có hai cách: một là sử dụng JDK, hai là sử dụng CGLib.

  • Trong đó, cách JDK hướng đến giao diện, các lớp chính liên quan là InvocationHandler và Proxy;
  • CGLib có thể proxy các lớp thông thường, các lớp chính liên quan là MethodInterceptor và Enhancer.

Điểm kiến thức này có tần suất xuất hiện trong phỏng vấn rất cao, trong kho có mã hoàn chỉnh cho hai cách hiện thực hóa này, ở đây tôi không đăng lại.

Dưới đây là kết quả JMH kiểm tra tốc độ proxy của 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 

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

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, sự khác biệt về tốc độ tạo và thực thi giữa proxy động JDK và CGLib trong các phiên bản Java mới không lớn như vậy, Spring chọn CGLib chủ yếu vì nó có thể proxy các lớp thông thường.

Mẫu Singleton

Spring khi tạo thành phần có thể sử dụng chú thích scope để chỉ định phạm vi của nó, dùng để đánh dấu đây là prototype (nhiều thể hiện) hay singleton (một thể hiện).

Khi được chỉ định là singleton (hành vi mặc định), trong container Spring, thành phần có và chỉ có một bản, khi bạn tiêm các thành phần liên quan, thể hiện thành phần bạn nhận được cũng là cùng một bản.

Đối với một lớp singleton thông thường, chúng ta thường đặt phương thức khởi tạo của singleton thành riêng tư, singleton có hai chế độ tải: tải lười (lazy loading) và tải đói (eager loading).

Những bạn hiểu về cơ chế tải lớp của JVM đều biết, một lớp từ tải đến khởi tạo trải qua 5 bước: tải (loading), xác thực (verification), chuẩn bị (preparation), phân tích (resolution) và khởi tạo (initialization).

Trong đó, trường static và khối static thuộc về lớp, đã được thực thi trong giai đoạn khởi tạo của lớp tải. Nó tương ứng với phương thức trong bytecode, thuộc về lớp (phương thức khởi tạo). Vì việc khởi tạo lớp chỉ xảy ra một lần, nên nó đảm bảo rằng hành động tải này là an toàn về mặt luồng.

Dựa trên nguyên tắc trên, chỉ cần đặt hành vi khởi tạo singleton vào phương thức , có thể hiện thực hóa chế độ tải đói.

private static DangKy dangKyThuocTinh = new DangKy();  

Chế độ tải đói ít được sử dụng trong mã, nó gây lãng phí tài nguyên, tạo ra nhiều đối tượng có thể không bao giờ được sử dụng. Việc khởi tạo đối tượng thì khác. Thông thường, khi chúng ta new một đối tượng mới, chúng ta sẽ gọi phương thức khởi tạo của nó, tức là, dùng để khởi tạo thuộc tính của đối tượng. Vì cùng một lúc, nhiều luồng có thể đồng thời gọi hàm, chúng ta cần sử dụng từ khóa synchronized để đồng bộ hóa quá trình tạo.

Hiện nay, cách hiện thực hóa singleton được công nhận là cân bằng giữa an toàn luồng và hiệu quả là double check. Nhiều nhà phỏng vấn sẽ yêu cầu bạn viết tay và phân tích nguyên lý của double check.

Như hình trên, là mã quan trọng của double check, chúng ta hãy giới thiệu bốn điểm quan trọng:

  • Kiểm tra lần đầu, khi instance là null, vào logic khởi tạo đối tượng, nếu không thì trả về trực tiếp.
  • Đồng khóa, ở đây là khóa lớp.
  • Kiểm tra lần thứ hai mới là quan trọng. Nếu không thêm hành động kiểm tra trống này, có thể có nhiều luồng vào khối mã đồng thời, từ đó tạo ra nhiều thể hiện.
  • Điểm cuối cùng quan trọng là từ khóa volatile. Trong một số phiên bản Java thấp hơn, do lý do sắp xếp lại chỉ dẫn, có thể dẫn đến singleton được new ra nhưng chưa kịp thực hiện hàm khởi tạo đã bị các luồng khác sử dụng. Từ khóa này có thể ngăn chặn việc sắp xếp lại chỉ dẫn bytecode, khi viết mã double check, thói quen sẽ thêm volatile.

Có thể thấy, cách viết double check phức tạp, nhiều điểm cần chú ý, thực tế nó đã trở thành một mẫu ngược, không còn được khuyến khích sử dụng, tôi cũng không khuyến khích bạn dùng trong mã của mình. Nhưng nó có thể đánh giá khả năng hiểu biết về concurrency của người phỏng vấn, nên vấn đề này thường được hỏi.

Khuyến nghị sử dụng enum để hiện thực hóa singleton tải lười, đoạn mã như sau:

Sách "Effective Java" cũng khuyến nghị cách thức này.

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(); 
        } 
    } 
} 

Mẫu Flyweight

Mẫu Flyweight là một mẫu thiết kế hiếm hoi, chuyên tối ưu hóa hiệu năng, nó sử dụng công nghệ chia sẻ để tái sử dụng đối tượng tối đa. Mẫu Flyweight thường sử dụng mã định danh duy nhất để kiểm tra, sau đó trả về đối tượng tương ứng, sử dụng HashMap hoặc các tập hợp tương tự để lưu trữ rất phù hợp.

Mô tả trên chúng ta rất quen thuộc, vì trong một số bài học trước, chúng ta đã thấy nhiều hình ảnh của mẫu Flyweight, chẳng hạn như đối tượng trong bể (pooling object) trong bài "Ứng dụng của đối tượng trong bể" và việc tái sử dụng đối tượng trong bài "Mục tiêu và điểm cần lưu ý khi tái sử dụng đối tượng lớn".

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

Điều đáng nói là, cùng một đoạn mã, cách giải thích khác nhau sẽ tạo ra hiệu quả khác nhau. Ví dụ như đoạn mã sau:

Map<String,ChiếnLược> chiếnLượcMap = new HashMap<>(); 
chiếnLượcMap.put("a",new ChiếnLượcA()); 
chiếnLượcMap.put("b",new ChiếnLượcB()); 

Nếu chúng ta nhìn từ góc độ tái sử dụng đối tượng, nó chính là mẫu Flyweight; nếu chúng ta nhìn từ góc độ chức năng của đối tượng, đó chính là mẫu Strategy. Vì vậy mọi người khi thảo luận về các mẫu thiết kế, nhất định phải chú ý đến sự khác biệt của ngữ cảnh ngữ cảnh này.

Mẫu Prototype

Mẫu Prototype tương tự như tư duy sao chép và dán, nó có thể trước tiên tạo một thể hiện, sau đó thông qua thể hiện này để tạo đối tượng mới. Trong Java, điển hình nhất là phương thức clone của lớp Object.

Nhưng trong quá trình mã hóa, phương thức này ít được sử dụng, mẫu prototype mà chúng ta đề cập ở trên không được hiện thực hóa bằng clone, mà sử dụng công nghệ phản xạ phức tạp hơn.

Một lý do quan trọng là nếu clone chỉ sao chép đối tượng ở mức hiện tại, nó chỉ hiện thực hóa việc sao chép nông. Trong thực tế, đối tượng thường rất phức tạp, muốn hiện thực hóa việc sao chép sâu, cần phải viết lượng lớn mã trong phương thức clone, xa tiện lợi hơn việc gọi phương thức new.

Để hiện thực hóa sao chép sâu, còn có các phương tiện như serialization, chẳng hạn như hiện thực hóa giao diện Serializable, hoặc chuyển đổi đối tượng thành JSON.

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

Thẻ: design-patterns Performance-Optimization proxy-pattern singleton-pattern flyweight-pattern

Đăng vào ngày 27 tháng 6 lúc 00:12