Flyweight pattern chia sẻ phần dữ liệu bất biến giữa hàng triệu đối tượng để tiết kiệm bộ nhớ, còn Bridge tách hai chiều biến thiên độc lập để số lớp không bùng nổ theo tích. Hai pattern này giải quyết hai chuyện khác nhau trong cùng một hệ thống bán hàng: gửi thông báo qua nhiều kênh, và nạp một triệu dòng đơn hàng cho báo cáo cuối tháng mà heap phình lên hàng trăm MB vì mỗi dòng mang theo một bản sao nhóm thuế. Mọi con số về bộ nhớ trong bài lấy từ lần chạy thật trên máy 16 GB RAM, mã biên dịch với --release 17.
Bài trước trong series là Composite pattern: xử lý cây đối tượng đồng nhất. Lá của cây Composite có thể được chia sẻ bằng Flyweight pattern, còn nếu bạn từng đọc Adapter pattern: nối API cũ với giao diện mới, Bridge sẽ trông quen nhưng mục đích khác hẳn, ta sẽ so sánh ở phần dưới.
Bridge pattern: bùng nổ lớp theo hai chiều
Giả sử có hai loại thông báo (đơn đã giao, nhắc thanh toán) và ba kênh gửi (email, SMS, chat). Nếu dùng kế thừa thuần, bạn cần một lớp cho mỗi tổ hợp: OrderShippedEmail, OrderShippedSms, PaymentReminderChat... tổng 2 × 3 = 6 lớp. Từ 2 × 3, thêm kênh push thì thêm 2 lớp (thành 2 × 4); sau đó thêm loại thông báo thứ ba thì thêm 4 lớp. Số lớp tăng theo tích, trong khi hai chiều thực ra độc lập: nội dung không quan tâm kênh, và kênh không quan tâm nội dung.
Theo mô tả trên refactoring.guru, Bridge tách một lớp lớn (hoặc nhóm lớp gần nhau) thành hai phân cấp riêng, abstraction và implementation, phát triển độc lập. Cần lưu ý đây không phải "interface" và "lớp cài đặt" theo nghĩa ngôn ngữ: abstraction là tầng điều khiển cấp cao, ít làm việc thật; implementation là tầng cấp thấp phụ thuộc nền tảng. Cấu trúc gồm năm vai trò:
| Vai trò | Nhiệm vụ | Trong ví dụ thông báo |
|---|---|---|
| Abstraction | Giữ tham chiếu tới implementor, chứa logic điều khiển | Notification |
| Refined Abstraction | Biến thể của logic điều khiển | OrderShipped, PaymentReminder |
| Implementor | Interface chung của tầng cấp thấp | Channel |
| Concrete Implementor | Mã phụ thuộc nền tảng | EmailChannel, SmsChannel, ChatChannel |
| Client | Ghép abstraction với implementor rồi dùng abstraction | main |
Cài đặt Bridge pattern bằng Java
Hai phân cấp nối với nhau bằng một tham chiếu kiểu composition: Notification giữ một Channel. Đó là "cây cầu". Mã dưới đây biên dịch với --release 17:
/** Implementor: chỉ biết cách "đưa một đoạn chữ tới người nhận" qua một kênh cụ thể. */
interface Channel {
String name();
void deliver(String to, String text);
}
static final class EmailChannel implements Channel {
public String name() { return "email"; }
public void deliver(String to, String text) { System.out.println("[EMAIL -> " + to + "] " + text); }
}
static final class SmsChannel implements Channel {
public String name() { return "sms"; }
public void deliver(String to, String text) {
String cut = text.length() > 40 ? text.substring(0, 37) + "..." : text; // SMS có giới hạn độ dài
System.out.println("[SMS -> " + to + "] " + cut);
}
}
static final class ChatChannel implements Channel {
public String name() { return "chat"; }
public void deliver(String to, String text) { System.out.println("[CHAT -> " + to + "] " + text); }
}
/** Abstraction: giữ tham chiếu tới Channel và quyết định nội dung. */
static abstract class Notification {
protected final Channel channel;
protected Notification(Channel channel) { this.channel = channel; }
abstract void send(String to);
}
static final class OrderShipped extends Notification {
private final String orderId;
OrderShipped(Channel c, String orderId) { super(c); this.orderId = orderId; }
void send(String to) { channel.deliver(to, "Đơn hàng " + orderId + " đã được giao cho đơn vị vận chuyển."); }
}
static final class PaymentReminder extends Notification {
private final long amount;
PaymentReminder(Channel c, long amount) { super(c); this.amount = amount; }
void send(String to) { channel.deliver(to, "Bạn còn khoản thanh toán " + amount + " đ chưa hoàn tất."); }
}Hàm main ghép mọi loại thông báo với mọi kênh chỉ bằng vòng lặp, không có lớp tổ hợp nào:
public static void main(String[] args) {
List<Channel> channels = List.of(new EmailChannel(), new SmsChannel(), new ChatChannel());
for (Channel c : channels) {
new OrderShipped(c, "DH-1024").send("khach01");
new PaymentReminder(c, 1_250_000).send("khach01");
}
// Số lớp: kế thừa thuần cần 2 x 3 = 6 lớp con; Bridge cần 2 + 3 = 5 lớp; thêm 1 kênh mới: +2 so với +1.
System.out.println("Kế thừa thuần: " + (2 * 3) + " lớp; Bridge: " + (2 + 3) + " lớp");
int loai = 5, kenh = 4;
System.out.println("Với " + loai + " loại thông báo x " + kenh + " kênh: " + (loai * kenh) + " lớp so với " + (loai + kenh));
}Kết quả chạy thật (java -cp out BridgeDemo):
[EMAIL -> khach01] Đơn hàng DH-1024 đã được giao cho đơn vị vận chuyển.
[EMAIL -> khach01] Bạn còn khoản thanh toán 1250000 đ chưa hoàn tất.
[SMS -> khach01] Đơn hàng DH-1024 đã được giao cho đơn...
[SMS -> khach01] Bạn còn khoản thanh toán 1250000 đ ch...
[CHAT -> khach01] Đơn hàng DH-1024 đã được giao cho đơn vị vận chuyển.
[CHAT -> khach01] Bạn còn khoản thanh toán 1250000 đ chưa hoàn tất.
Kế thừa thuần: 6 lớp; Bridge: 5 lớp
Với 5 loại thông báo x 4 kênh: 20 lớp so với 9Kênh SMS cắt bớt nội dung vì giới hạn độ dài; chi tiết này nằm gọn trong SmsChannel, không rò sang OrderShipped. Hai dòng cuối đếm số lớp cụ thể (không tính Notification và Channel, tức abstraction và interface gốc, vì chúng có mặt ở cả hai cách): với 5 loại thông báo và 4 kênh, kế thừa thuần cần 20 lớp, Bridge chỉ cần 9. Thêm một kênh, Bridge chỉ thêm 1 lớp.
Bridge trong thực tế và so với Adapter, Strategy
Ví dụ hay được nhắc nhất trong hệ sinh thái Java là JDBC: ứng dụng viết code với Connection, Statement, ResultSet do JDK định nghĩa, còn mỗi nhà cung cấp cơ sở dữ liệu cài đặt phía implementor trong driver. Đây là cách diễn giải của nhiều tài liệu, không phải tuyên bố chính thức của JDK. Tài liệu giao diện nằm ở gói java.sql (Oracle), còn định nghĩa gốc của hai mẫu này có trong sách Design Patterns của GoF (1994).
Ba pattern dễ lẫn vì đều dựa vào composition và uỷ quyền:
- Bridge và Adapter: Bridge thường được thiết kế từ đầu để hai phần phát triển độc lập; Adapter được áp vào hệ thống đã có để các lớp không tương thích làm việc được với nhau. Bài về Adapter ở link phía trên cho thấy hướng "chữa cháy" đó.
- Bridge và Strategy: cấu trúc giống nhau nhưng bài toán khác. Bridge tách hai chiều của thiết kế; Strategy hoán đổi các thuật toán thay thế được cho nhau. Ở bài kế tiếp trong series, ta sẽ viết Strategy bằng lambda.
- Bridge và kế thừa: Bridge thay kế thừa bằng composition ở một chiều, vì thế có thể đổi implementor lúc chạy (nếu bỏ
finalvà thêm setter).
Flyweight pattern: chia sẻ trạng thái để tiết kiệm bộ nhớ
Flyweight pattern là pattern cấu trúc nhằm giảm bộ nhớ bằng cách để nhiều đối tượng dùng chung phần trạng thái giống nhau. Cốt lõi của Flyweight pattern là tách trạng thái của đối tượng thành hai nhóm, như refactoring.guru mô tả:
- Trạng thái nội tại (intrinsic): dữ liệu không đổi, lặp lại ở rất nhiều đối tượng, ví dụ nhóm thuế gồm mã, mô tả, thuế suất, biểu tượng. Nó nằm trong đối tượng dùng chung, bất biến, chỉ gán qua constructor.
- Trạng thái ngoại lai (extrinsic): dữ liệu riêng của từng ngữ cảnh, ví dụ số lượng và đơn giá của từng dòng hàng. Nó nằm ngoài flyweight, do client hoặc đối tượng ngữ cảnh giữ, và được truyền vào phương thức khi cần.
Cấu trúc của Flyweight pattern gồm bốn phần: Flyweight giữ trạng thái nội tại; Context giữ trạng thái ngoại lai cùng tham chiếu tới flyweight; Flyweight Factory quản lý kho (pool), trả về flyweight có sẵn nếu trạng thái nội tại khớp, ngược lại tạo mới; Client chỉ lấy flyweight qua factory. Điều kiện sống còn của Flyweight pattern: flyweight phải bất biến, vì một thay đổi sẽ lan sang mọi nơi đang dùng chung.
Đo thật hiệu quả của Flyweight pattern với một triệu dòng đơn hàng
Thay vì tin con số lý thuyết, ta đo hiệu quả của Flyweight pattern. Kịch bản: một triệu dòng hàng thuộc 20 nhóm thuế. Phương án thường gặp là mỗi dòng giữ một TaxCategory riêng, vì dữ liệu được nạp từ DB và dựng đối tượng cho từng dòng. Phương án Flyweight pattern chỉ dựng 20 TaxCategory và để các dòng trỏ vào. Hai lớp dòng hàng giống hệt nhau về cấu trúc, khác ở chỗ chúng được tạo ra thế nào:
/** Trạng thái nội tại (intrinsic): bất biến, dùng chung. */
static final class TaxCategory {
final String code;
final String description;
final int ratePercent;
final byte[] icon; // giả lập dữ liệu nặng đi kèm
TaxCategory(String code, String description, int ratePercent, int iconBytes) {
this.code = code; this.description = description; this.ratePercent = ratePercent;
this.icon = new byte[iconBytes];
}
}
/** Dòng hàng trùng lặp dữ liệu: mỗi dòng giữ bản sao riêng của TaxCategory. */
static final class LineNaive {
final TaxCategory cat; final int qty; final long price;
LineNaive(TaxCategory cat, int qty, long price) { this.cat = cat; this.qty = qty; this.price = price; }
}
/** Dòng hàng dùng Flyweight: chỉ giữ tham chiếu; qty và price là trạng thái ngoại lai (extrinsic). */
static final class LineShared {
final TaxCategory cat; final int qty; final long price;
LineShared(TaxCategory cat, int qty, long price) { this.cat = cat; this.qty = qty; this.price = price; }
}Phép đo gọi System.gc() vài lần rồi lấy totalMemory - freeMemory. Kho dùng chung chỉ là một mảng, bản tối giản của Flyweight Factory trong Flyweight pattern. Code trong bài là bản trích; mã đầy đủ ở code/s6-11/src/ (Mem.java, BridgeDemo.java, Factory.java). Hàm usedAfterGc():
static long usedAfterGc() {
for (int i = 0; i < 4; i++) { System.gc(); try { Thread.sleep(50); } catch (InterruptedException e) { } }
Runtime r = Runtime.getRuntime();
return r.totalMemory() - r.freeMemory();
}Phần đo chính:
static final int N = 1_000_000, TYPES = 20;
static final int ICON = Integer.getInteger("icon", 512); // kích thước dữ liệu nặng đi kèm mỗi TaxCategory (byte)
static TaxCategory make(int k) {
return new TaxCategory("VAT-" + k, "Nhóm hàng số " + k + " chịu thuế suất theo quy định hiện hành", k % 2 == 0 ? 8 : 10, ICON);
}
public static void main(String[] args) {
long base = usedAfterGc();
LineNaive[] naive = new LineNaive[N];
for (int i = 0; i < N; i++) naive[i] = new LineNaive(make(i % TYPES), i % 5 + 1, 10_000L + i % 100);
long naiveBytes = usedAfterGc() - base;
System.out.printf("Không Flyweight: %,d dòng, %d TaxCategory trong bộ nhớ -> %.1f MB%n", N, N, naiveBytes / 1048576.0);
long checksum = Arrays.stream(naive).mapToLong(l -> l.qty * l.price * l.cat.ratePercent / 100).sum();
naive = null;
long base2 = usedAfterGc();
TaxCategory[] pool = new TaxCategory[TYPES]; // Flyweight Factory tối giản
LineShared[] shared = new LineShared[N];
for (int i = 0; i < N; i++) {
int k = i % TYPES;
if (pool[k] == null) pool[k] = make(k);
shared[i] = new LineShared(pool[k], i % 5 + 1, 10_000L + i % 100);
}
long sharedBytes = usedAfterGc() - base2;
System.out.printf("Có Flyweight : %,d dòng, %d TaxCategory trong bộ nhớ -> %.1f MB%n", N, TYPES, sharedBytes / 1048576.0);
long checksum2 = Arrays.stream(shared).mapToLong(l -> l.qty * l.price * l.cat.ratePercent / 100).sum();
System.out.printf("Tiết kiệm: %.1f lần; kết quả nghiệp vụ giống nhau: %b (%d)%n",
(double) naiveBytes / sharedBytes, checksum == checksum2, checksum2);
}Kết quả thật (java -Xmx2g -cp out Mem, JDK 21, biên dịch với --release 17, máy 16 GB RAM). Qua các lần chạy, phần không dùng Flyweight quanh 759,7 đến 760,1 MB, phần dùng Flyweight là 34,5 hoặc 35,3 MB, nên tỷ lệ dao động khoảng 21,5 đến 22,0 lần do GC. Một lần chạy:
Không Flyweight: 1,000,000 dòng, 1000000 TaxCategory trong bộ nhớ -> 759.7 MB
Có Flyweight : 1,000,000 dòng, 20 TaxCategory trong bộ nhớ -> 34.5 MB
Tiết kiệm: 22.0 lần; kết quả nghiệp vụ giống nhau: true (2713150000)Với Flyweight pattern, bộ nhớ giữ lại giảm khoảng 22 lần (21,5 đến 22,0 tuỳ lần chạy), từ khoảng 760 MB xuống khoảng 35 MB, và kết quả nghiệp vụ (tổng thuế) vẫn giống hệt. Trong phép đo này, phần 35 MB còn lại là một triệu đối tượng dòng hàng cộng mảng tham chiếu, tức trạng thái ngoại lai, không thể chia sẻ. Con số phụ thuộc mạnh vào độ "nặng" của phần dùng chung: mỗi nhóm thuế ở đây mang kèm mảng 512 byte giả lập biểu tượng. Khi bỏ mảng đó (-Dicon=0), kết quả khiêm tốn hơn nhiều:
Không Flyweight: 1,000,000 dòng, 1000000 TaxCategory trong bộ nhớ -> 271.3 MB
Có Flyweight : 1,000,000 dòng, 20 TaxCategory trong bộ nhớ -> 35.2 MB
Tiết kiệm: 7.7 lần; kết quả nghiệp vụ giống nhau: true (2713150000)Flyweight pattern vẫn tiết kiệm (khoảng 7,7 đến 7,9 lần qua các lần chạy) nhưng thấp hơn, vì phần còn lại của TaxCategory gồm hai chuỗi và vài trường nhỏ. Lợi ích của Flyweight pattern tỷ lệ với kích thước phần nội tại và mức độ lặp lại của nó; với đối tượng nhẹ, chia sẻ có thể không đáng công. Hãy luôn đo trên dữ liệu thật của bạn trước khi áp dụng Flyweight pattern.
Flyweight pattern trong JDK: Factory an toàn đa luồng, Integer và String
Mảng ở trên chỉ đủ cho thử nghiệm đơn luồng của Flyweight pattern. Trong ứng dụng thật, factory cần an toàn đa luồng. ConcurrentHashMap.computeIfAbsent đảm bảo mỗi khoá chỉ tạo một flyweight, và record cho ta kiểu bất biến gần như miễn phí:
record Currency(String code, int decimals) { } // record bất biến: ứng viên Flyweight tốt
static final class CurrencyFactory {
private final Map<String, Currency> pool = new ConcurrentHashMap<>();
Currency get(String code, int decimals) { return pool.computeIfAbsent(code, c -> new Currency(c, decimals)); }
int size() { return pool.size(); }
}JDK dùng ý tưởng Flyweight pattern ở nhiều nơi. Integer.valueOf(int) luôn cache các giá trị từ -128 đến 127 theo tài liệu Integer của Java SE 17, còn String.intern() trả về bản đại diện chuẩn từ một pool riêng. Đoạn chạy dưới đây cho thấy hệ quả của Flyweight pattern, kể cả cái bẫy khi so sánh bằng ==:
Phần main của Factory.java (so sánh Integer, String.intern() và gọi từ tám luồng) không dán lại vì dài, xem trong file đó. Output thật:
VND dùng chung: true, số flyweight: 2
Integer 100 == 100: true
Integer 1000 == 1000: false (so sánh tham chiếu, không nên dùng ==)
Integer 1000 equals 1000: true
new String: false, sau intern(): true
Currency[code=USD, decimals=2]
8 luồng cùng lấy EUR, cùng một đối tượng: true, tổng flyweight: 3Chú ý dòng Integer 1000 == 1000: false. Tài liệu chỉ cam kết cache trong khoảng -128 đến 127 và cho phép cache thêm ngoài khoảng đó, nên đừng bao giờ dựa vào == để so sánh giá trị số nguyên bọc: dùng equals. Dòng cuối của ví dụ cho thấy tám luồng cùng xin tiền tệ EUR đều nhận về một đối tượng.
Khi nào không nên dùng Flyweight pattern và Bridge
Flyweight pattern là tối ưu hoá, nên theo lời khuyên của refactoring.guru, chỉ dùng khi chương trình thật sự có lượng lớn đối tượng giống nhau làm căng RAM và bạn đã xác nhận đó là vấn đề. Đánh đổi gồm:
- CPU đổi lấy RAM: nếu trạng thái ngoại lai phải tính lại mỗi lần gọi, bạn trả bằng thời gian.
- Độ phức tạp: Flyweight pattern buộc tách trạng thái nội tại/ngoại lai làm code khó đọc hơn với người mới.
- Bất biến bắt buộc: đối tượng cần sửa thường xuyên không hợp, vì mỗi lần sửa phải đổi sang flyweight khác, nên Flyweight pattern không hợp với dữ liệu hay đổi.
- Kho không bao giờ dọn: pool giữ tham chiếu mạnh, nếu khoá sinh ra vô hạn (ví dụ chuỗi theo người dùng) thì kho chính là rò bộ nhớ. Với Flyweight pattern, hãy dùng giới hạn kích thước hoặc tham chiếu yếu.
Với Bridge, nhược điểm chính là sự phức tạp thừa nếu áp vào lớp vốn đã gắn kết chặt: một chiều biến thiên duy nhất thì kế thừa hoặc Strategy đủ dùng. Dấu hiệu nên dùng Bridge (thay vì Flyweight pattern hay kế thừa) là khi bạn bắt đầu đặt tên lớp kiểu A + B ghép lại.
Lỗi thường gặp
- Flyweight pattern với trạng thái thay đổi: một setter trên
TaxCategorysẽ đổi thuế suất của cả triệu dòng. Cho tất cả trườngfinalvà dùngrecordkhi có thể. - Tạo flyweight ngoài factory: trong Flyweight pattern, chỉ cần một chỗ gọi
newtrực tiếp là kho mất tác dụng. Đặt constructor ở phạm vi package hoặc private. - Factory không an toàn đa luồng: trong Flyweight pattern, kiểm tra-rồi-tạo bằng
HashMapthường có thể tạo hai thể hiện cho cùng một khoá. DùngcomputeIfAbsentcủaConcurrentHashMap. - Đo sai bộ nhớ: không gọi GC trước khi đọc, hoặc đọc kích thước heap bị JVM thay đổi. Kết quả của
System.gc()chỉ là gợi ý, nên hãy chạy nhiều lần và dùng JFR hoặc heap dump khi cần chính xác. - Bridge nhưng implementor biết abstraction: nếu
SmsChannelkiểm trainstanceof OrderShippedthì cây cầu đã gãy, hai chiều không còn độc lập.
Checklist áp dụng Flyweight pattern và Bridge
- Tên lớp có dạng ghép hai khái niệm độc lập không? Nếu có, tách thành hai phân cấp và nối bằng Bridge.
- Implementor chỉ nhận dữ liệu qua interface, không biết gì về các abstraction?
- Trước khi áp Flyweight pattern, đã đo bộ nhớ (heap dump hoặc phép đo như trong bài) và xác nhận vấn đề thật?
- Với Flyweight pattern, phần dùng chung đã bất biến, và kho flyweight có giới hạn hoặc vòng đời rõ ràng?
- Nếu cây Composite có nhiều lá giống nhau, thử Flyweight pattern trước khi tối ưu cách khác.
Mã nguồn đầy đủ của Flyweight pattern và Bridge đã được biên dịch với --release 17 và chạy lại trước khi dán kết quả. Bạn có thể đọc thêm về nền tảng trong Nguyên lý SOLID trong Java qua ví dụ thực tế, đặc biệt nguyên lý Open/Closed vốn là lý do chính của Bridge.
Bài tiếp theo
Bài kế tiếp trong series là Strategy pattern với lambda và functional interface. Ta sẽ thấy cấu trúc rất giống Bridge nhưng mục đích khác, và Java 8 trở lên cho phép thay cả lớp Strategy bằng một biểu thức lambda.



