Bài trước trong khóa này đã dựng cùng một hình dạng tới hai lần mà không gọi tên nó: một interface với nhiều implementation, chọn cái nào là do bên khởi tạo object quyết định. Hình dạng đó có tên, và nó là một trong năm pattern của bài này. Design pattern phần lớn không phải ý tưởng mới — chúng là tên gọi cho những cấu trúc bạn đã viết rồi, và đó vừa là lý do nên học chúng, vừa là lý do rất dễ lạm dụng chúng.
Phần lớn bài viết về chủ đề này là năm sơ đồ UML kèm năm class đồ chơi tên kiểu AnimalFactory. Bài này thì không. Mỗi pattern ở đây đều được chỉ ra ngay bên trong JDK, với tên class lúc runtime do một chương trình đã chạy thật in ra, và mỗi pattern đều có phần nói rõ khi nào nó trở thành thứ gây hại. Chỗ nào mô tả một phiên bản sai thì phiên bản sai đó đã được compile, chạy, và trích nguyên output hoặc exception thật.
![]()
Mọi dòng output, mọi exception và mọi thông báo javac bên dưới đều được tạo ra trên OpenJDK 21.0.6 cho arm64. Chỗ nào là nhận định thiết kế chứ không phải thứ một chương trình quyết định được, bài sẽ nói rõ.
Design pattern là gì, và cái giá của nó
Một pattern là tên gọi cho một cách sắp xếp object lặp đi lặp lại và vấn đề mà cách sắp xếp đó giải quyết. Nó không phải library, không phải annotation, cũng không phải tính năng của ngôn ngữ: javac hoàn toàn không biết class bạn vừa viết có phải singleton hay không. Nghĩa là một pattern không bao giờ "đúng" một cách tự thân — nó chỉ đúng so với một thay đổi mà bạn dự đoán mình sẽ phải làm.
Năm pattern dưới đây là những cái bạn gặp đầu tiên, và cả năm đều đã có sẵn trong JDK bạn đang compile.
| Pattern | Vấn đề mà nó đặt tên | Chỗ JDK đã làm sẵn |
|---|---|---|
| Singleton | chỉ một instance, và ai được phép tạo ra nó | Runtime.getRuntime(), Collections.emptyList() |
| Factory | bên gọi không cần biết tên implementation | Integer.valueOf, List.of, EnumSet.noneOf, Calendar.getInstance |
| Builder | quá nhiều parameter trong constructor để nhớ nổi | StringBuilder, HttpRequest.newBuilder() |
| Observer | một thay đổi, nhiều bên muốn biết | listener interface ở khắp nơi, cộng với java.util.Observable đã deprecated |
| Strategy | bản thân thuật toán là một parameter | Comparator, Runnable, ThreadFactory |
Hai phép kiểm tra identity ở dòng đầu là thật:
System.out.println("Runtime.getRuntime() == Runtime.getRuntime() ? "
+ (Runtime.getRuntime() == Runtime.getRuntime()));
System.out.println("Collections.emptyList() == Collections.emptyList() ? "
+ (Collections.emptyList() == Collections.emptyList()));
Runtime.getRuntime() == Runtime.getRuntime() ? true
Collections.emptyList() == Collections.emptyList() ? true
Pattern nào cũng có cái giá đếm được: thêm file class, thêm object, thêm một tầng gián tiếp mà người đọc phải đi qua, hoặc một reference mà garbage collector không lấy đi được nữa. Bài này nêu con số ở mọi chỗ có con số để nêu.
Singleton: phần thú vị nằm ở initialization
Singleton là một class chỉ có đúng một instance kèm một điểm truy cập toàn cục tới nó. Nói vậy nghe rất tầm thường, và phần tầm thường — constructor private cộng một static field — đúng là tầm thường thật. Câu hỏi thú vị là khi nào instance đó được tạo và chuyện gì xảy ra nếu hai thread cùng hỏi một lúc.
Eager singleton
Dạng đơn giản nhất khởi tạo field ngay khi class được initialize.
final class Config {
static { System.out.println("Config class initialised"); }
static final Config INSTANCE = new Config();
private Config() { System.out.println("Config constructed"); }
String get(String key) { return "value-of-" + key; }
}
public class Eager {
public static void main(String[] args) {
System.out.println("main started");
System.out.println(Config.INSTANCE.get("port"));
System.out.println("one object? " + (Config.INSTANCE == Config.INSTANCE));
}
}
main started
Config class initialised
Config constructed
value-of-port
one object? true
Hãy để ý thứ tự. main started in ra trước, nghĩa là Config chưa hề được initialize lúc chương trình khởi động — JVM initialize một class ở lần dùng thực sự đầu tiên, không phải lúc load. Nên singleton "eager" thật ra đã lazy ở mức độ thường có ý nghĩa, và nó thread-safe miễn phí, vì JVM bảo đảm class initializer chạy đúng một lần.
Hạn chế thật sự duy nhất: instance được tạo ngay lần đầu có thứ gì đó chạm vào Config, kể cả một lần đọc hằng số static không liên quan trên cùng class đó. Nếu việc khởi tạo tốn kém và có những nhánh code không bao giờ cần tới nó, đó là lý do bạn muốn lazy ở mức mịn hơn.
Lazy singleton, và cuộc đua nó thua
Cách hiển nhiên để có được sự lazy đó là một null check.
final class LazyConfig {
static final AtomicInteger CONSTRUCTED = new AtomicInteger();
private static LazyConfig instance;
private LazyConfig() {
CONSTRUCTED.incrementAndGet();
// A real constructor does work; this stands in for it.
long acc = 0;
for (int i = 0; i < 200_000; i++) acc += i;
if (acc == -1) throw new AssertionError();
}
static LazyConfig getInstance() {
if (instance == null) { // check
instance = new LazyConfig(); // then act
}
return instance;
}
}
Tám thread được thả ra cùng lúc từ một CyclicBarrier, mỗi thread gọi getInstance() một lần, kết quả gom vào một set theo identity:
int threads = 8;
var barrier = new CyclicBarrier(threads);
var seen = ConcurrentHashMap.<LazyConfig>newKeySet();
var pool = Executors.newFixedThreadPool(threads);
for (int i = 0; i < threads; i++) {
pool.execute(() -> {
try { barrier.await(); } catch (Exception e) { throw new RuntimeException(e); }
seen.add(LazyConfig.getInstance());
});
}
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("constructor ran = " + LazyConfig.CONSTRUCTED.get() + " time(s)");
System.out.println("distinct instances = " + seen.size());
System.out.println("singleton held? = " + (seen.size() == 1));
constructor ran = 8 time(s)
distinct instances = 8
singleton held? = false
Tám object, lặp lại như nhau trong năm lần chạy liên tiếp. Class hứa chỉ có một instance lại sinh ra mỗi thread một cái. if (instance == null) đọc field, còn instance = new LazyConfig() ghi field, và không có gì gộp hai thao tác đó thành một bước, nên mọi thread đều có thể đọc thấy null trước khi bất kỳ thread nào kịp ghi. Đây là check-then-act, dạng bug concurrency phổ biến nhất, và ở đây nó không phải một interleaving hiếm gặp — với tám thread cùng được thả ở barrier thì lần nào cũng xảy ra.
Đánh dấu getInstance() là synchronized thì sửa đúng. Phản đối thường gặp là mọi lời gọi sau đó đều phải trả giá cho một lock không cần thiết, và đó chính là động cơ của dạng tiếp theo.
Double-checked locking, và vì sao volatile không phải thứ tùy chọn
Double-checked locking đọc field khi chưa có lock, chỉ lấy lock nếu field trông như chưa được set, rồi kiểm tra lại lần nữa bên trong.
final class Dcl {
private static volatile Dcl instance; // volatile is not decoration
private Dcl() { /* ... */ }
static Dcl getInstance() {
Dcl local = instance; // one volatile read on the fast path
if (local == null) {
synchronized (Dcl.class) {
local = instance;
if (local == null) instance = local = new Dcl();
}
}
return local;
}
}
Chạy qua đúng bài kiểm tra tám thread ở trên:
constructor ran = 1 time(s)
distinct instances = 1
singleton held? = true
volatile chính là lý do phiên bản này đúng, và cần nói chính xác vì sao. new Dcl() không phải một thao tác đơn. Nó cấp phát bộ nhớ, chạy constructor để điền các field, rồi gán reference vào instance. Nếu không có volatile, không có gì trong Java memory model cấm một thread quan sát thấy lệnh gán vào instance trước khi quan sát thấy các lệnh ghi field mà constructor thực hiện. Thread thứ hai khi đó sẽ đi vào nhánh nhanh, thấy reference khác null, và dùng một object mà các field vẫn còn ở giá trị mặc định. Hiện tượng đó gọi là unsafe publication. Khai báo field là volatile biến lệnh ghi vào instance thành một release và mọi lần đọc nó thành một acquire, nhờ đó các lệnh ghi của constructor được sắp thứ tự trước bất kỳ ai đọc được reference.
Nói thẳng về phần chứng minh: tôi đã không tái hiện được unsafe publication, và tôi cho rằng bạn cũng không tái hiện được nó một cách đáng tin theo yêu cầu. Nó phụ thuộc vào memory model của bộ xử lý và vào quyết định của JIT trong lần chạy đó, nên một chương trình "chứng minh" điều này thường chỉ chứng minh rằng máy này tình cờ chặt chẽ. Lập luận cho volatile ở đây là lập luận từ memory model chứ không phải từ một phép đo — và chính vì vậy dạng tiếp theo mới là dạng nên dùng.
Initialisation-on-demand holder
Một nested class được initialize ở lần dùng đầu tiên của chính nó, không phải của class bao ngoài. Chỉ riêng sự thật đó đã cho bạn cả tính lazy lẫn thread-safe mà trong code của bạn không có lấy một cái lock.
final class Settings {
static { System.out.println(" [Settings initialised]"); }
private Settings() { System.out.println(" [Settings constructed]"); }
private static class HolderOf {
static { System.out.println(" [Settings.HolderOf initialised]"); }
static final Settings INSTANCE = new Settings();
}
static Settings getInstance() { return HolderOf.INSTANCE; }
int port() { return 8080; }
}
main started
touching the outer class: Settings
still no instance yet
first getInstance():
[Settings initialised]
[Settings.HolderOf initialised]
[Settings constructed]
second getInstance():
same instance? true, port 8080
Vệt chạy đó chứng minh hai điều. Việc đọc Settings.class.getName() không làm initializer nào in ra gì cả, vì class literal không phải một lần dùng thực sự nên không kích hoạt initialization. Và HolderOf được initialize ở lần getInstance() đầu tiên chứ không sớm hơn — việc khởi tạo diễn ra đúng một lần, đúng vào thời điểm có nhu cầu đầu tiên.
Chạy lại chính chương trình đó với -Xlog:class+init=info:stdout:none rồi lọc lấy hai dòng đáng chú ý, phía JVM xác nhận điều tương tự:
first getInstance():
396 Initializing 'Settings' (0x0000000501000bf0) by thread "main"
[Settings initialised]
397 Initializing 'Settings$HolderOf' (0x0000000501000bf0) by thread "main"
[Settings.HolderOf initialised]
[Settings constructed]
Trong file nguồn đó không có cái lock nào, mà nó vẫn đúng với số thread bất kỳ, vì JVM tự lấy class-initialization lock giùm bạn và giữ tới khi initializer chạy xong. Mọi thread khác tới trong lúc một class đang được initialize đều bị block rồi sau đó thấy kết quả đã hoàn tất.

