skip to content

Show how a companion factory with a private constructor can intern/cache instances so equal inputs return the same object. What are the concurrency and lifecycle concerns?

level: seniorimportance: should knowfreq 35%

answer

  1. ConcurrentHashMap + computeIfAbsent (atomic)
  2. getOrPut is NOT atomic — may build twice
  3. private constructor → cache can't be bypassed
  4. unbounded map = leak; use Weak/Soft or LRU
  5. interning makes === coincide with equals

basics

~20 s

Keep a cache (a map) in the companion. The factory looks up the key; if found it returns the existing object, otherwise it builds one, stores it, and returns it. With a private constructor, the factory is the only way in, so the cache can never be bypassed.

solid answer

~40 s

Put a cache in the companion, e.g. a `ConcurrentHashMap<Key, T>`, and have the named factory do `cache.getOrPut(key) { T(...) }`. The `private constructor` guarantees no caller can sidestep the cache, so interning is airtight. For thread safety, use a concurrent map and prefer `computeIfAbsent` (atomic) over read-then-write; note `getOrPut` may evaluate the builder more than once under contention, so use it only when double-construction is harmless. Watch lifecycle: a plain map is an unbounded cache that pins objects forever (memory leak); for large/unbounded key spaces use a bounded cache or `WeakReference`/`SoftReference` values. Document equality semantics — if instances are interned, reference equality (`===`) may coincide with `equals`, which callers might lean on. The companion is initialized lazily and lives for the class's lifetime, so the cache is effectively a process-wide singleton store.

code

kotlin · 11 lines
kotlin
class Color private constructor(val hex: String) {
    companion object {
        private val pool = java.util.concurrent.ConcurrentHashMap<String, Color>()
        fun of(hex: String): Color =
            pool.computeIfAbsent(hex.lowercase()) { Color(it) }
    }
}

val x = Color.of("#FFF")
val y = Color.of("#fff")
check(x === y) // interned: identical instance

go deeper

for a junior

Can describe 'check a map, return existing or create new' at a high level.

for a middle

Writes the getOrPut/map version and sees that a private constructor makes the cache authoritative.

for a senior

Chooses ConcurrentHashMap.computeIfAbsent for atomicity, explains the getOrPut non-atomicity caveat, and addresses unbounded-cache leaks.

for a principal

Weighs interning against memory/lifecycle, defines the identity-equality contract, and scopes the technique to bounded immutable value domains vs unbounded keys.

## The pattern Interning means returning the **same instance** for equal inputs. The companion holds the cache; the private constructor forces all creation through the factory. ```kotlin class Currency private constructor(val code: String) { companion object { private val cache = java.util.concurrent.ConcurrentHashMap<String, Currency>() fun of(code: String): Currency { val key = code.uppercase() return cache.computeIfAbsent(key) { Currency(it) } } } } val a = Currency.of("usd") val b = Currency.of("USD") println(a === b) // true — same interned instance ``` Because `Currency(...)` is private, **nobody can create a second `USD`** outside the factory; the cache is authoritative. ## Concurrency - **Atomic insert**: `ConcurrentHashMap.computeIfAbsent` performs the lookup-and-insert atomically per key, so concurrent callers see one instance. - **`getOrPut` caveat**: Kotlin's `MutableMap.getOrPut` is *not* atomic — under contention the lambda can run more than once and a value can be overwritten. Use it only when constructing twice is cheap and harmless, or prefer `computeIfAbsent` on a concurrent map. - **Visibility**: a `ConcurrentHashMap` provides the needed happens-before guarantees; a plain `HashMap` shared across threads is unsafe. - **Companion init**: the companion (and its cache) is initialized lazily and safely by the JVM class-init lock the first time the class is touched. ## Lifecycle / memory - A strong-referenced map is an **unbounded cache**: every distinct key pins an object for the JVM's life → potential memory leak for open-ended key spaces. - Mitigations: bound the cache (size/LRU), or store `SoftReference`/`WeakReference` values so the GC can reclaim unused instances. - The companion lives as long as its class is loaded, so the cache is effectively a **process-global** store — fine for small fixed domains (enum-like values, currencies), risky for user-supplied keys. ## Equality contract With interning, `===` (reference equality) often matches `equals`. Document whether callers may rely on identity; if you later disable interning, identity-dependent code can break. ## When to use Great for **small, bounded, immutable value domains** (color names, currencies, flyweight tokens). Avoid for large/unbounded or short-lived data where the cache outweighs the savings.

  • Why prefer computeIfAbsent over Kotlin's getOrPut here?
    computeIfAbsent on ConcurrentHashMap is atomic per key, guaranteeing a single instance; getOrPut isn't atomic and under contention may run the builder twice or overwrite the value.
  • How do you stop the cache from leaking memory for open-ended keys?
    Bound it (size/LRU eviction) or store SoftReference/WeakReference values so the GC can reclaim instances that are no longer strongly referenced elsewhere.

Interning is like a library issuing one official copy of each book: ask for 'Dune' twice and you get the very same physical book, because the front desk (factory) never prints a duplicate.

saying these in an interview costs you the question

  • Using a plain HashMap shared across threads
  • Assuming getOrPut is atomic
  • Ignoring unbounded-cache memory growth
  • Letting external code bypass the cache via a public constructor
  • Relying on === without documenting the interning contract

context