What hazards arise from the shared, interned instances that the Flyweight pattern creates — around identity comparison, mutability, locking, serialization and multi-tenancy?
answer
- Shared reference = coupling across call sites
- Deep immutability, not just final fields
- Never lock on an interned object
- Deserialization/reflection/eviction mint twins
- Global table pins tenants and class loaders
basics
~20 sBecause one object is shared everywhere, any mutation of it affects every user; comparing by reference works until an instance is created outside the factory or evicted; locking on a shared instance can make unrelated code contend or deadlock; and copies from serialization or other tenants silently break the "one instance" assumption.
solid answer
~50 sSharing creates coupling between unrelated call sites, and the hazards follow from that. **Mutability**: any writable field on a flyweight is a global variable in disguise; a single write corrupts every occurrence and produces load-dependent, non-reproducible bugs. Require deep immutability, including defensive copies of collections and arrays. **Identity**: interning tempts callers into reference comparison. That is sound only if *every* instance goes through the factory and nothing is ever evicted. Deserialization, reflection, cloning, cross-classloader or cross-process boundaries, and eviction all mint uninterned duplicates, after which reference comparison fails silently for a subset of values — the worst kind of bug. Keep value equality as the contract; treat identity as an internal optimization. **Locking**: synchronizing on a shared flyweight makes unrelated clients contend, and interned instances are also visible to third-party code, enabling deadlock. Never lock on interned objects. **Scope**: a process-wide factory leaks across tenants, tests and request contexts, and pins whatever the flyweights reference. Prefer scoped factories where isolation matters.
go deeper
Say the shared object must never be modified, because a change would affect everywhere it is used.
Add deep immutability (including contained collections) and the point that reference comparison is unsafe when instances can be created outside the factory.
Cover the full identity-trap surface — deserialization, reflection, eviction, class loaders — plus the never-lock-on-shared-instances rule and the leak/pinning risk of a global table.
Frame it as coupling introduced deliberately for memory, and govern it: factory-only construction, value equality as the public contract, scoped/tenant-aware tables, re-canonicalization at trust and serialization boundaries, staleness/versioning of intrinsic data, and metrics that prove the optimization still pays.
## The root cause **Flyweight** deliberately makes one object reachable from many unrelated places. Everything below is a consequence of that single fact: *shared references create coupling between code paths that were never designed to know about each other*. ## Hazard 1 — mutability turns a field into a global variable If a flyweight exposes any writable state, writing it from one call site changes what every other occurrence observes. Typical symptoms: an attribute "randomly" changes between reads; a rendering artifact appears only under load; a test passes alone and fails in a suite. These reproduce poorly because they depend on interleaving and on which occurrences happen to share a key. Defences: - **Deep immutability.** Final/`val` fields are not enough: an immutable object holding a mutable list or array is mutable. Copy on construction and expose unmodifiable views, or store primitive arrays that are never handed out. - **No lazy caching of context-dependent values.** A tempting `lastComputedLayout` field inside the flyweight is extrinsic state in disguise. Lazy caching of *intrinsic-derived* values is fine if it is idempotent and safely published; anything derived from arguments is not. - **Safe publication.** Another thread must not be able to see a partially constructed flyweight. Immutable objects with final fields get this from most memory models; objects populated after construction do not. ## Hazard 2 — the identity trap Interning creates a tempting invariant: "equal values are the same object, so I can compare with `==`/`is`". Reference comparison is fast and it works — until an instance appears that did not come from the factory. Sources of uninterned duplicates: - **Deserialization.** Reading an object from bytes, JSON, or an RPC frame constructs a fresh instance. Unless you re-canonicalize on the read path (a `readResolve`-style hook or an explicit `intern()` in your mapper), you now hold a value-equal but reference-distinct twin. - **Reflection, cloning, copy constructors, direct `new`.** Any path that bypasses the factory. Make constructors private/internal so the factory is the only door. - **Eviction or weak references.** If the cache can drop entries, the same key can yield a different instance later, and two live instances for one value can coexist. - **Multiple class loaders / modules / processes.** Distinct loaders create distinct classes and distinct interning tables; "the same" constant is not the same object across them. Nothing is shared across process boundaries at all. - **Compile-time vs runtime construction.** In several runtimes, literal constants are automatically interned while values built at runtime are not — which is exactly why comparing strings by reference is a classic beginner bug that hides for months. The rule: **publish value equality as the contract; treat reference identity as a private optimization** that only code inside the flyweight's own module may exploit, and only if it controls every construction path. ## Hazard 3 — locking and unintended contention Synchronizing on an interned object is a well-known anti-pattern. Because the instance is globally reachable, any other component — including third-party libraries that intern the same values — can lock the same monitor. Results: contention between unrelated subsystems, priority inversion, and genuine deadlock via lock-ordering conflicts you cannot see in your own code. Use a private lock object, or better, keep flyweights immutable so no locking is needed at all. The same applies to using a flyweight as a key in an identity-keyed side table with different lifecycle assumptions. ## Hazard 4 — scope, tenancy and lifecycle A process-global flyweight table is shared by every request, tenant, test and background job. - **Tenancy/security.** If flyweights encode tenant-derived data, or if the *presence* of a key is observable (via metrics, timing, or a size limit), you have a cross-tenant information channel. Key on tenant or scope the factory per tenant. - **Tests.** Global interning makes tests order-dependent: one test's entries change another's memory profile or eviction behaviour. Prefer injectable, scoped factories. - **Reachability pinning.** Flyweights that transitively reference contexts, connections or class loaders keep them alive forever, which is a classic redeploy-time leak in containerized application servers. - **Reload/versioning.** If the intrinsic data can change (a font is updated, a tax rule is amended), a permanent interned instance is now stale. Version the key or scope the cache to a configuration generation, and be explicit about whether existing occurrences see the old or new value. ## Hazard 5 — debuggability and ownership Shared objects make heap analysis and blame assignment harder: a retained-size report attributes shared bytes ambiguously, and an unexpected value in a flyweight gives you no clue which of a million call sites wrote it (if it was mutable). Logging is also affected — a flyweight's `toString` appears identically in unrelated log lines. Build in observability: factory metrics (`K`, hit ratio, retained bytes), an assertion or test that equal keys return identical instances, and a test that the flyweight type has no mutable state. ## The governing principle Flyweight buys memory with **coupling through shared references**. Every hazard above is that coupling escaping the boundary you intended. Keep the boundary tight: deep immutability, private constructors, factory-only construction, no locking on shared instances, scoped rather than global tables where isolation matters, and value equality as the public contract.
- Why is comparing interned values by reference dangerous even when the factory guarantees uniqueness today?The guarantee only covers instances the factory created and never evicted. Deserialization, reflection, cloning, a second class loader, a different process, or adding eviction later all produce value-equal but reference-distinct instances. The comparison then fails for a subset of values, silently and non-deterministically. Publish value equality and keep identity as an internal optimization.
- How would you preserve canonical instances across serialization boundaries?Re-canonicalize on the read path: run every deserialized instance back through the factory (a resolve hook, or an explicit intern step in the mapper/codec). Alternatively serialize only the key and reconstruct via the factory on the other side, which also shrinks the payload. Never assume identity survives a byte stream.
- What is wrong with using a flyweight object as a synchronization lock?It is globally reachable, so unrelated components — including third-party code that interns the same value — may lock the same monitor. You get contention between subsystems that share nothing logically, and deadlocks arising from lock orderings you cannot observe. Use a private lock object, or rely on immutability so no lock is needed.
A shared whiteboard in a hallway: brilliant for broadcasting one message cheaply, disastrous the moment any passer-by is allowed to edit it — and if two floors each get their own whiteboard, "the" message is no longer one thing.
saying these in an interview costs you the question
- Adding a 'harmless' setter or lazily-computed context field to a shared flyweight
- Treating final/val fields as sufficient immutability while exposing a mutable collection or array
- Publishing reference equality as the comparison contract for interned values
- Assuming instance identity survives serialization, reflection, cloning, or a second class loader
- Synchronizing on an interned object such as a shared flyweight or interned string
- Keeping one process-global factory in a multi-tenant system, so tenants share and pin each other's data