Còn một dạng thứ năm — enum một hằng số — mà ngôn ngữ bảo đảm là duy nhất trước cả serialization lẫn reflection. Dạng đó đã được trình bày kỹ trong bài về advanced enum của series này, kể cả exception chính xác mà reflection ném ra, nên ở đây không nhắc lại.
Khi nào singleton trở thành thứ gây hại. Không phải ở phần initialization, thứ mà toàn bộ phía trên đang bàn, mà ở những gì nó làm với code sử dụng nó. Một singleton là global mutable state được đặt cho cái tên dễ nghe hơn, và tác hại nằm ở chỗ nó vô hình tại call site:
final class SequenceGenerator {
private static final SequenceGenerator INSTANCE = new SequenceGenerator();
private int next = 1;
private SequenceGenerator() {}
static SequenceGenerator getInstance() { return INSTANCE; }
synchronized int next() { return next++; }
}
final class OrderService {
// Nothing in this signature says a shared counter is involved.
String create(String item) {
return "ORD-" + SequenceGenerator.getInstance().next() + "/" + item;
}
}
Hai phép kiểm tra, chạy theo thứ tự khai báo rồi chạy ngược lại, trong cùng một JVM:
-- declared order --
testFirstOrderIsOne -> ORD-1/keyboard
testSecondOrder -> ORD-2/mouse
-- reordered --
testSecondOrder -> ORD-1/mouse
testFirstOrderIsOne -> ORD-2/keyboard
Kết quả phụ thuộc vào thứ tự thực thi, và không có kẽ hở nào để reset bộ đếm: field là private static final, constructor là private, nên không gì bên ngoài class đưa nó về được. OrderService.create nhận một argument mà lại phụ thuộc ngầm vào state của cả process. Truyền generator vào qua constructor tốn thêm đúng một field và xóa sạch vấn đề — cũng chính là nhận định "chỉ invert những dependency bạn thật sự cần thay thế" mà bài trước kết lại.
Factory: đặt tên, cache, và trả về thứ khác
Constructor có ba hạn chế mà ngôn ngữ không cho bạn đường vòng nào: nó buộc phải trùng tên class, buộc phải trả về instance đúng class đó, và buộc phải trả về object mới. Static factory method không dính hạn chế nào trong ba cái đó.
Static factory method so với constructor
Hạn chế về tên đập vào mặt ngay lập tức. Tọa độ Cartesian và tọa độ cực đều là hai giá trị double:
final class Point {
private final double x, y;
Point(double x, double y) { this.x = x; this.y = y; }
// Same signature after the parameter names are gone.
Point(double r, double theta) {
this.x = r * Math.cos(theta);
this.y = r * Math.sin(theta);
}
}
TwoCtors.java:7: error: constructor Point(double,double) is already defined in class Point
Point(double r, double theta) {
^
1 error
Tên parameter không thuộc về signature, nên hai constructor đó là cùng một constructor. Static factory method thì có tên nên không đụng nhau — và nhân tiện, hạn chế thứ ba cũng biến mất:
final class Point {
private final double x, y;
private Point(double x, double y) { this.x = x; this.y = y; }
static Point ofCartesian(double x, double y) { return new Point(x, y); }
static Point ofPolar(double r, double theta) {
return new Point(r * Math.cos(theta), r * Math.sin(theta));
}
private static final Point ORIGIN = new Point(0, 0);
/** Not required to return a new object. */
static Point origin() { return ORIGIN; }
@Override public String toString() {
return String.format("(%.2f, %.2f)", x, y);
}
}
ofCartesian(3, 4) = (3.00, 4.00)
ofPolar(5, PI/2) = (0.00, 5.00)
origin() == origin() ? true
Bây giờ call site tự nói rõ nó hiểu theo nghĩa nào, và origin() trả về cùng một object mỗi lần vì chẳng có gì bắt nó phải làm khác.
Integer.valueOf và cache
Static factory được dùng nhiều nhất trong JDK là cái mà autoboxing gọi hộ bạn. Integer.valueOf giữ một cache các giá trị boxed trong khoảng -128 tới 127 và trả về object đã cache khi argument rơi vào khoảng đó.
Integer a = Integer.valueOf(127), b = Integer.valueOf(127);
Integer c = Integer.valueOf(128), d = Integer.valueOf(128);
System.out.println("valueOf(127) == valueOf(127) ? " + (a == b));
System.out.println("valueOf(128) == valueOf(128) ? " + (c == d));
Set<Integer> ids = Collections.newSetFromMap(new IdentityHashMap<>());
for (int pass = 0; pass < 3; pass++)
for (int i = -128; i <= 127; i++) ids.add(Integer.valueOf(i));
System.out.println("768 calls over -128..127 produced " + ids.size() + " objects");
valueOf(127) == valueOf(127) ? true
valueOf(128) == valueOf(128) ? false
valueOf(-128) == valueOf(-128)? true
valueOf(-129) == valueOf(-129)? false
768 calls over -128..127 produced 256 objects
768 calls over 1000..1255 produced 768 objects
Cùng số lời gọi, cùng đoạn code, mà một bên biên sinh ra gấp ba lần số object so với bên kia. Constructor không làm được chuyện đó, và đó đúng là lý do constructor bị lấy đi: compile new Integer(127) với -Xlint:deprecation cho ra
Ctor.java:3: warning: [removal] Integer(int) in Integer has been deprecated and marked for removal
Integer boxed = new Integer(127);
^
1 warning
Mấy kết quả identity đó cũng là lý do == trên kiểu boxed là một cái bẫy. Integer không hứa gì về identity cả; cache là chi tiết cài đặt tình cờ được đặc tả cho khoảng đó, và phép so sánh đúng là equals hoặc so sánh trên int đã unbox.

Một factory, nhiều class implementation
Hạn chế thứ hai — buộc trả về đúng class đã khai báo — mới là cái làm thay đổi thiết kế. Một factory có thể trả về class khác nhau ở từng lời gọi mà bên gọi không hề biết.
System.out.println("List.of() -> " + List.of().getClass().getName());
System.out.println("List.of(1) -> " + List.of(1).getClass().getName());
System.out.println("List.of(1,2) -> " + List.of(1, 2).getClass().getName());
System.out.println("List.of(1,2,3) -> " + List.of(1, 2, 3).getClass().getName());
System.out.println("Calendar th -> " + Calendar.getInstance(
Locale.forLanguageTag("th-TH-u-ca-buddhist")).getClass().getName());
System.out.println("Calendar ja -> " + Calendar.getInstance(
Locale.forLanguageTag("ja-JP-u-ca-japanese")).getClass().getName());
System.out.println("Calendar us -> " + Calendar.getInstance(Locale.US).getClass().getName());
List.of() -> java.util.ImmutableCollections$ListN
List.of(1) -> java.util.ImmutableCollections$List12
List.of(1,2) -> java.util.ImmutableCollections$List12
List.of(1,2,3) -> java.util.ImmutableCollections$ListN
new ArrayList<>() -> java.util.ArrayList
Calendar th -> sun.util.BuddhistCalendar
Calendar ja -> java.util.JapaneseImperialCalendar
Calendar us -> java.util.GregorianCalendar
List12 chứa một hoặc hai phần tử trong hai field, không có array nào; ListN thì có array. Kiểu khai báo trong mọi trường hợp đều là List, không class nào trong số đó là public, và new ArrayList() là dòng duy nhất không có quyền chọn.
EnumSet chọn theo một trục khác — số hằng số của enum. Hai enum, một cái 64 hằng số và một cái 65, cùng một lời gọi factory:
Small has 64 constants -> java.util.RegularEnumSet
Big has 65 constants -> java.util.JumboEnumSet
same factory call, same declared type: java.util.EnumSet
Biên đúng bằng 64 vì RegularEnumSet giữ toàn bộ set trong một long, mỗi hằng số một bit. Thêm hằng số thứ 65 là không nhét vừa nữa, nên factory đưa lại implementation dựa trên array. Ở call site không có gì thay đổi.
Còn một dạng Factory Method sát nghĩa hơn: một abstract method trên class cơ sở, để subclass quyết định class cơ sở sẽ làm việc với object nào.
abstract class Report {
protected abstract Formatter createFormatter(); // the factory method
final String render(List<String> rows) {
Formatter f = createFormatter();
StringBuilder sb = new StringBuilder(f.header());
for (String r : rows) sb.append(f.row(r));
return sb.append(f.footer()).toString();
}
}
CsvReport -> value\nkeyboard\nmouse\n
JsonReport -> ["keyboard","mouse"]
formatter chosen by CsvReport = CsvReport$1
formatter chosen by JsonReport = JsonReport$1
render là final và giống hệt nhau ở cả hai; chỉ object được đưa vào là khác. Calendar.getInstance phía trên chính là phiên bản của JDK cho cùng ý tưởng đó, với locale đóng vai người quyết định thay cho subclass.
Khi nào factory trở thành thứ gây hại. Khi chỉ có một implementation và sẽ mãi chỉ có một. ClockFactory.create() trả về new SystemClock() thêm vào một interface, một class factory và một tầng gián tiếp, đổi lại đúng con số không: người đọc phải mở ba file để tìm ra một chữ new. Đây là nhận định thiết kế chứ không phải thứ chương trình quyết định, và tiêu chí tôi dùng là implementation thứ hai có tồn tại ngay bây giờ hay không — chứ không phải có tưởng tượng ra được hay không. Static factory method đặt ngay trên class thì khác và gần như luôn đáng làm, vì phần đặt tên và cache trả lời ngay; còn một class factory riêng cho đúng một type thì thường là không.
Builder: từ telescoping constructor tới builder được compiler kiểm tra
Telescoping constructor
Khi một class có nhiều field tùy chọn hơn mức constructor còn đọc nổi, phản xạ thường thấy là một chuỗi constructor gọi dồn về cái rộng nhất.
final class HttpClientConfig {
HttpClientConfig(String host, int port) { this(host, port, 10, 30, true, true); }
HttpClientConfig(String host, int port, int connectTimeoutSeconds, int readTimeoutSeconds) {
this(host, port, connectTimeoutSeconds, readTimeoutSeconds, true, true);
}
HttpClientConfig(String host, int port, int connectTimeoutSeconds,
int readTimeoutSeconds, boolean followRedirects, boolean useCompression) {
/* ... assign six fields ... */
}
}
Điểm chết không nằm ở chỗ nó xấu. Nó nằm ở chỗ các parameter cạnh nhau cùng kiểu thì với compiler là hoán đổi được cho nhau:
// Intended: connect 5s, read 60s, follow redirects, no compression.
var cfg = new HttpClientConfig("api.example.com", 443, 60, 5, false, true);
System.out.println(cfg);
host=api.example.com port=443 connect=60s read=5s redirects=false compression=true
Cả hai cặp đều bị đảo. Nó compile không warning, chạy không exception, và sinh ra một client có read timeout năm giây, sẽ hỏng với bất kỳ response chậm nào. Type system không giúp được gì, vì (int, int) và (boolean, boolean) chẳng nói lên cái nào là cái nào.
Builder
Builder thay các argument theo vị trí bằng các lời gọi có tên trên một object mutable, rồi tạo ra object immutable ở bước cuối.
final class ClientConfig {
private final String host; private final int port;
private final int connectTimeoutSeconds; private final int readTimeoutSeconds;
private final boolean followRedirects; private final boolean useCompression;
private ClientConfig(Builder b) {
this.host = b.host; this.port = b.port;
this.connectTimeoutSeconds = b.connectTimeoutSeconds;
this.readTimeoutSeconds = b.readTimeoutSeconds;
this.followRedirects = b.followRedirects; this.useCompression = b.useCompression;
}
static Builder builder() { return new Builder(); }
static final class Builder {
private String host = "localhost"; private int port = 80;
private int connectTimeoutSeconds = 10; private int readTimeoutSeconds = 30;
private boolean followRedirects = true; private boolean useCompression = true;
Builder host(String v) { this.host = v; return this; }
Builder port(int v) { this.port = v; return this; }
Builder connectTimeoutSeconds(int v) { this.connectTimeoutSeconds = v; return this; }
Builder readTimeoutSeconds(int v) { this.readTimeoutSeconds = v; return this; }
Builder followRedirects(boolean v) { this.followRedirects = v; return this; }
Builder useCompression(boolean v) { this.useCompression = v; return this; }
ClientConfig build() {
if (port < 1 || port > 65535)
throw new IllegalArgumentException("port out of range: " + port);
return new ClientConfig(this);
}
}
}
var cfg = ClientConfig.builder()
.host("api.example.com")
.port(443)
.connectTimeoutSeconds(5)
.readTimeoutSeconds(60)
.followRedirects(false)
.build();
System.out.println(cfg);
try {
ClientConfig.builder().host("api.example.com").port(70000).build();
} catch (IllegalArgumentException e) {
System.out.println("caught: " + e);
}
host=api.example.com port=443 connect=5s read=60s redirects=false compression=true
caught: java.lang.IllegalArgumentException: port out of range: 70000
Hai thứ đã đổi. Số năm và số sáu mươi không hoán đổi cho nhau được nữa, vì mỗi cái đều đứng cạnh tên field mà nó set. Và build() là một chỗ duy nhất để chạy validation liên field, thứ mà chuỗi constructor không cho bạn nếu không lặp lại phép kiểm tra.

Mọi method trừ build() đều trả về this, đó là thứ làm cho chuỗi gọi chạy được và làm cho cả chuỗi chỉ là một lần khởi tạo chứ không phải nhiều lần. StringBuilder cũng đúng hình dạng đó và nói thẳng ra điều ấy:
StringBuilder sb = new StringBuilder();
Object same = sb.append("GET ").append('/').append("orders").append(' ').append(200);
System.out.println("StringBuilder result = " + sb);
System.out.println("append() returns this? = " + (same == sb));
StringBuilder result = GET /orders 200
append() returns this? = true
HttpRequest.newBuilder() là phiên bản của JDK hiện đại, và nó minh họa luôn cả ý về factory ở phần trước — builder lẫn sản phẩm đều là class cài đặt bị giấu đi:
builder class = jdk.internal.net.http.HttpRequestBuilderImpl
built request = GET https://example.com/orders
built class = jdk.internal.net.http.ImmutableHttpRequest
newBuilder().build() -> java.lang.IllegalStateException: uri is null
Dòng cuối chính là điểm yếu của builder thông thường. URI là bắt buộc, và quên nó là lỗi lúc runtime chứ không phải lỗi compile. Builder của chúng ta cũng có đúng lỗ hổng đó, ở dạng còn im lặng hơn:
host=localhost port=80 connect=10s read=30s redirects=true compression=true
Đó là kết quả của ClientConfig.builder().build() — không host, không port, không một lời phàn nàn, một object đầy đủ trỏ vào hư không.
Staged builder được compiler kiểm tra
Lỗ hổng đó bịt được. Cho mỗi field bắt buộc một interface riêng, và để mỗi setter trả về interface của stage kế tiếp, sao cho build() chưa tồn tại trên kiểu bạn đang cầm cho tới khi các field bắt buộc đã được set.
final class Endpoint {
private final String host; private final int port; private final int timeoutSeconds;
private Endpoint(String host, int port, int timeoutSeconds) {
this.host = host; this.port = port; this.timeoutSeconds = timeoutSeconds;
}
/** Stage 1: the only thing you can do is name a host. */
interface NeedsHost { NeedsPort host(String host); }
/** Stage 2: the only thing you can do is name a port. */
interface NeedsPort { Ready port(int port); }
/** Stage 3: optional settings, and now build() exists. */
interface Ready { Ready timeoutSeconds(int s); Endpoint build(); }
static NeedsHost builder() { return new Stages(); }
private static final class Stages implements NeedsHost, NeedsPort, Ready {
private String host; private int port; private int timeoutSeconds = 30;
public NeedsPort host(String host) { this.host = host; return this; }
public Ready port(int port) { this.port = port; return this; }
public Ready timeoutSeconds(int s) { this.timeoutSeconds = s; return this; }
public Endpoint build() { return new Endpoint(host, port, timeoutSeconds); }
}
@Override public String toString() {
return host + ":" + port + " timeout=" + timeoutSeconds + "s";
}
}
api.example.com:443 timeout=30s
db.internal:5432 timeout=5s
Một class cài đặt cả ba interface, nên lúc runtime chỉ có đúng một object; việc chia stage chỉ tồn tại trong các kiểu mà bên gọi nhìn thấy. Và cái sai mà builder thường im lặng chấp nhận thì bây giờ không compile được:
Endpoint e = Endpoint.builder().build(); // no host, no port
Endpoint f = Endpoint.builder().host("a").build(); // no port
StagedBad.java:3: error: cannot find symbol
Endpoint e = Endpoint.builder().build(); // no host, no port
^
symbol: method build()
location: interface NeedsHost
StagedBad.java:4: error: cannot find symbol
Endpoint f = Endpoint.builder().host("a").build(); // no port
^
symbol: method build()
location: interface NeedsPort
2 errors
Cái giá là mỗi field bắt buộc một interface cộng một thứ tự cố định mà bên gọi phải theo, nên kỹ thuật này đáng làm với type có hai hoặc ba field thật sự bắt buộc, chứ không đáng với type có tám cái.
Khi nào builder trở thành thứ gây hại. Khi không có gì để làm sai. Ba field, tất cả đều bắt buộc, không có field tùy chọn nào:
record Money(String currency, long amount, int scale) {}
Money[currency=VND, amount=250000, scale=0]
Bản builder của cùng type đó cho ra đúng dòng ấy nhưng từ 21 dòng nguồn thay vì 7, và ba file class thay vì hai. Một record cho bạn sẵn constructor, equals, hashCode và toString, còn constructor ba argument thì vẫn đọc được ở call site. Quy tắc kinh nghiệm của tôi — và đây là nhận định chứ không phải phép đo — là builder bắt đầu có lãi ở khoảng bốn hoặc năm parameter, hoặc ngay khi vài parameter là tùy chọn, hoặc ngay khi hai parameter cạnh nhau cùng kiểu.
Observer: đăng ký, notification, và hai thứ hỏng
Observer cho một object thông báo rằng có chuyện vừa xảy ra mà không cần biết ai quan tâm. Subject giữ một danh sách listener; bên nào quan tâm thì tự thêm mình vào; một thay đổi sẽ duyệt qua danh sách đó.
Đăng ký và notification
interface PriceListener {
void onPrice(String symbol, long cents);
}
final class PriceFeed {
private final List<PriceListener> listeners = new ArrayList<>();
void addListener(PriceListener l) { listeners.add(l); }
void removeListener(PriceListener l) { listeners.remove(l); }
int listenerCount() { return listeners.size(); }
void publish(String symbol, long cents) {
for (PriceListener l : listeners) l.onPrice(symbol, cents);
}
}
listeners = 3
publish AAPL 19250:
chart : AAPL 19250
audit : AAPL 19250
alerts : AAPL 19250
PriceFeed không nhắc tên listener nào và không import gì liên quan tới chart hay auditing. Đó là toàn bộ lợi ích, và nó có thật. Cả hai vấn đề bên dưới đều sinh ra từ đúng mười lăm dòng đó.
Trước đó là một ghi chú lịch sử: java.util.Observer và java.util.Observable vẫn còn trên OpenJDK 21 và vẫn chạy. javap -v cho thấy annotation chúng mang:
Deprecated: true
RuntimeVisibleAnnotations:
0: #88(#89=s#90)
java.lang.Deprecated(
since="9"
)
Deprecated từ Java 9, và — điều quan trọng — không đánh dấu forRemoval, nên code dùng chúng hôm nay vẫn compile và chạy, chỉ kèm warning. Dù vậy vẫn nên tránh: Observable là một class, nên subject phải tiêu suất kế thừa duy nhất của mình vào đó, còn setChanged() là protected, nghĩa là bạn thậm chí không phát nổi một event nếu không kế thừa.
@SuppressWarnings("deprecation")
class Feed extends Observable {
void publish(String item) {
setChanged(); // protected: only a subclass can call it
notifyObservers(item);
}
}
notified with: build-42
observer count = 1
Listener không bao giờ được thu hồi
Subject giữ strong reference tới mọi listener đã đăng ký, và reference đó sống đúng bằng tuổi thọ của subject. Một screen tự đăng ký rồi bị đóng mà không hủy đăng ký thì không phải rác:
/** A screen that registers itself and is never unregistered. */
final class ChartScreen implements PriceListener2 {
private final byte[] pixels = new byte[4 * 1024 * 1024]; // 4 MB of retained state
public void onPrice(String symbol, long cents) { pixels[0] = 1; }
}
ChartScreen screen = new ChartScreen();
feed.addListener(screen);
WeakReference<ChartScreen> ref = new WeakReference<>(screen);
screen = null; // the screen is closed; nothing else refers to it
System.gc(); Thread.yield(); System.gc();
System.out.println("screen closed, feed still holds " + feed.listenerCount() + " listener(s)");
System.out.println("collected? " + (ref.get() == null));
feed.removeListener(ref.get());
System.gc(); Thread.yield(); System.gc();
System.out.println("after removeListener, feed holds " + feed.listenerCount() + " listener(s)");
System.out.println("collected? " + (ref.get() == null));
screen closed, feed still holds 1 listener(s)
collected? false
after removeListener, feed holds 0 listener(s)
collected? true
Giống hệt nhau trong năm lần chạy liên tiếp. System.gc() là một lời đề nghị chứ không phải mệnh lệnh, nên dòng collected? true thứ hai không được đặc tả bảo đảm — nhưng dòng collected? false đầu tiên thì có, và đó mới là nửa quan trọng: chừng nào feed còn giữ reference thì không collector nào được phép lấy object đi.
Bốn megabyte cho mỗi screen bị rò, và mức rò tăng theo số lần screen được mở. Phiên bản khó sửa thật sự của bug này là đăng ký một lambda viết thẳng tại chỗ, vì khi đó không còn reference nào để truyền vào removeListener sau này. Cách sửa thì bình thường: cho việc đăng ký một vòng đời khớp với vòng đời của listener, cho addListener trả về một handle tự hủy đăng ký khi đóng, hoặc giữ listener bằng weak reference và chấp nhận rằng listener không ai khác tham chiếu tới có thể ngừng được notify.
Listener ném exception
Vòng lặp không cô lập lỗi. Một listener ném exception là kết thúc lượt broadcast cho tất cả những cái đứng sau.
void publishNaive(String symbol, long cents) {
for (PriceListener3 l : listeners) l.onPrice(symbol, cents);
}
publishNaive:
chart : AAPL 19250
publish() threw: java.lang.IllegalStateException: audit sink offline
alerts was never notified.
Listener alerts không làm gì sai và không hề được gọi, còn exception thì nổi lên ở đoạn code publish giá — đoạn code chẳng biết audit sink là cái gì. Bắt exception theo từng listener là sửa được:
void publishIsolated(String symbol, long cents) {
for (PriceListener3 l : listeners) {
try {
l.onPrice(symbol, cents);
} catch (RuntimeException e) {
System.out.println(" listener failed, continuing: " + e);
}
}
}
publishIsolated:
chart : AAPL 19250
listener failed, continuing: java.lang.IllegalStateException: audit sink offline
alerts : AAPL 19250

Còn một kiểu hỏng thứ ba đáng biết vì nó không trông giống hỏng. Một listener tự hủy đăng ký ngay trong lúc được notify sẽ sửa đúng cái list mà vòng lặp đang duyệt:
L2 once = new L2() {
public void on(String e) {
System.out.println(" once: unsubscribing itself");
bus.remove(this);
}
};
with 2 listeners:
once: unsubscribing itself
fire() returned normally
with 3 listeners:
once: unsubscribing itself
fire() threw: java.util.ConcurrentModificationException
Với hai listener thì cái thứ hai bị bỏ qua trong im lặng và không có gì báo, vì iterator của ArrayList kiểm tra cursor != size và việc xóa đã làm hai giá trị đó bằng nhau. Với ba listener thì nó ném exception. Cùng đoạn code, cùng cái sai, còn chuyện bạn có phát hiện ra hay không thì phụ thuộc vào lúc đó tình cờ có mấy listener đăng ký.
Khi nào observer trở thành thứ gây hại. Khi nó bị dùng để điều khiển luồng chạy thay vì để thông báo. Một danh sách listener làm cho call graph không còn tra ra được: bạn không trả lời được câu "chuyện gì xảy ra khi cái này bắn" bằng cách đọc method publish, mà phải tìm mọi lời gọi addListener trong cả chương trình, kể cả những cái nằm ở module khác. Cái giá đó đáng trả khi subject thật sự không được phép biết khán giả của mình — một widget UI, một cache, một feed dữ liệu. Nó không đáng trả để nối hai class luôn đi cùng nhau và luôn theo đúng một thứ tự; một lời gọi method trực tiếp thì dễ đọc, tra được, và không leak.
Strategy: interface với nhiều implementation, giờ có tên
Đây chính là hình dạng bài trước đã dựng hai lần — một shipping calculator với interface ShippingMethod cho mỗi phương thức, và một class FeatureFlags nhận vào FlagSource — mà cả hai lần đều không gọi tên. Tên của nó là Strategy: thuật toán là một object, bên gọi chọn cái nào, và đoạn code sử dụng nó không rẽ nhánh gì cả.
Comparator là strategy type của JDK
Comparator là interface strategy gọn gàng nhất trong JDK. Một abstract method duy nhất, và javap -v xác nhận điều đó biến nó thành cái gì:
public interface java.util.Comparator<T> {
public abstract int compare(T, T);
...
}
RuntimeVisibleAnnotations:
0: #248()
java.lang.FunctionalInterface
Một call site, ba object thay thế được cho nhau, ba kết quả khác nhau:
record Item(String name, int weight) {}
final class ByWeight implements Comparator<Item> {
public int compare(Item a, Item b) { return Integer.compare(a.weight(), b.weight()); }
}
static int byName(Item a, Item b) { return a.name().compareTo(b.name()); }
static void show(String label, Comparator<Item> c) {
List<Item> l = new ArrayList<>(List.of(
new Item("crate", 30), new Item("box", 10), new Item("bag", 20)));
l.sort(c); // the call site never changes
List<String> names = new ArrayList<>();
for (Item i : l) names.add(i.name());
System.out.println(label + " -> " + names);
}
named class -> [box, bag, crate]
lambda -> [box, bag, crate]
method ref -> [bag, box, crate]
built by JDK -> [crate, bag, box]
l.sort(c) giống hệt nhau ở cả bốn lần chạy. Collections.sort, Arrays.sort, TreeMap, PriorityQueue và List.sort đều nhận strategy như một parameter thay vì tự quy định cách sắp xếp, và đó là lý do chúng chạy được với những type mà JDK chưa từng thấy.

Runnable cũng là hình dạng đó với signature khác: new Thread(runnable) đưa cho một cơ chế cái việc cần làm, còn Thread thì không biết gì về nó. ThreadFactory cũng vậy, và mọi type trong java.util.function mà bạn truyền vào một method cũng vậy.
Khi nào interface có tên mới đáng giá
Nói cho sòng phẳng: trong Java hiện đại, phần lớn strategy chỉ là một lambda hoặc một method reference, và viết hẳn một interface một method có tên cộng hai class implementation cho thứ mà lambda diễn đạt trong một dòng chính là kiểu lạm dụng mà bài này cứ cảnh báo mãi. Ba trong bốn dòng phía trên không cần class nào cả.
Một interface có tên chỉ đáng giá khi strategy mang state hoặc khai báo nhiều hơn một method — cả hai điều lambda đều không làm được.
/** Two methods and per-instance state: not expressible as one lambda. */
interface RetryPolicy {
boolean shouldRetry(int attempt, RuntimeException failure);
int backoffUnits(int attempt);
String describe();
}
final class ExponentialRetry implements RetryPolicy {
private final int maxAttempts;
ExponentialRetry(int maxAttempts) { this.maxAttempts = maxAttempts; }
public boolean shouldRetry(int attempt, RuntimeException f) {
return attempt < maxAttempts && !(f instanceof IllegalArgumentException);
}
public int backoffUnits(int attempt) { return 1 << (attempt - 1); }
public String describe() { return "exponential(max=" + maxAttempts + ")"; }
}
fixed(max=5, units=2) -> ok on attempt 3 [wait 2u, wait 2u]
exponential(max=5) -> ok on attempt 3 [wait 1u, wait 2u]
exponential(max=5) -> gave up after attempt 1 []
fixed(max=5, units=2) -> gave up after attempt 5 [wait 2u, wait 2u, wait 2u, wait 2u]
Backoff được báo theo đơn vị trừu tượng thay vì millisecond để vệt chạy luôn xác định. Cả hai policy đều giữ cấu hình (maxAttempts, độ trễ cố định) và trả lời ba câu hỏi tách biệt, còn dòng thứ ba cho thấy một policy quyết định rằng IllegalArgumentException không đáng retry trong khi policy cố định vẫn retry nó bốn lần. Không thứ nào trong đó nhét vừa một lambda.
Khi nào strategy trở thành thứ gây hại. Khi chỉ có một implementation. Một interface tên TaxStrategy với đúng một class tên StandardTaxStrategy là một lớp bọc có cái tên dài hơn — người đọc đi theo một tầng gián tiếp rồi tới đúng phép tính mà lẽ ra họ đã thấy ngay trong method. Dấu hiệu cần để ý là một interface mà implementation duy nhất của nó được đặt tên theo chính nó.
Khi từng pattern biến thành thiệt hại
Cả năm pattern đều có thể bị áp dụng tới mức tốn nhiều hơn phần thu về, và thất bại lúc nào cũng cùng một dạng: nhiều type hơn, cùng một kết quả. Đây là một strategy cộng một factory áp lên phép tính chỉ có đúng một quy tắc:
interface TaxStrategy { long taxCents(long netCents); }
final class StandardTaxStrategy implements TaxStrategy {
public long taxCents(long netCents) { return Math.round(netCents * 0.10); }
}
final class TaxStrategyFactory {
static TaxStrategy create() { return new StandardTaxStrategy(); }
}
// What all of that replaced.
static long taxCents(long netCents) { return Math.round(netCents * 0.10); }
via strategy + factory = 1617
via one method = 1617
strategy + factory : 4 class files: Over.class StandardTaxStrategy.class TaxStrategy.class TaxStrategyFactory.class
one static method : 1 class file: Plain.class
Cùng một con số, gấp bốn lần số file class, và một người đọc phải mở ba khai báo để tìm ra một phép nhân.
| Pattern | Dấu hiệu bạn đã lạm dụng nó | Nên viết gì thay vào |
|---|---|---|
| Singleton | một class gọi getInstance() bên trong method, và signature không nói gì về chuyện đó | truyền object vào qua constructor |
| Factory | một class factory với đúng một method create() trả về đúng một implementation | gọi thẳng constructor, hoặc thêm static factory method vào chính class đó |
| Builder | builder cho ba field bắt buộc, không có field tùy chọn nào | một record, hoặc một constructor bình thường |
| Observer | dùng listener để xâu chuỗi hai class vốn luôn đi cùng nhau | gọi method trực tiếp |
| Strategy | interface mà implementation duy nhất được đặt tên theo nó | chính thân method, viết thẳng vào chỗ cũ |
⚠️ Câu hỏi đáng tin nhất cho cả năm pattern là: sự biến thiên mà bạn đang chuẩn bị đối phó đã từng xảy ra chưa. Một implementation thứ hai, một đường khởi tạo thứ hai, một listener thứ hai — nếu chưa cái nào tồn tại và cũng chưa cái nào nằm trong kế hoạch, thì pattern chỉ là bảo hiểm cho một thay đổi có thể không bao giờ tới, và phí bảo hiểm do mọi người đọc code trong lúc chờ đợi trả.
Pattern duy nhất ở đây có một đáp án kỹ thuật bắt buộc thay vì một lựa chọn thiết kế là singleton, và đáp án đó là initialization: null check không đồng bộ là sai, double-checked locking cần volatile, còn holder idiom lấy cả hai tính chất từ một quy tắc JVM vốn đã bảo đảm. Mọi thứ còn lại trong bài này là một sự đánh đổi bạn thực hiện với một thay đổi cụ thể trong đầu.
FAQ
Design pattern khác gì với một tính năng của Java?
Tính năng là thứ compiler biết; pattern là thứ chỉ bạn và người đọc kế tiếp biết. javac sẽ không báo cho bạn rằng một class vốn được thiết kế làm singleton, sẽ không kiểm tra rằng builder đã set các field bắt buộc — trừ khi bạn chia stage như ở trên — và không có ý kiến gì về việc một interface với đúng một implementation có phải ý hay hay không. Đó là lý do mọi pattern trong bài này đều được mô tả kèm cái giá của nó chứ không chỉ công dụng.
Singleton có phải anti-pattern trong Java không?
Tự thân thì không. Tính duy nhất là bình thường và thường là đúng — Runtime.getRuntime() là một singleton và chẳng ai phản đối. Thứ bị chỉ trích là truy cập toàn cục: một class gọi Something.getInstance() bên trong method thì có một dependency không xuất hiện trong signature, không thay thế được để kiểm tra, và chia sẻ mutable state với mọi thứ khác trong process. Giữ nguyên một instance duy nhất nhưng truyền nó vào qua constructor thì lời phản đối biến mất.
Vì sao Integer.valueOf(127) == Integer.valueOf(127) trả về true còn 128 thì không?
Vì valueOf trả về object đã cache cho khoảng -128 tới 127 và cấp phát mới ở ngoài khoảng đó, tức là một factory làm điều constructor không làm được. Bài học không nằm ở cái biên: nó nằm ở chỗ so sánh identity trên kiểu boxed là vô nghĩa. Hãy dùng equals, hoặc so sánh giá trị int đã unbox.
Khi nào nên viết builder thay vì constructor?
Khi danh sách parameter không còn đọc nổi ở call site — khoảng bốn hoặc năm parameter, ít hơn nếu nhiều cái là tùy chọn, hoặc ngay lập tức nếu hai parameter cạnh nhau cùng kiểu và có thể bị đảo mà compiler không kêu. Dưới mức đó thì record hoặc constructor thường ngắn hơn và rõ hơn. Đây là một nhận định, và phần trình bày phía trên cho thấy đi sai hướng thì trông thế nào: 21 dòng nguồn với ba file class để in ra đúng dòng mà một record 7 dòng đã in.
java.util.Observer và Observable còn dùng được trên Java 21 không?
Chúng vẫn tồn tại và vẫn chạy. Cả hai đều mang annotation @Deprecated(since = "9") và không cái nào bị đánh dấu để gỡ bỏ, nên code dùng chúng vẫn compile trên OpenJDK 21 kèm warning. Dù vậy vẫn nên tránh: Observable là class chứ không phải interface, nên subject tiêu mất suất kế thừa duy nhất vào nó, và setChanged() là protected, nên bạn không phát nổi một event nếu không kế thừa. Một listener interface một method do bạn tự viết thì nhỏ hơn và tốt hơn.
Có lambda rồi thì còn cần Strategy pattern nữa không?
Bạn vẫn cần ý tưởng đó; thường thì bạn không cần cái interface. Một lambda hoặc method reference chính là một strategy — list.sort((a, b) -> ...) là pattern đó, áp dụng đầy đủ. Hãy viết interface có tên khi strategy giữ state, khai báo nhiều hơn một method, hoặc cần một cái tên diễn đạt khái niệm nghiệp vụ. Ngoài ra thì lambda vẫn là pattern đó nhưng ít code hơn.
Trong năm cái này nên học cái nào trước?
Factory, ở dạng static factory method, vì nó trả lời ngay trong code bạn viết hôm nay: một constructor có tên, một instance được cache, quyền tự do trả về subtype. Kế đó là Builder, vì bug telescoping constructor phía trên là có thật và im lặng. Rồi tới Strategy, vì bạn vốn đã dùng nó mỗi lần truyền một Comparator. Singleton để cuối, chủ yếu để bạn biết vì sao initialization của nó tinh vi và vì sao truy cập toàn cục mới là phần cần tránh.
Kết luận
Năm pattern, và một bản tóm tắt sòng phẳng cho từng cái. Cái khó của singleton không phải tính duy nhất mà là initialization: null check không đồng bộ đã sinh ra tám object từ tám thread, double-checked locking cần volatile để ngăn người đọc thấy một object mới xây dở, còn holder idiom lấy cả tính lazy lẫn thread-safe từ quy tắc class initialization mà JVM vốn đã bảo đảm. Factory mua về ba quyền tự do constructor không thể có — một cái tên, một instance đã cache, một class implementation khác nhau ở từng lời gọi — và đó là lý do List.of(1, 2) với List.of(1, 2, 3) trả về hai class khác nhau, còn EnumSet đổi implementation đúng ở mốc 64 hằng số. Builder biến argument theo vị trí thành lời gọi có tên, và staged builder biến một field bắt buộc bị quên từ IllegalStateException lúc runtime thành lỗi compile. Observer tách subject khỏi khán giả của nó và tính phí bằng reference bị giữ lại cùng luồng điều khiển không tra được. Strategy biến thuật toán thành parameter, và trong Java hiện đại nó thường chỉ là một lambda.
Nửa còn lại của mỗi phần cũng quan trọng ngang vậy. Singleton giấu global state; factory cho một implementation là gián tiếp không đổi lại được gì; builder cho ba field thì dài hơn chính cái record nó thay thế; observer dùng để xâu chuỗi làm call graph không tra ra được; interface strategy với một implementation là một lớp bọc. Bốn file class và một static method cho ra cùng con số 1617.
Bài này khép lại Phần 1 của khóa học, phần nói về ngôn ngữ và cách thiết kế bằng nó: bốn nguyên lý OOP, nested và inner class, advanced enum, generics, SOLID, và giờ là những pattern mà các nguyên lý đó kết tinh thành. Phần 2 chuyển từ việc thiết kế type sang việc dùng những type JDK đã có sẵn, bắt đầu bằng Collection Framework chuyên sâu — và mở màn bằng đúng phép so sánh quyết định phần lớn lựa chọn hằng ngày: ArrayList so với LinkedList so với Vector, mỗi cái thật sự lưu gì bên trong, và thao tác nào thật sự tốn gì.