skip to content

What hazards arise from the shared, interned instances that the Flyweight pattern creates — around identity comparison, mutability, locking, serialization and multi-tenancy?

level: principalimportance: nice to knowfreq 18%

answer

  1. Shared reference = coupling across call sites
  2. Deep immutability, not just final fields
  3. Never lock on an interned object
  4. Deserialization/reflection/eviction mint twins
  5. Global table pins tenants and class loaders

basics

~20 s

Because 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 s

Sharing 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

for a junior

Say the shared object must never be modified, because a change would affect everywhere it is used.

for a middle

Add deep immutability (including contained collections) and the point that reference comparison is unsafe when instances can be created outside the factory.

for a senior

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.

for a principal

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

context