This is like building a tiny locked pantry: the food is close, but only one person should restock and rearrange the shelves at a time.
Question: Design a thread-safe in-memory cache.
Answer: A thread-safe cache stores key/value pairs in RAM and makes sure multiple threads can read and write without corrupting data. In Java, the simplest correct design is to guard the cache with a lock and use a map that supports fast lookups, then add an eviction rule such as TTL (time to live) or LRU (least recently used). The main goals are correctness first, then performance, then memory control.
Interview-Ready Answer: I’d design it as an in-memory map protected by synchronization, because thread-safety means every get, put, and evict operation must be atomic and visible to all threads. For a simple and correct version, I’d use a lock plus a LinkedHashMap for LRU order and a TTL field for expiry, so reads stay O(1) on average and eviction is predictable. If traffic grew, I’d move to finer-grained locking or a concurrent structure to reduce contention.
Thread-safe means two things: atomicity (an operation happens as one unit, not half-finished) and visibility (when one thread writes a value, another thread can actually see the latest value). A cache breaks if one thread updates the map while another thread reads a partially updated state, or if eviction runs while reads are still using stale metadata.
value + expiry time, so the cache can answer both “is it present?” and “is it stale?” quickly.LinkedHashMap in access-order mode when you want LRU behavior, because the oldest accessed entry becomes the eviction candidate.get, lock, check the key, remove it if expired, and otherwise return the value. Because access-order is enabled, the hit also refreshes recency.put, lock, insert the item, then evict expired entries and trim the oldest item if the cache is above capacity.With one lock, you avoid race conditions, which are bugs caused by timing. No two threads can change the map at the same time, so the internal order stays valid. In Java 8+, this simple design is often good enough for small or medium caches, especially when correctness matters more than extreme throughput.
| Design | Pros | Cons | Best for |
|---|---|---|---|
| Single lock + map | Simple, correct | Lower throughput | Small to medium caches |
ConcurrentHashMap only | Fast key lookup | No built-in LRU order | Basic key/value caching |
| Concurrent map + eviction lock | Scales better | More code, easier to get wrong | High-traffic services |
| Distributed cache | Shared across JVMs | Network latency | Multi-instance systems |
Average get and put are O(1). Eviction from a linked order structure is also O(1) for one entry, but a cleanup scan is O(n), where n is the number of cached items. Space is O(n). A realistic setup might be 10,000 to 100,000 entries with a TTL of 30 seconds to 5 minutes, but once reads become very hot, a single lock can become a bottleneck. One important gotcha: do not allow null values if you want get to clearly mean “missing” versus “present but empty.” Another gotcha is cache stampede: if many threads miss at once and all call the database, the cache stops helping. The fix is a loader that stores one in-flight computation per key.
Use this design when you need fast repeated reads, limited memory, and predictable correctness. It is a great fit for reference data, feature flags, rate-limit state, or short-lived computed results. If you need cross-service sharing, persistence, or millions of ops per second, a JVM-local cache is usually not enough on its own.
Real-World Example: Imagine a checkout service in an e-commerce app. It caches product prices and tax rules so every page load does not hit the database. A thread-safe cache matters because dozens of requests may ask for the same price at once, and one background thread may refresh expired data while user threads are reading.
If the cache is not synchronized, you can get symptoms like random wrong totals, duplicate loads, or rare ConcurrentModificationException failures. In logs, you might see one thread writing a new price version while another thread still serves the old one. From the user’s view, the cart total flickers or changes at checkout. If eviction is missing, heap usage grows, garbage collection pauses get longer, and the service slowly becomes unstable under load.
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Iterator;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
// A small, correct cache is often better in interviews than a huge fancy one.
// This version is thread-safe because one lock protects the full state.
static final class ThreadSafeInMemoryCache<K, V> implements AutoCloseable {
private final long ttlMillis;
private final int maxSize;
private final ReentrantLock lock = new ReentrantLock();
// accessOrder=true makes LinkedHashMap behave like LRU: a get moves the entry to the end.
private final LinkedHashMap<K, CacheEntry<V>> map = new LinkedHashMap<>(16, 0.75f, true);
private final ScheduledExecutorService cleaner;
ThreadSafeInMemoryCache(long ttlMillis, int maxSize, long cleanupIntervalMillis) {
if (ttlMillis < 0) throw new IllegalArgumentException("ttlMillis must be >= 0");
if (maxSize <= 0) throw new IllegalArgumentException("maxSize must be > 0");
if (cleanupIntervalMillis < 0) throw new IllegalArgumentException("cleanupIntervalMillis must be >= 0");
this.ttlMillis = ttlMillis;
this.maxSize = maxSize;
if (cleanupIntervalMillis > 0) {
this.cleaner = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
private final AtomicInteger n = new AtomicInteger();
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "cache-cleaner-" + n.incrementAndGet());
t.setDaemon(true);
return t;
}
});
this.cleaner.scheduleAtFixedRate(this::safeCleanup, cleanupIntervalMillis, cleanupIntervalMillis, TimeUnit.MILLISECONDS);
} else {
this.cleaner = null;
}
}
public void put(K key, V value) {
Objects.requireNonNull(key, "key must not be null");
Objects.requireNonNull(value, "value must not be null");
long now = System.currentTimeMillis();
long expiresAt = ttlMillis == 0 ? Long.MAX_VALUE : now + ttlMillis;
lock.lock();
try {
map.put(key, new CacheEntry<>(value, expiresAt));
cleanupExpiredLocked(now); // keeps the cache from filling with dead entries
evictIfNeededLocked();
} finally {
lock.unlock();
}
}
public Optional<V> get(K key) {
Objects.requireNonNull(key, "key must not be null");
long now = System.currentTimeMillis();
lock.lock();
try {
CacheEntry<V> entry = map.get(key); // also refreshes LRU order
if (entry == null) {
return Optional.empty();
}
if (entry.isExpired(now)) {
map.remove(key); // remove stale data immediately so future reads are clean
return Optional.empty();
}
return Optional.of(entry.value);
} finally {
lock.unlock();
}
}
public Optional<V> remove(K key) {
Objects.requireNonNull(key, "key must not be null");
lock.lock();
try {
CacheEntry<V> removed = map.remove(key);
return removed == null ? Optional.empty() : Optional.of(removed.value);
} finally {
lock.unlock();
}
}
public int size() {
lock.lock();
try {
cleanupExpiredLocked(System.currentTimeMillis());
return map.size();
} finally {
lock.unlock();
}
}
public void cleanupExpired() {
lock.lock();
try {
cleanupExpiredLocked(System.currentTimeMillis());
} finally {
lock.unlock();
}
}
private void safeCleanup() {
try {
cleanupExpired();
} catch (RuntimeException ignored) {
// A background cleanup should never crash the app; failures are isolated here.
}
}
private void cleanupExpiredLocked(long now) {
Iterator<Map.Entry<K, CacheEntry<V>>> it = map.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().isExpired(now)) {
it.remove();
}
}
}
private void evictIfNeededLocked() {
while (map.size() > maxSize) {
Iterator<K> it = map.keySet().iterator();
if (it.hasNext()) {
it.next();
it.remove(); // removes the least recently used entry
}
}
}
@Override
public void close() {
if (cleaner != null) {
cleaner.shutdownNow();
}
}
private static final class CacheEntry<V> {
private final V value;
private final long expiresAt;
private CacheEntry(V value, long expiresAt) {
this.value = value;
this.expiresAt = expiresAt;
}
private boolean isExpired(long now) {
return now >= expiresAt;
}
}
}
public static void main(String[] args) throws Exception {
try (ThreadSafeInMemoryCache<String, String> cache = new ThreadSafeInMemoryCache<>(250, 3, 100)) {
cache.put("a", "A");
cache.put("b", "B");
cache.put("c", "C");
// Touching "a" makes it recently used, so "b" becomes the eviction victim.
System.out.println("a -> " + cache.get("a").orElse("<missing>"));
cache.put("d", "D");
System.out.println("b after LRU eviction -> " + cache.get("b").orElse("<evicted>"));
System.out.println("cache size now -> " + cache.size());
// Edge case: null is rejected so callers never confuse "missing" with "stored null".
try {
cache.put(null, "boom");
} catch (NullPointerException ex) {
System.out.println("null key rejected -> " + ex.getMessage());
}
Thread.sleep(300);
System.out.println("a after TTL expiry -> " + cache.get("a").orElse("<expired>"));
System.out.println("cache size after cleanup -> " + cache.size());
}
}
}
Follow-up & Tricky Questions:
FutureTask or CompletableFuture, so 100 threads do not all hit the database for the same missing item.get, put, and eviction together, then assert that no invalid states appear and that expired entries disappear.ConcurrentHashMap enough by itself? No. It helps with safe concurrent access, but it does not automatically give you LRU, TTL cleanup, or atomic multi-step operations across structures.null is allowed, it becomes ambiguous whether the key is missing or the cached value is actually null.size() stay exact under concurrency? In this locked design, yes. In a fully concurrent design, size can be approximate or more expensive to compute.Tricky / gotcha questions:
get may need the same lock as a write.HashMap with no lock. Fix: guard all shared state with synchronization or use a correct concurrent design.ConcurrentHashMap solves everything. Fix: it handles concurrent access, but eviction order and atomic load logic still need design.null values. Fix: reject null so a miss is not confused with a stored null.Think of the cache as a locked pantry with a lazy Susan: the lock keeps people from bumping into each other, the lazy Susan keeps the newest-used item near the front, and expired food gets thrown away before it smells up the room.
get/put should be O(1).null unless you have a very explicit policy.HashMap cache that supports put and get.