How should the flyweight factory be implemented — the cache/interning strategy behind the Flyweight pattern — and what concurrency and lifetime issues does it introduce?
answer
- Factory = canonicalizing cache (interning)
- Key: immutable, value-equal, normalized
- Atomic compute-if-absent, not check-then-act
- Unbounded strong map = leak
- Eviction breaks one-instance-per-key
basics
~20 sThe factory keeps a map from the shared-state key to the single instance, creating one only on a miss. It must be thread-safe, must not let two callers get two different instances for the same key, and must have a bounding or eviction policy so the map does not grow forever.
solid answer
~50 sA flyweight factory is a canonicalizing cache: `get(key)` returns the one instance for that key, creating it on first request (interning). Requirements: (1) the key must be a correct value-equality key — equal keys must hash equally and be immutable, or lookups silently miss; (2) the operation must be atomic under concurrency, otherwise two threads create two instances and uniqueness — which callers may rely on for reference equality — is broken. Use a concurrent map with an atomic compute-if-absent, or precompute the whole table at startup when the key space is small and known (enums, small-integer caches). (3) Lifetime: a strongly-referenced unbounded map is a memory leak by construction, and if flyweights transitively reference application classes or contexts, it pins them. Options are: bounded caches with an eviction policy, weak-value or weak-key references so unreferenced flyweights are collectible, or accepting permanence when the key space is genuinely finite. (4) Creation should be cheap or the miss path should avoid holding a global lock while constructing.
code
pseudocode · 10 linesclass GlyphFactory {
private val cache = ConcurrentMap<GlyphKey, Glyph>() // key is immutable + value-equal
fun of(family, size, weight, ch): Glyph {
val key = GlyphKey(normalize(family), size, weight, ch)
return cache.computeIfAbsent(key) { k -> Glyph(loadOutline(k)) } // atomic: one instance per key
}
fun stats() = (distinct = cache.size, acquisitions = counter) // prove K << N
}go deeper
Say the factory keeps a map from key to instance and reuses an existing one instead of creating a new object.
Add key correctness (immutable, value-equality) and thread safety via an atomic compute-if-absent instead of check-then-put.
Discuss lifetime — unbounded strong caches leak and pin object graphs — plus weak/bounded/scoped alternatives, safe publication, and the trade-off that eviction destroys the uniqueness invariant.
Argue about where canonicalization belongs (object layer vs storage/serialization layer), multi-tenant and class-loader pinning risks, observability of K/N/hit-ratio to justify or retire the optimization, and the API contract you are implicitly promising callers about instance identity.
## What the factory is In the **Flyweight** pattern the client never calls the flyweight's constructor. It asks a **flyweight factory** for an instance by its intrinsic key, and the factory guarantees *one canonical instance per distinct key*. That guarantee is the entire pattern: without it, sharing does not happen. The general technique of "return the one canonical instance for this value" is called **interning** or **canonicalization**. ``` fun of(key): Flyweight = cache.computeIfAbsent(key) { k -> new Flyweight(k) } // atomic ``` ## Requirement 1 — a sound key The key is the tuple of intrinsic values. It must be: - **Immutable.** A mutable key that changes after insertion becomes unfindable — the entry is stranded in the map forever (a leak) and later lookups miss and create duplicates. - **Correctly value-equal.** Equality and hashing must be defined over the whole tuple. If they are identity-based (the default in many languages), every lookup misses and the cache degenerates into an allocator with a leak attached. - **Cheap to hash.** The factory is on the hot path; hashing a large key can cost more than the allocation you avoided. Sometimes a small integer or packed bit-field key is worth designing for. - **Normalized.** Two spellings of the same value (`"US"` vs `"us"`, `1.0` vs `1.00`, `+0.0` vs `-0.0`, unicode composed vs decomposed) must be canonicalized before lookup, or you get duplicate flyweights that are semantically identical — undermining both memory savings and any reference-equality reasoning. ## Requirement 2 — concurrency The naive `if (!map.containsKey(k)) map.put(k, create(k))` is a check-then-act race: under concurrency two threads can both miss and both create. Consequences range from benign (a little extra memory, both instances behave identically) to serious (if any code compares flyweights by reference, or if the flyweight owns a unique resource such as a file handle, native buffer or lock). Strategies, roughly in order of preference: 1. **Eager/precomputed table.** If the key space is small and known at startup — enum constants, the 256 byte values, a fixed set of currencies — build the whole table once and make it read-only. No locking, no eviction, no races, trivially thread-safe. 2. **Concurrent map with atomic compute-if-absent.** One instance per key is guaranteed by the map; construction of *different* keys proceeds in parallel. Beware: on some implementations the mapping function runs under a bin lock, so a slow or recursive constructor can stall or deadlock the map. If construction is expensive, insert a lazily-initialized holder/future instead of the value. 3. **Optimistic create-then-`putIfAbsent`.** Construct speculatively, publish atomically, and discard the loser. Safe only when the flyweight is cheap and side-effect-free to construct and discard — never when it owns a resource. 4. **Coarse `synchronized`.** Correct but serializes all acquisitions; acceptable only off the hot path. Also: the flyweight itself must be **safely published** — fully constructed and immutable before any other thread can see it — or readers can observe partially initialized state. Deep immutability plus final/`val` fields gives this for free in most memory models. ## Requirement 3 — lifetime and leaks A flyweight cache is a **global strong reference table**. That is fine when the key space is finite and small; it is a leak when the key space is open-ended (arbitrary user strings, per-tenant configurations, generated identifiers). Even worse, if a flyweight transitively references a large graph, a class loader, a request context or a connection, the cache pins all of it. Remedies: - **Weak or soft value references**, so a flyweight that nothing else holds becomes collectible. Note that this defeats the purpose if occurrences hold their flyweight only indirectly, and it introduces cleanup work and non-determinism. - **Bounded cache with eviction** (LRU/LFU/size- or weight-based). This trades the uniqueness invariant: after eviction a later lookup creates a *different* instance for the same key, so reference-equality reasoning is no longer sound and two live flyweights for one key can coexist. - **Scoped caches.** Instead of one process-global table, attach the cache to a document, session, request or tenant so it dies with its scope. This also fixes multi-tenant cross-contamination and makes tests deterministic. - **Deliberate permanence.** If the key space is provably finite, an unbounded table is correct — but write that assumption down, and consider a size assertion or metric so a violated assumption surfaces as an alert rather than an out-of-memory error. ## Requirement 4 — knowing whether it works Instrument the factory: number of distinct entries (`K`), acquisitions (`N`), hit ratio, and estimated retained bytes. A hit ratio near zero, or `K` tracking `N`, is proof that the intrinsic/extrinsic split is wrong or the key includes context. These metrics are also how you defend the design in review — Flyweight is a memory optimization and should be justified by measurement, not intuition. ## Related library behaviour worth citing Runtimes intern small integers and short strings; compile-time string literals are typically interned automatically while runtime-built strings are not, which is exactly why comparing strings by reference is a classic bug. Enum constants are eagerly-built flyweight tables. Analytical file formats dictionary-encode repeated column values — a flyweight factory realized in storage rather than in objects.
- Why is the check-then-act version (`if absent, then put`) dangerous even though both instances behave the same?Because callers may rely on uniqueness: reference equality as a fast identity test, identity-keyed side tables, per-flyweight locks, or a flyweight owning a native resource. Duplicates silently break all of those, and the bug is load-dependent and hard to reproduce.
- What changes if you put an LRU eviction policy on the flyweight cache?You bound memory but give up the one-instance-per-key guarantee: after eviction the same key yields a new instance, so two live flyweights for one value can coexist and reference comparison stops being sound. You must then use value equality everywhere and ensure flyweights own no unique resources.
- When is an eagerly precomputed flyweight table the better choice?When the key space is small, finite and known up front — enums, byte values, currencies, HTTP status codes. You get zero lock contention, no eviction policy, no leak risk, and constant-time lookup, at the cost of some startup work.
saying these in an interview costs you the question
- Using check-then-act (`containsKey` then `put`) and calling it thread-safe
- Using a key type with identity-based equality/hashing, so every lookup misses
- Leaving the table unbounded and strongly referenced over an open-ended key space
- Assuming eviction is free when callers compare flyweights by reference
- Running an expensive or reentrant constructor inside a concurrent map's compute-if-absent function
- Never measuring distinct entries versus acquisitions, so a useless cache goes unnoticed