Mẫu thiết kế (Design Pattern) là sự tổng hợp của các kỹ thuật lập trình phổ biến, giúp các lập trình viên giao tiếp với nhau một cách chuyên nghiệp và hiệu quả. Ví dụ, khi chúng ta đề cập đến việc mô-đun I/O sử dụng mẫu trang trí (Decorator Pattern) trong bài viết "02 | Phân tích lý thuyết: Tối ưu hiệu suất có quy luật, 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ã nguồn của mô-đun I/O.
Thực tế, hầu hết các mẫu thiết kế không làm tăng hiệu suất của chương trình, chúng chỉ là một cách tổ chức mã nguồn. Trong bài viết này, chúng ta sẽ phân tích một số mẫu thiết kế liên quan đến hiệu suất, bao gồm mẫu proxy, mẫu singleton, mẫu flyweight, mẫu prototype, và các mẫu khác.
Làm thế nào để tìm ra nguyên nhân gây 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 (Aspect-Oriented Programming), chẳng hạn như các khía cạnh về quyền truy cập, nhật ký, v.v. Trong khi việc này giúp mã nguồn trở nên thuận tiện, nó cũng gây ra nhiều khó khăn cho những người không quen thuộc với mã nguồn dự án.
Dưới đây, tôi sẽ phân tích một cách cụ thể sử dụng arthas để tìm ra nguyên nhân gây 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ã nguồn dự án để xác định điểm gây ra hiệu suất kém.
Đầu tiên, chúng ta tạo một Bean đơn giản nhất (xem mã trong kho lưu trữ).
@Service
public class SampleService {
public void execute() {
System.out.println("-----------");
}
}
Sau đó, chúng ta sử dụng chú thích Aspect để viết mã 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 LoggingAspect {
@Pointcut("execution(* com.example.demo.service.SampleService.*(..)))")
public void servicePointcut() {
}
@Before("servicePointcut()")
public void logBefore() {
System.out.println("Processing...");
try {
Thread.sleep(TimeUnit.SECONDS.toMillis(1));
} catch (InterruptedException e) {
throw new RuntimeException();
}
}
}
Tạo một Controller, khi truy cập liên kết /performance, nó sẽ xuất tên lớp của Bean cùng với thời gian thực thi.
@RestController
public class PerformanceController {
@Autowired
private SampleService sampleService;
@GetMapping("/performance")
public String checkPerformance() {
long startTime = System.currentTimeMillis();
sampleService.execute();
long duration = System.currentTimeMillis() - startTime;
String className = sampleService.getClass().toString();
return className + " | " + duration + "ms";
}
}
Kết quả thực thi như sau, có thể thấy proxy AOP đã 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 method mất tới 1023ms.
class com.example.demo.service.SampleService$$EnhancerBySpringCGLIB$$b7c8f42a | 1023ms
Bây giờ 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 trong danh sách, ở đâ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 /performance, 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.example.demo.service.SampleService execute
Mẫu Proxy
Mẫu Proxy (Proxy) cho phép kiểm soát việc truy cập một đối tượng thông qua một lớp proxy.
Java hiện thực proxy động chủ yếu bằng 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 hỏi trong phỏng vấn rất thường xuyên, kho lưu trữ có mã nguồn hoàn chỉnh cho hai cách hiện thực này, ở đây tôi không đăng lại.
Dưới đây là kết quả kiểm tra JMH về 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
Phiên bản JDK tôi đang dùng là 1.8, có thể thấy tốc độ của CGLib không nhanh như đồn (có tin đồn nhanh hơn 10 lần), thậm chí tốc độ của nó còn giảm nhẹ. Chúng ta hãy xem lại tốc độ tạo proxy, kết quả như sau. Có thể thấy, về mặt khởi tạo lớp proxy, thông lượng của JDK 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à tốc độ thực thi giữa proxy động JDK và proxy CGLib trong các phiên bản Java mới không lớn lắm, 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 sử dụng 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 chỉ có một thể hiện duy nhất, khi bạn tiêm các thành phần liên quan, thể hiện của thành phần bạn nhận được cũng là cùng một thể hiện.
Đối với một lớp singleton thông thường, chúng ta thường đặt phương thức 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 sớm (eager loading).
Những ai 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 code thuộc về lớp, chúng đã được thực hiện ở giai đoạn khởi tạo của lớp tải. Trong bytecode, nó tương ứng với phương thức <clinit>, thuộc về lớp (phương thức khởi tạo). Vì việc khởi tạo lớp chỉ diễn ra một lần, nên nó đảm bảo rằng thao tác 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 thao tác khởi tạo singleton vào trong phương thức <clinit>, có thể hiện thực chế độ tải sớm (eager loading).
private static Singleton instance = new Singleton();
Chế độ tải sớm được sử dụng rất ít 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 tạo của nó, tức là <init>, dùng để khởi tạo thuộc tính của đối tượng. Vì cùng một thời điểm, 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, mẫu singleton được công nhận là cân bằng giữa an toàn luồng và hiệu suất là double check. Nhiều người 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ã khóa 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 tiên, 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.
- Thêm khóa đồng bộ, đây là khóa lớp.
- Kiểm tra lần thứ hai mới là điểm mấu chốt. Nếu không thêm thao tác kiểm tra null này, có thể có nhiều luồng vào khối mã đồng bộ, dẫn đến tạo ra nhiều thể hiện.
- Điểm quan trọng cuối cù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 (instruction reordering), có thể dẫn đến singleton được new ra nhưng chưa kịp thực hiện hàm 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 là sẽ 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 ý, hiện nó thực chất là một mẫu ngược (anti-pattern), không còn được khuyến khích sử dụng, tôi cũng không khuyến khích bạn sử dụng trong mã của mình. Nhưng nó có thể đánh giá sự hiểu biết của ứng viên về đồng thời, nên câu hỏi này thường được hỏi.
Đề xuất sử dụng enum để hiện thực singleton tải lười, đoạn mã như sau:
Cuốn sách "Effective Java" cũng đề xuất cách này.
public class SingletonWithEnum {
private SingletonWithEnum() {
}
public static SingletonWithEnum getInstance() {
return Container.SINGLETON.instance;
}
private enum Container {
SINGLETON;
private final SingletonWithEnum instance;
Container() {
instance = new SingletonWithEnum();
}
}
}
Mẫu Flyweight
Mẫu Flyweight là một trong số ít mẫu thiết kế chuyên tối ưu hóa hiệu suất, 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 và trả về đối tượng tương ứng, việc sử dụng các bộ sưu tập như HashMap rất phù hợp.
Mô tả trên rất quen thuộc, vì trong một số bài viết trước, chúng ta đã thấy bóng dáng của mẫu Flyweight, chẳng hạn như "09 | Phân tích trường hợp: Ứng dụng场景 của đối tượng pool" trong đối tượng pool và "10 | Phân tích trường hợp: Mục tiêu và điểm cần lưu ý khi tái sử dụng đối tượng lớn" trong việc tái sử dụng đối tượng.
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 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ác cách giải thích khác nhau sẽ tạo ra hiệu quả khác nhau. Ví dụ, đoạn mã sau:
Map<String,Processor> processors = new HashMap<>();
processors.put("x",new TypeXProcessor());
processors.put("y",new TypeYProcessor());
Nếu chúng ta nhìn từ góc độ tái sử dụng đối tượng, đó là mẫu flyweight; nếu chúng ta nhìn từ góc độ chức năng của đối tượng, đó là mẫu chiến lược (strategy). Vì vậy, khi thảo luận về mẫu thiết kế, mọi người phải chú ý đến sự khác biệt ngữ cảnh này.
Mẫu Prototype
Mẫu Prototype khá giống với tư duy sao chép và dán, nó có thể tạo một thể hiện trước, sau đó tạo đối tượng mới thông qua thể hiện này. Trong Java, ví dụ điển hình nhất là phương thức clone của lớp Object.
Nhưng trong 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 phải được hiện thực thông qua 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 ở cấp độ hiện tại, nó chỉ hiện thực được sao chép nông (shallow copy). Trong thực tế, đối tượng thường rất phức tạp, muốn hiện thực sao chép sâu (deep copy) thì cần viết rất nhiều mã trong phương thức clone, không tiện bằng việc gọi phương thức new.
Để hiện thực sao chép sâu, còn có các phương pháp như tuần tự hóa, ví dụ hiện thực giao diện Serializable, hoặc chuyển đố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.