Mẫu thiết kế là sự tổng kết các kỹ thuật phát triển phổ biến, giúp lập trình viên giao tiếp vấn đề với nhau một cách chuyên nghiệp và thuận tiện hơn. Ví dụ, trong bài viết "02 | Phân tích lý thuyết: Tối ưu hóa hiệu suất có thể dự đoán, thảo luận về các điểm切入 phổ biến", chúng ta đã đề cập rằng mô-đun I/O sử dụng mẫu thiết kế Decorator, 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 thể 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 học này, chúng ta sẽ giải thích từng mẫu thiết kế liên quan đến hiệu suất, bao gồm Proxy, Singleton, Flyweight, Prototype, v.v.
Cách tìm nguyên nhân của logic chậm trong proxy động?
Spring sử dụng rộng rãi mẫu thiết kế Proxy, nó sử dụng CGLIB để tăng cường bytecode của Java. Trong các dự án phức tạp, sẽ có rất nhiều mã AOP, chẳng hạn như các khía cạnh quyền truy cập, nhật ký, v.v. Mặc dù việc này giúp mã hóa dễ dàng hơn, nhưng AOP 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 cách sử dụng arthas để tìm nguyên nhân cụ thể của logic 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, bạn không cần phải quen thuộc với mã nguồn dự án để xác định điểm nghẽn hiệu suất.
Đầ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 DataBean {
public void process() {
System.out.println("*******************");
}
}
Sau đó, chúng ta sử dụng chú thích Aspect để viết khía cạnh, trong phương thức tiền xử lý, chúng ta để luồng ngủ 1 giây.
@Aspect
@Service
public class PerformanceAspect {
@Pointcut("execution(* com.example.spring.DataBean.*(..))")
public void pointcut() {
}
@Before("pointcut()")
public void before() {
System.out.println("Trước khi thực thi");
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 /performance, nó sẽ xuất tên lớp Bean và thời gian thực thi của nó.
@RestController
public class PerformanceController {
@Autowired
private DataBean dataBean;
@GetMapping("/performance")
public String performance() {
long startTime = System.currentTimeMillis();
dataBean.process();
long duration = System.currentTimeMillis() - startTime;
String className = dataBean.getClass().toString();
return className + " | " + duration;
}
}
Kết quả thực thi như sau, có thể thấy AOP proxy đã được kích hoạt, đối tượng Bean trong bộ nhớ đã trở thành loại EnhancerBySpringCGLIB, việc gọi phương thức process mất đến 1023ms.
class com.example.spring.DataBean$$EnhancerBySpringCGLIB$$a5d91535 | 1023
Bây giờ, sử dụng arthas để phân tích quá trình thực thi này và tìm 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 chúng ta trong 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 /performance, terminal sẽ in ra một số thông tin gỡ lỗi, có thể thấy hoạt động tốn thời gian nhất là lớp proxy của Spring.
trace com.example.spring.DataBean process
Mẫu thiết kế Proxy
Mẫu thiết kế Proxy (Proxy) có thể kiểm soát việc truy cập một đối tượng thông qua một lớp proxy.
Trong Java, có hai cách chính để triển khai proxy động: một là sử dụng JDK, cách còn lại là sử dụng CGLib.
- Trong đó, cách JDK là hướng giao diện, các lớp liên quan chính là InvocationHandler và Proxy;
- CGLib có thể proxy các lớp thông thường, các lớp liên quan chính là MethodInterceptor và Enhancer.
Điểm kiến thức này rất thường gặp trong các cuộc phỏng vấn, kho lưu trữ có mã nguồn hoàn chỉnh của hai triển khai này, ở đây tôi sẽ không dán ra.
Dưới đây là kết quả kiểm tra tốc độ proxy của 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 sử dụng là 1.8, có thể thấy, tốc độ của CGLib không nhanh như lời đồn (có tin đồn nhanh hơn 10 lần), so sánh với JDK, tốc độ của nó thậm chí còn giảm nhẹ. Bây giờ 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, tốc độ xử lý 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, về tốc độ tạo và tốc độ thực thi, sự khác biệt giữa proxy động JDK và CGLib trong các phiên bản Java mới không thực sự lớn, Spring chọn CGLib chủ yếu vì nó có thể proxy các lớp thông thường.
Mẫu thiết kế Singleton
Khi Spring tạo các thành phần, bạn có thể chỉ định phạm vi của chúng bằng chú thích scope, để đánh dấu đây là prototype (nhiều thể hiện) hay singleton (một thể hiện).
Khi chỉ định là singleton (hành vi mặc định), trong container Spring, thành phần chỉ có một bản sao, khi bạn tiêm các thành phần liên quan, bạn nhận được cùng một thể hiện.
Đối với lớp singleton thông thường, chúng ta thường đặt phương thức khởi tạo của singleton là private, singleton có hai mô hình tải lười và tải tham lam.
Những người hiểu về cơ chế tải lớp JVM đều biết rằng, một lớp từ tải đến khởi tạo cần trải qua 5 bước: tải, xác thực, chuẩn bị, phân tích, khởi tạo.
Trong đó, các trường static và khối mã static thuộc về lớp, đã được thực thi trong giai đoạn khởi tạo của việc tải lớp. 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ó có thể đảm bảo rằng hành động tải này là an toàn luồng.
Dựa trên nguyên lý trên, chỉ cần đặt hành động khởi tạo singleton vào phương thức, chúng ta có thể thực hiện mô hình tải tham lam.
private static Singleton instance = new Singleton();
Mô hình tải tham lam ít được sử dụng trong mã, nó sẽ 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 khác nhau. 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à, để khởi tạo các thuộc tính của đối tượng. Vì nhiều luồng có thể gọi hàm cùng một lúc, chúng ta cần sử dụng từ khóa synchronized để đồng bộ hóa quá trình tạo.
Hiện tại, mẫu singleton được công nhận là 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ã nguồn chính của double check, chúng ta sẽ giới thiệu bốn điểm chính:
- 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.
- Thêm khóa đồng bộ, ở đây là khóa lớp.
- Kiểm tra lần thứ hai mới là quan trọng. Nếu không có hành động kiểm tra null này, có thể có nhiều luồng vào khối mã đồng bộ, từ đó 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, do lý do sắp xếp lại chỉ thị, có thể dẫn đến singleton được new ra, nhưng chưa kịp thực thi 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ỉ thị bytecode, khi viết mã double check, thói quen sẽ thêm volatile.
Có thể thấy, cách viết double check rất phức tạp, có nhiều điểm cần chú ý, thực tế nó là một anti-pattern, không được khuyến nghị sử dụng. Tôi cũng không khuyên bạn nên sử dụng trong mã của mình. Nhưng nó có thể kiểm tra hiểu biết về đồng bộ hóa của người phỏng vấn, vì vậy câu hỏi này thường được hỏi.
Đề xuất sử dụng enum để triển khai singleton tải lười, đoạn mã như sau:
Cuốn sách "Effective Java" cũng đề xuất phương pháp 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 thiết kế Flyweight
Mẫu thiết kế Flyweight là một trong số ít các mẫu thiết kế chuyên dành cho tối ưu hóa hiệu suất, nó tối đa hóa việc tái sử dụng đối tượng thông qua công nghệ chia sẻ. Mẫu thiết kế 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 để 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 thiết kế Flyweight, chẳng hạn như trong bài học "09 | Phân tích trường hợp: Ứng dụng场景 của đối tượng pool" và "10 | Phân tích trường hợp: Mục tiêu và điểm cần chú ý của việc tái sử dụng đối tượng lớn".
Mẫu thiết kế trừu tượng hóa việc mã hóa hàng ngày của chúng ta, từ các góc độ khác nhau để giải thích mẫu thiết kế, sẽ tìm thấy một số điểm chung trong tư tưởng thiết kế. Ví dụ, mẫu thiết kế singleton là một trường hợp đặc biệt của mẫu thiết kế Flyweight, nó đạt được việc tái sử dụng đối tượng thông qua việc chia sẻ một thể hiện duy nhất.
Đáng chú ý là, cùng một đoạn mã, các giải thích khác nhau sẽ tạo ra các hiệu ứng khác nhau. Ví dụ, đoạn mã sau:
Map<String,Strategy> strategies = new HashMap<>();
strategies.put("a",new AStrategy());
strategies.put("b",new BStrategy());
Nếu chúng ta nhìn từ góc độ tái sử dụng đối tượng, nó là mẫu thiết kế Flyweight; nếu chúng ta nhìn từ góc độ chức năng của đối tượng, thì nó là mẫu thiết kế Strategy. Vì vậy, khi mọi người thảo luận về mẫu thiết kế, hãy chắc chắn chú ý đến sự khác biệt của ngữ cảnh.
Mẫu thiết kế Prototype
Mẫu thiết kế Prototype tương tự như tư tưởng 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 phương thức này ít được sử dụng trong mã hóa, mẫu thiết kế prototype mà chúng ta đã đề cập ở trên không được triển khai thông qua clone, mà sử dụng công nghệ phản chiếu 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ỉ thực hiện shallow copy. Trong thực tế, đối tượng thường rất phức tạp, để thực hiện deep copy, cần phải viết rất nhiều mã trong phương thức clone, không tiện hơn việc gọi new.
Để thực hiện deep copy, còn có các phương pháp như tuần tự hóa, ví dụ, triển khai giao diện Serializable, hoặc chuyển đổi đối tượng thành JSON.
Vì vậy, trong thực tế, mẫu thiết kế Prototype trở thành một tư tưởng, thay vì một công cụ để tăng tốc độ tạo đối tượng.