Free lesson · Concurrency Interview Mastery
Exercise: Design a Thread-Safe Cache with TTL
This exercise builds the lazy-expiration cache, adds active expiration, and diagnoses the classic TTL-cache concurrency bugs. Reason each through before the solution - the goal is to wield the atomic conditional remove (avoiding the check-then-act race on expiry) and to weigh lazy versus active reclamation.
Important: Two anchors: store an absolute expiry time and remove expired entries with an atomic conditional remove (
map.remove(key, entry)- only if still this entry), so a concurrent freshputis never clobbered; and lazy expiration keeps reads correct but leaks memory for untouched keys - a background sweeper reclaims it.
Part 1 - Lazy expiration
Your task: Implement put and get on a concurrent map with lazy expiration. Explain why you store absolute expiry time and how get handles an expired entry safely.
<details> <summary>Hint</summary>
map = ConcurrentHashMap<K, Entry>, Entry = {value, expiryTime}. put(k,v,ttl): map.put(k, Entry(v, now()+ttl)) - store absolute time so expiry is a simple <= now() check. get(k): e = map.get(k); if e==null return null; if e.expiryTime <= now(): map.remove(k, e); return null; return e.value. The conditional remove(k, e) removes only if the key still maps to this entry, avoiding clobbering a concurrent fresh put.
</details>
Part 2 - Active expiration
Your task: Add a background sweeper that reclaims expired entries even if they're never accessed. How do you make it efficient rather than scanning the whole map every time?
<details> <summary>Hint</summary>
A ScheduledExecutorService runs a sweep periodically. Naive: scan all entries, remove expired - O(n) per sweep. Efficient: maintain a DelayQueue or priority queue of (expiryTime, key) ordered by expiry; the sweeper pops only entries whose expiry has passed (O(log n) each), removing each from the map with a conditional remove (only if still the expired entry). Pair lazy (correctness) with the sweeper (memory hygiene).
</details>
Part 3 - Find the bugs
// Bug A - non-atomic expiry removal:
function get(key):
entry = map.get(key)
if entry != null and entry.expiryTime <= now():
map.remove(key) // <-- unconditional remove
return null
return entry == null ? null : entry.value
// Bug B - TTL stored as duration:
function put(key, value, ttlMillis):
map.put(key, new Entry(value, ttlMillis)) // <-- stores the duration
function get(key):
entry = map.get(key)
if entry != null and entry.expiryTime <= now(): ... // compares duration to now()
Your task: Identify each bug and its effect.
<details> <summary>Hint</summary>
Bug A: map.remove(key) removes unconditionally. Between reading the expired entry and removing, another thread could put a fresh value for key - the unconditional remove then deletes the new value (data loss). Fix: map.remove(key, entry) (conditional - only if still the expired entry). Bug B: the entry stores the TTL duration (e.g., 5000ms) as expiryTime, but get compares it to now() (a large absolute timestamp). Since 5000 <= now() is always true, every entry is treated as expired immediately. Fix: store now() + ttlMillis (absolute expiry).
</details>
Solution
Part 1 - Lazy expiration
class Entry<V>:
value: V
expiryTime: long # absolute: now() + ttl
map = ConcurrentHashMap<K, Entry<V>>()
function put(key, value, ttlMillis):
map.put(key, new Entry(value, now() + ttlMillis))
function get(key):
entry = map.get(key)
if entry == null:
return null
if entry.expiryTime <= now(): # expired
map.remove(key, entry) # ATOMIC conditional remove
return null
return entry.value
Storing the absolute expiry time (now() + ttlMillis) makes the expiry check a trivial comparison against the current time, with no need to separately track insertion time. The crucial concurrency detail is the atomic conditional remove: map.remove(key, entry) removes the mapping only if key currently maps to this exact entry. If, between our map.get and our remove, another thread called put(key, freshValue, ...), then key now maps to a different entry, so our conditional remove does nothing and the fresh value is preserved. An unconditional map.remove(key) would instead clobber the fresh value - the check-then-act race. This lazy approach keeps reads correct (an expired entry is never returned) with no background machinery.
Part 2 - Active expiration
# (expiryTime, key) ordered by expiry; sweeper pops only what has actually expired
delayQueue = DelayQueue<ExpiringKey>() # ExpiringKey.getDelay() = expiryTime - now()
scheduler = ScheduledExecutorService
function put(key, value, ttlMillis):
expiry = now() + ttlMillis
map.put(key, new Entry(value, expiry))
delayQueue.put(new ExpiringKey(key, expiry))
# sweeper loop (background thread):
function sweep():
while true:
expiringKey = delayQueue.take() # blocks until an entry's delay elapses
entry = map.get(expiringKey.key)
if entry != null and entry.expiryTime <= now():
map.remove(expiringKey.key, entry) # conditional remove
The sweeper reclaims memory for entries that are never get-accessed (which lazy expiration would leave forever). The efficiency trick is the DelayQueue (a priority queue whose take() blocks until the head's delay elapses): the sweeper sleeps until the next entry actually expires, then pops exactly that entry - O(log n) per expiry - rather than periodically scanning the whole map (O(n) per sweep). Each popped key is removed with a conditional remove (so a fresh put since the entry was queued isn't clobbered). Pairing this with Part 1's lazy check gives the production-grade combination: lazy guarantees reads never see expired data; the sweeper guarantees memory is reclaimed regardless of access pattern. (A simpler ScheduledExecutorService scanning periodically also works but is O(n) per scan.)
Part 3 - The bugs
Bug A - unconditional removal (check-then-act race). map.remove(key) removes whatever is currently mapped, not specifically the expired entry we observed. Sequence: thread 1 reads an expired entry for key; thread 2 calls put(key, freshValue) (now key maps to a fresh, live entry); thread 1 calls map.remove(key) - deleting the fresh value thread 2 just inserted. Result: silent data loss, and the next get is a spurious miss. The fix is the conditional remove map.remove(key, entry), which removes only if key still maps to the expired entry we saw - so if thread 2's fresh put happened, our remove is a no-op. This is the canonical check-then-act race in a TTL cache, and the conditional (CAS-style) remove is the canonical fix.
Bug B - TTL stored as duration instead of absolute time. new Entry(value, ttlMillis) stores the duration (e.g., 5000) in the expiryTime field, but get compares expiryTime <= now(), where now() is a large absolute timestamp (e.g., milliseconds since epoch). Since 5000 <= now() is essentially always true, every entry is judged expired the instant it's read - the cache never returns anything. The fix is to store the absolute expiry: now() + ttlMillis. This is a common, easy-to-miss bug: the expiry field must be a point in time comparable to now(), not a duration. (Symmetrically, if you store duration you must also store insertion time and compare insertionTime + duration <= now() - but storing absolute expiry is simpler.)
Both bugs reinforce the essentials: atomic conditional removal (Bug A) and absolute expiry time (Bug B).
Common mistakes
Warning: Watch for: (1) Unconditional expiry removal - clobbers a concurrent fresh put; use
remove(key, entry). (2) Storing TTL as duration - compared againstnow(), everything looks expired; store absolutenow()+ttl. (3) Using a plainHashMap- not thread-safe; useConcurrentHashMap(or explicit locking). (4) Lazy-only with an unbounded keyspace - untouched expired entries leak memory; add a sweeper. (5) Sweeper scanning the whole map - O(n); use aDelayQueue/PQ ordered by expiry. (6) Non-atomic refresh-on-access - usecompute/mergefor per-key read-modify-write.
Why this exercise matters for interviews
Interview: The TTL cache is a staple concurrency design question, and this exercise mirrors the typical interview arc. Part 1 (lazy expiration) centers on the atomic conditional remove - catching and fixing the check-then-act race is the key concurrency signal. Part 2 (active expiration) tests whether you can make reclamation efficient (a
DelayQueueby expiry, not an O(n) scan) and recognize that production caches do both lazy and active. Part 3 (bugs) targets the two signature errors: unconditional removal (race) and duration-vs-absolute expiry. The throughline interviewers reward: a concurrent map with absolute-expiry entries, atomic conditional removal for expiry, lazy+active expiration for correctness+memory, and awareness that size-bounded LRU eviction is the hard extension (reach for Caffeine/Guava).