The Flyweight pattern shares the immutable part of your data across millions of objects to save memory, while Bridge splits two independent axes of variation so the number of classes does not explode as a product. The two patterns solve different problems in the same sales system: sending notifications over several channels, and loading a million order lines for a month-end report where the heap balloons by hundreds of MB because every line carries its own copy of a tax category. Every memory figure in this article comes from a real run on a machine with 16 GB of RAM, with the code compiled with --release 17.
The previous article in the series is Composite pattern: treating object trees uniformly. The leaves of a Composite tree can be shared with the Flyweight pattern. And if you have read Adapter pattern: connecting legacy APIs to new interfaces, Bridge will look familiar but its purpose is quite different; we compare them below.
Bridge pattern: class explosion along two axes
Suppose there are two kinds of notifications (order shipped, payment reminder) and three delivery channels (email, SMS, chat). With plain inheritance you need one class for each combination: OrderShippedEmail, OrderShippedSms, PaymentReminderChat, and so on, for a total of 2 × 3 = 6 classes. Add a push channel and you add 2 classes (2 × 4); add a third notification type after that and you add 4 more. The class count grows as a product, even though the two axes are really independent: the content does not care about the channel, and the channel does not care about the content.
According to the description on refactoring.guru, Bridge splits a large class (or a set of closely related classes) into two separate hierarchies, the abstraction and the implementation, which can be developed independently. Note that these are not the language-level "interface" and "implementing class": the abstraction is a high-level control layer that does little real work, while the implementation is a low-level, platform-dependent layer. The structure has five roles:
| Role | Responsibility | In the notification example |
|---|---|---|
| Abstraction | Holds a reference to the implementor and contains the control logic | Notification |
| Refined Abstraction | Variants of the control logic | OrderShipped, PaymentReminder |
| Implementor | The common interface of the low-level layer | Channel |
| Concrete Implementor | Platform-dependent code | EmailChannel, SmsChannel, ChatChannel |
| Client | Pairs an abstraction with an implementor, then uses the abstraction | main |
Implementing the Bridge pattern in Java
The two hierarchies are connected by a composition reference: Notification holds a Channel. That is the "bridge". The code below compiles with --release 17:
/** Implementor: only knows how to "deliver a piece of text to a recipient" over one specific channel. */
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 has a length limit
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: holds a reference to a Channel and decides the content. */
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, "Order " + orderId + " has been handed to the carrier."); }
}
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, "You have a pending payment of " + amount + " VND."); }
}The main method pairs every notification type with every channel using only a loop, with no combination classes:
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");
}
// Class count: plain inheritance needs 2 x 3 = 6 subclasses; Bridge needs 2 + 3 = 5 classes; adding one channel: +2 versus +1.
System.out.println("Plain inheritance: " + (2 * 3) + " classes; Bridge: " + (2 + 3) + " classes");
int loai = 5, kenh = 4;
System.out.println("With " + loai + " notification types x " + kenh + " channels: " + (loai * kenh) + " classes versus " + (loai + kenh));
}Actual output (java -cp out BridgeDemo):
[EMAIL -> khach01] Order DH-1024 has been handed to the carrier.
[EMAIL -> khach01] You have a pending payment of 1250000 VND.
[SMS -> khach01] Order DH-1024 has been handed to the...
[SMS -> khach01] You have a pending payment of 12...
[CHAT -> khach01] Order DH-1024 has been handed to the carrier.
[CHAT -> khach01] You have a pending payment of 1250000 VND.
Plain inheritance: 6 classes; Bridge: 5 classes
With 5 notification types x 4 channels: 20 classes versus 9The SMS channel truncates the content because of its length limit; that detail stays inside SmsChannel and does not leak into OrderShipped. The last two lines count concrete classes (excluding Notification and Channel, the root abstraction and interface, since both approaches have them): with 5 notification types and 4 channels, plain inheritance needs 20 classes and Bridge needs only 9. Add one more channel and Bridge adds just 1 class.
Bridge in practice, compared with Adapter and Strategy
The example most often cited in the Java ecosystem is JDBC: the application is written against Connection, Statement and ResultSet, which the JDK defines, while each database vendor provides the implementor side inside its driver. This is how many references interpret it, not an official statement from the JDK. The interface documentation is in the java.sql package (Oracle), and the original definition of both patterns is in the GoF book Design Patterns (1994).
The three patterns are easy to confuse because they all rely on composition and delegation:
- Bridge vs. Adapter: Bridge is usually designed up front so that two parts can evolve independently; Adapter is applied to an existing system so that incompatible classes can work together. The Adapter article linked above shows that "firefighting" direction.
- Bridge vs. Strategy: the structure is the same but the problem differs. Bridge separates two axes of a design; Strategy swaps interchangeable algorithms. In the next article of the series, we write Strategy with lambdas.
- Bridge vs. inheritance: Bridge replaces inheritance with composition along one axis, so you can swap the implementor at runtime (if you drop
finaland add a setter).
Flyweight pattern: sharing state to save memory
The Flyweight pattern is a structural pattern that reduces memory by letting many objects share the parts of their state that are identical. The core of the Flyweight pattern is splitting an object's state into two groups, as refactoring.guru describes:
- Intrinsic state: constant data repeated across a huge number of objects, such as a tax category with its code, description, rate and icon. It lives in a shared, immutable object that is assigned only through the constructor.
- Extrinsic state: data specific to each context, such as the quantity and unit price of each order line. It lives outside the flyweight, held by the client or a context object, and is passed into methods when needed.
The Flyweight pattern has four parts: the Flyweight holds the intrinsic state; the Context holds the extrinsic state plus a reference to the flyweight; the Flyweight Factory manages a pool and returns an existing flyweight when the intrinsic state matches, or creates a new one otherwise; the Client obtains flyweights only through the factory. The vital condition of the Flyweight pattern is that flyweights must be immutable, because a single change would spread to every place that shares the object.
Measuring the Flyweight pattern with a million order lines
Instead of trusting theoretical numbers, we measure the Flyweight pattern. The scenario: one million order lines belonging to 20 tax categories. The common approach is for each line to hold its own TaxCategory, because the data is loaded from the database and an object is built for every line. The Flyweight pattern approach builds only 20 TaxCategory objects and lets the lines point to them. The two line classes are structurally identical; they differ only in how they are created:
/** Intrinsic state: immutable, shared. */
static final class TaxCategory {
final String code;
final String description;
final int ratePercent;
final byte[] icon; // simulates heavy accompanying data
TaxCategory(String code, String description, int ratePercent, int iconBytes) {
this.code = code; this.description = description; this.ratePercent = ratePercent;
this.icon = new byte[iconBytes];
}
}
/** Order line with duplicated data: each line holds its own copy of 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; }
}
/** Order line using Flyweight: holds only a reference; qty and price are extrinsic state. */
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; }
}The measurement calls System.gc() a few times and then reads totalMemory - freeMemory. The shared pool is just an array, a minimal version of the Flyweight Factory in the Flyweight pattern. The code in this article is an excerpt; the full source is in code/s6-11/src/ (Mem.java, BridgeDemo.java, Factory.java). The usedAfterGc() function:
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();
}The main measurement:
static final int N = 1_000_000, TYPES = 20;
static final int ICON = Integer.getInteger("icon", 512); // size of the heavy data attached to each TaxCategory (bytes)
static TaxCategory make(int k) {
return new TaxCategory("VAT-" + k, "Product group " + k + " taxed at the rate set by current regulations", 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("Without Flyweight: %,d lines, %d TaxCategory objects in memory -> %.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]; // minimal Flyweight Factory
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("With Flyweight : %,d lines, %d TaxCategory objects in memory -> %.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("Savings: %.1fx; business results identical: %b (%d)%n",
(double) naiveBytes / sharedBytes, checksum == checksum2, checksum2);
}Actual results (java -Xmx2g -cp out Mem, JDK 21, compiled with --release 17, 16 GB RAM machine). Across runs, the version without Flyweight stays around 759.7 to 760.1 MB and the Flyweight version is 34.5 or 35.3 MB, so the ratio varies between about 21.5x and 22.0x because of GC. One run:
Without Flyweight: 1,000,000 lines, 1000000 TaxCategory objects in memory -> 759.7 MB
With Flyweight : 1,000,000 lines, 20 TaxCategory objects in memory -> 34.5 MB
Savings: 22.0x; business results identical: true (2713150000)With the Flyweight pattern, retained memory drops by about 22 times (21.5 to 22.0 depending on the run), from roughly 760 MB to roughly 35 MB, and the business result (the total tax) is exactly the same. In this measurement, the remaining 35 MB is one million line objects plus the array of references, that is, the extrinsic state, which cannot be shared. The number depends heavily on how "heavy" the shared part is: each tax category here carries a 512-byte array simulating an icon. When you remove that array (-Dicon=0), the result is far more modest:
Without Flyweight: 1,000,000 lines, 1000000 TaxCategory objects in memory -> 271.3 MB
With Flyweight : 1,000,000 lines, 20 TaxCategory objects in memory -> 35.2 MB
Savings: 7.7x; business results identical: true (2713150000)The Flyweight pattern still saves memory (about 7.7x to 7.9x across runs) but less, because what remains of TaxCategory is two strings and a few small fields. The benefit of the Flyweight pattern scales with the size of the intrinsic part and how often it repeats; for lightweight objects, sharing may not be worth the effort. Always measure on your own data before applying the Flyweight pattern.
Flyweight pattern in the JDK: a thread-safe factory, Integer and String
The array above is only good enough for a single-threaded experiment with the Flyweight pattern. In a real application, the factory must be thread-safe. ConcurrentHashMap.computeIfAbsent guarantees that each key creates only one flyweight, and a record gives us an immutable type almost for free:
record Currency(String code, int decimals) { } // immutable record: a good Flyweight candidate
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(); }
}The JDK uses the Flyweight pattern idea in several places. Integer.valueOf(int) always caches values from -128 to 127 according to the Java SE 17 Integer documentation, and String.intern() returns the canonical representative from a separate pool. The run below shows the consequences of the Flyweight pattern, including the trap when comparing with ==:
The main part of Factory.java (comparing Integer, String.intern() and calling from eight threads) is not pasted here because it is long; see that file. Actual output:
VND shared: true, flyweight count: 2
Integer 100 == 100: true
Integer 1000 == 1000: false (reference comparison, do not use ==)
Integer 1000 equals 1000: true
new String: false, after intern(): true
Currency[code=USD, decimals=2]
8 threads all fetching EUR, same object: true, total flyweights: 3Notice the line Integer 1000 == 1000: false. The documentation only guarantees caching in the range -128 to 127 and allows caching beyond it, so never rely on == to compare boxed integers: use equals. The last line of the example shows that eight threads all requesting the EUR currency receive the same object.
When not to use the Flyweight pattern and Bridge
The Flyweight pattern is an optimization, so following refactoring.guru's advice, use it only when the program really has a large number of similar objects straining RAM and you have confirmed that this is the problem. The trade-offs are:
- CPU traded for RAM: if the extrinsic state must be recomputed on every call, you pay in time.
- Complexity: the Flyweight pattern forces you to split intrinsic and extrinsic state, which makes code harder for newcomers to read.
- Immutability is mandatory: objects that change often are a poor fit, because each change means switching to a different flyweight, so the Flyweight pattern does not suit frequently changing data.
- A pool that is never cleaned: the pool holds strong references, so if keys are generated without bound (for example per-user strings), the pool itself is a memory leak. With the Flyweight pattern, use a size limit or weak references.
For Bridge, the main drawback is needless complexity when applied to a class that is already cohesive: with a single axis of variation, inheritance or Strategy is enough. The sign that you should use Bridge (rather than the Flyweight pattern or inheritance) is when you start naming classes as A + B glued together.
Common mistakes
- Flyweight pattern with mutable state: a setter on
TaxCategorywould change the tax rate of a million lines at once. Make all fieldsfinaland use arecordwhen you can. - Creating flyweights outside the factory: in the Flyweight pattern, a single direct
newcall somewhere is enough to defeat the pool. Make the constructor package-private or private. - A factory that is not thread-safe: in the Flyweight pattern, check-then-create with a plain
HashMapcan create two instances for the same key. UsecomputeIfAbsentofConcurrentHashMap. - Measuring memory wrongly: not calling GC before reading, or reading a heap size that the JVM has resized. The result of
System.gc()is only a hint, so run several times and use JFR or a heap dump when you need precision. - Bridge where the implementor knows the abstraction: if
SmsChannelchecksinstanceof OrderShipped, the bridge is broken and the two axes are no longer independent.
Checklist for applying the Flyweight pattern and Bridge
- Does a class name combine two independent concepts? If so, split them into two hierarchies and connect them with Bridge.
- Does the implementor receive data only through its interface and know nothing about the abstractions?
- Before applying the Flyweight pattern, have you measured memory (heap dump or a measurement like the one in this article) and confirmed the problem is real?
- With the Flyweight pattern, is the shared part immutable, and does the flyweight pool have a size limit or a clear lifecycle?
- If a Composite tree has many identical leaves, try the Flyweight pattern before optimizing in other ways.
The full source of the Flyweight pattern and Bridge examples was compiled with --release 17 and rerun before the results were pasted. You can read more about the fundamentals in SOLID principles in Java with practical examples, especially the Open/Closed principle, which is the main reason behind Bridge.
Next article
The next article in the series is Strategy pattern with lambdas and functional interfaces. We will see a structure very similar to Bridge but with a different purpose, and Java 8 and later let you replace an entire Strategy class with a single lambda expression.



