androidinterview.com

Android System Design Interview Questions

Design a caching library.

Tier: CommonDifficulty: Hard

The design is a Cache<K, V> with a memory tier in front of an optional disk tier. Entries are evicted by bytes rather than by count, and expiry is checked lazily on read. This is the general-purpose caching problem, a put and get store for arbitrary objects that any part of the app can use. The question is really about layering and eviction rather than any single clever trick.

What I'd clarify first

  • Are we caching arbitrary objects in memory only, or does this need a disk tier for persistence across process death.
  • Do entries need a TTL on top of size-based eviction, or is pure LRU enough.
  • Does invalidation need to work by exact key only, or does the caller need to clear a whole group at once, like every cached entry for a logged-out user.

The shape of the API

interface Cache<K, V> {
    fun get(key: K): V?
    fun put(key: K, value: V, ttl: Duration? = null)
    fun remove(key: K)
    fun clear()
}

Generic over key and value, so the same library backs a network response cache, a computed-result cache and a small object cache without three separate implementations.

Core components

  • A memory tier, an LRU sized by a caller-supplied sizeOf function rather than by entry count.
  • An optional disk tier, checked on a memory miss, for anything that must survive process death.
  • A TTL, stored beside each entry in both tiers and checked on read.
  • A tag index, a second map from a tag to the keys under it, for group invalidation.

Counting entries treats a 10-byte string and a 10-megabyte object as one slot each, which is wrong, so the budget is bytes. The disk tier is optional because an in-memory cache of computed values for the current session does not need one. An expired hit is treated as a miss and evicted lazily, which avoids a background thread whose only job is expiring entries nobody has asked for. The tag index is what lets clearTag("user:42") drop every entry for that user without the caller tracking and passing every key.

How a request flows

get() checks memory first. On a hit it checks the TTL before returning, and an expired entry is evicted and treated as a miss. On a full miss with a disk tier present, disk is checked next. The disk tier hands back the value together with its expiry, so an entry that expired while it sat on disk is deleted rather than promoted. A live disk hit is promoted into memory with whatever life it has left, so a value read from disk once is fast on the next read. A true miss returns null, and the caller fetches and put()s the real value. This library never fetches, it only stores.

The tiers behind one surfaceClasses Cache, LayeredCache, MemoryCache, DiskTier. LayeredCache implements Cache. MemoryCache implements Cache. LayeredCache aggregates MemoryCache. LayeredCache aggregates 0..1 DiskTier.
The tiers behind one surface, a UML class diagram of Cache, LayeredCache, MemoryCache, DiskTier
Both tiers answer to the same surface, which is what lets the layered cache hold one of them and still be one itself. Memory is asked first and disk only on a miss. The expiry travels with the value in both directions, so a value read off disk is promoted with the life it has left and never resurrected once it has none.

Three files carry the design. The generic surface, the memory tier that owns the byte budget and the lazy expiry check, and the layered cache that promotes a disk hit and holds the tag index. The memory tier is the same access order LinkedHashMap as the LRU cache answer, with two things added. Each entry records its byte size at insert, so the counter cannot drift if sizeOf is not stable, and each entry carries the expiry the read path checks.

Java

com.androidinterview.cachinglib.Cache.java

package com.androidinterview.cachinglib;

import java.time.Duration;

// One generic surface, so a network response cache, a computed value cache and
// a small object cache are the same library rather than three of them. A null
// ttl means the entry lives until it is evicted for size.
//
// Notice what is missing. There is no load or fetch method. This library
// stores, it never goes and gets anything, and keeping that line clean is what
// stops it from growing into a repository.
public interface Cache<K, V> {

    V get(K key);

    void put(K key, V value, Duration ttl);

    void remove(K key);

    void clear();
}

com.androidinterview.cachinglib.LayeredCache.java

package com.androidinterview.cachinglib;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

// Memory in front of an optional disk tier, plus the one thing a plain key
// value store cannot do, dropping a whole group of entries at once.
public final class LayeredCache<K, V> implements Cache<K, V> {

    // The disk tier is a port, not a class. This library never picks a file
    // format, and an app on Android hands it a DiskLruCache. An in session
    // cache of computed values passes null and stays memory only.
    //
    // The expiry crosses the disk hop with the value, in both directions. A
    // tier that only stored the value would resurrect an expired entry on the
    // next read and promote it into memory with no expiry at all.
    public interface DiskTier<K, V> {
        record Stored<V>(V value, Instant expiresAt) {
        }

        Stored<V> read(K key);

        void write(K key, V value, Instant expiresAt);

        void delete(K key);

        void clear();
    }

    private final MemoryCache<K, V> memory;
    private final DiskTier<K, V> disk;
    private final Clock clock;
    // Tag to the keys filed under it, so a logout drops every entry belonging
    // to that user without the caller having tracked what it stored. Both the
    // map and each set are concurrent, because a logout calling clear runs
    // against a background put that is still filing keys under a tag.
    private final Map<String, Set<K>> tagged = new ConcurrentHashMap<>();

    public LayeredCache(MemoryCache<K, V> memory, DiskTier<K, V> disk) {
        this(memory, disk, Clock.systemUTC());
    }

    public LayeredCache(MemoryCache<K, V> memory, DiskTier<K, V> disk, Clock clock) {
        this.memory = memory;
        this.disk = disk;
        this.clock = clock;
    }

    @Override
    public V get(K key) {
        V hot = memory.get(key);
        if (hot != null || disk == null) {
            return hot;
        }
        DiskTier.Stored<V> cold = disk.read(key);
        if (cold == null) {
            return null;
        }
        Instant now = Instant.now(clock);
        if (cold.expiresAt() != null && now.isAfter(cold.expiresAt())) {
            // Expired while it sat on disk. Delete rather than promote, or the
            // entry would come back from the dead with every process start.
            disk.delete(key);
            return null;
        }
        // Promote, so a value read off disk once is never read off disk twice,
        // and promote with whatever life the entry has left.
        Duration remaining = cold.expiresAt() == null ? null : Duration.between(now, cold.expiresAt());
        memory.put(key, cold.value(), remaining);
        return cold.value();
    }

    @Override
    public void put(K key, V value, Duration ttl) {
        memory.put(key, value, ttl);
        if (disk != null) {
            disk.write(key, value, ttl == null ? null : Instant.now(clock).plus(ttl));
        }
    }

    public void put(K key, V value, Duration ttl, String tag) {
        put(key, value, ttl);
        tagged.computeIfAbsent(tag, unused -> ConcurrentHashMap.newKeySet()).add(key);
    }

    public void clearTag(String tag) {
        Set<K> keys = tagged.remove(tag);
        if (keys != null) {
            keys.forEach(this::remove);
        }
    }

    @Override
    public void remove(K key) {
        memory.remove(key);
        if (disk != null) {
            disk.delete(key);
        }
    }

    @Override
    public void clear() {
        memory.clear();
        if (disk != null) {
            disk.clear();
        }
        tagged.clear();
    }
}

com.androidinterview.cachinglib.MemoryCache.java

package com.androidinterview.cachinglib;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.ToIntFunction;

// The memory tier, and two decisions live in it.
//
// The budget is bytes rather than entries, because only a caller supplied
// sizeOf knows what a value actually costs. Counting slots treats a ten byte
// string and a ten megabyte bitmap as equals.
//
// Expiry is lazy. An entry that timed out is dropped when something reads it,
// so the library never owns a background thread whose whole job is deleting
// entries nobody has asked for.
public final class MemoryCache<K, V> implements Cache<K, V> {

    private record Entry<V>(V value, int bytes, Instant expiresAt) {
        boolean isExpired(Instant now) {
            return expiresAt != null && now.isAfter(expiresAt);
        }
    }

    private final long maxBytes;
    private final ToIntFunction<V> sizeOf;
    // The clock is injected so a test can expire an entry without sleeping.
    private final Clock clock;
    // The same access order LinkedHashMap as the LRU cache answer, with two
    // things added for this library. The entry records its byte size at
    // insert, so the counter cannot drift if sizeOf is not stable, and it
    // carries the expiry that get checks lazily.
    private final LinkedHashMap<K, Entry<V>> entries = new LinkedHashMap<>(16, 0.75f, true);
    private long bytes;

    public MemoryCache(long maxBytes, ToIntFunction<V> sizeOf) {
        this(maxBytes, sizeOf, Clock.systemUTC());
    }

    public MemoryCache(long maxBytes, ToIntFunction<V> sizeOf, Clock clock) {
        this.maxBytes = maxBytes;
        this.sizeOf = sizeOf;
        this.clock = clock;
    }

    @Override
    public synchronized V get(K key) {
        Entry<V> entry = entries.get(key);
        if (entry == null) {
            return null;
        }
        if (entry.isExpired(Instant.now(clock))) {
            remove(key);
            return null;
        }
        return entry.value();
    }

    // A value larger than the whole budget is inserted and evicted by the same
    // call, so put stores nothing and says nothing. Rejecting it up front is a
    // fair alternative. This version keeps put total.
    @Override
    public synchronized void put(K key, V value, Duration ttl) {
        int size = sizeOf.applyAsInt(value);
        Instant expiresAt = ttl == null ? null : Instant.now(clock).plus(ttl);
        Entry<V> previous = entries.put(key, new Entry<>(value, size, expiresAt));
        if (previous != null) {
            bytes -= previous.bytes();
        }
        bytes += size;
        trimTo(maxBytes);
    }

    @Override
    public synchronized void remove(K key) {
        Entry<V> removed = entries.remove(key);
        if (removed != null) {
            bytes -= removed.bytes();
        }
    }

    @Override
    public synchronized void clear() {
        entries.clear();
        bytes = 0;
    }

    // onTrimMemory hands this a smaller budget than the configured one, which
    // is the only reason the budget is a parameter and not the field.
    public synchronized void trimTo(long budget) {
        Iterator<Map.Entry<K, Entry<V>>> oldestFirst = entries.entrySet().iterator();
        while (bytes > budget && oldestFirst.hasNext()) {
            bytes -= oldestFirst.next().getValue().bytes();
            oldestFirst.remove();
        }
    }
}

Kotlin

com.androidinterview.cachinglib.Cache.kt

package com.androidinterview.cachinglib

import kotlin.time.Duration

// One generic surface, so a network response cache, a computed value cache and
// a small object cache are the same library rather than three of them.
//
// Notice what is missing. There is no load or fetch method. This library
// stores, it never goes and gets anything, and keeping that line clean is what
// stops it from growing into a repository.
interface Cache<K : Any, V : Any> {

    operator fun get(key: K): V?

    fun put(key: K, value: V, ttl: Duration? = null)

    fun remove(key: K)

    fun clear()
}

com.androidinterview.cachinglib.LayeredCache.kt

package com.androidinterview.cachinglib

import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds

// The disk tier is a port, not a class. This library never picks a file
// format, and an app on Android hands it a DiskLruCache. An in session cache
// of computed values passes nothing and stays memory only.
//
// The expiry crosses the disk hop with the value, in both directions. A tier
// that only stored the value would resurrect an expired entry on the next read
// and promote it into memory with no expiry at all.
interface DiskTier<K : Any, V : Any> {
    data class Stored<V>(val value: V, val expiresAtMillis: Long?)

    fun read(key: K): Stored<V>?
    fun write(key: K, value: V, expiresAtMillis: Long?)
    fun delete(key: K)
    fun clear()
}

// Memory in front of an optional disk tier, plus the one thing a plain key
// value store cannot do, dropping a whole group of entries at once. The tag
// index is a second map from a tag to the keys filed under it, so a logout
// clears every entry belonging to that user without the caller having tracked
// what it stored. Both the map and each set are concurrent, because a logout
// calling clear runs against a background put still filing keys under a tag.
class LayeredCache<K : Any, V : Any>(
    private val memory: MemoryCache<K, V>,
    private val disk: DiskTier<K, V>? = null,
    private val now: () -> Long = System::currentTimeMillis,
) : Cache<K, V> {

    private val tagged = ConcurrentHashMap<String, MutableSet<K>>()

    override fun get(key: K): V? {
        memory[key]?.let { return it }
        val cold = disk?.read(key) ?: return null
        val expiresAt = cold.expiresAtMillis
        if (expiresAt != null && now() > expiresAt) {
            // Expired while it sat on disk. Delete rather than promote, or the
            // entry would come back from the dead with every process start.
            disk.delete(key)
            return null
        }
        // Promote, so a value read off disk once is never read off disk twice,
        // and promote with whatever life the entry has left.
        memory.put(key, cold.value, expiresAt?.let { (it - now()).milliseconds })
        return cold.value
    }

    override fun put(key: K, value: V, ttl: Duration?) {
        memory.put(key, value, ttl)
        disk?.write(key, value, ttl?.let { now() + it.inWholeMilliseconds })
    }

    fun put(key: K, value: V, ttl: Duration?, tag: String) {
        put(key, value, ttl)
        tagged.getOrPut(tag) { ConcurrentHashMap.newKeySet() } += key
    }

    fun clearTag(tag: String) {
        tagged.remove(tag)?.forEach(::remove)
    }

    override fun remove(key: K) {
        memory.remove(key)
        disk?.delete(key)
    }

    override fun clear() {
        memory.clear()
        disk?.clear()
        tagged.clear()
    }
}

com.androidinterview.cachinglib.MemoryCache.kt

package com.androidinterview.cachinglib

import kotlin.time.Duration

// The memory tier, and two decisions live in it.
//
// The budget is bytes rather than entries, because only a caller supplied
// sizeOf knows what a value costs. Counting slots treats a ten byte string and
// a ten megabyte bitmap as equals.
//
// Expiry is lazy. An entry that timed out is dropped when something reads it,
// so the library never owns a thread whose whole job is deleting entries
// nobody has asked for. The clock is injected so a test can expire an entry
// without sleeping.
class MemoryCache<K : Any, V : Any>(
    private val maxBytes: Long,
    private val now: () -> Long = System::currentTimeMillis,
    private val sizeOf: (V) -> Int,
) : Cache<K, V> {

    private class Entry<V>(val value: V, val bytes: Int, val expiresAt: Long?)

    // The same access order LinkedHashMap as the LRU cache answer, with two
    // things added for this library. The entry records its byte size at
    // insert, so the counter cannot drift if sizeOf is not stable, and it
    // carries the expiry that get checks lazily.
    private val entries = LinkedHashMap<K, Entry<V>>(16, 0.75f, true)
    private var bytes = 0L

    @Synchronized
    override fun get(key: K): V? {
        val entry = entries[key] ?: return null
        val expiresAt = entry.expiresAt
        if (expiresAt != null && now() > expiresAt) {
            remove(key)
            return null
        }
        return entry.value
    }

    // A value larger than the whole budget is inserted and evicted by the same
    // call, so put stores nothing and says nothing. Rejecting it up front is a
    // fair alternative. This version keeps put total.
    @Synchronized
    override fun put(key: K, value: V, ttl: Duration?) {
        val size = sizeOf(value)
        val expiresAt = ttl?.let { now() + it.inWholeMilliseconds }
        entries.put(key, Entry(value, size, expiresAt))?.let { bytes -= it.bytes }
        bytes += size
        trimTo(maxBytes)
    }

    @Synchronized
    override fun remove(key: K) {
        entries.remove(key)?.let { bytes -= it.bytes }
    }

    @Synchronized
    override fun clear() {
        entries.clear()
        bytes = 0
    }

    // onTrimMemory hands this a smaller budget than the configured one, which
    // is the only reason the budget is a parameter and not the field.
    @Synchronized
    fun trimTo(budget: Long) {
        val oldestFirst = entries.entries.iterator()
        while (bytes > budget && oldestFirst.hasNext()) {
            bytes -= oldestFirst.next().value.bytes
            oldestFirst.remove()
        }
    }
}

Tradeoffs I'd call out

  • Generic library vs specialized caches. A single generic cache is less code than three specialized ones, but it cannot make the assumptions a specialized cache can. An image cache knows it can downsample before storing, and a generic Cache<K, V> has no idea what V is. The usual answer is this generic layer as the mechanism, with specialized wrappers like an image loader built on top of it rather than reimplementing eviction.
  • Lazy TTL expiry vs a background sweep. Checking expiry on read is cheap and needs no extra thread, but a rarely read expired entry sits in memory until something happens to read it. A periodic sweep reclaims that space proactively, at the cost of running on a schedule whether or not the cache is under pressure.
  • Thread safety cost. A cache touched from several threads needs the map and the size accounting guarded together, and one lock per operation is simple and correct. The tag index is a concurrent map for the same reason, because a logout calling clear() runs against background puts that are still filing keys. A lock-free structure is faster under contention and much harder to get right, so it is worth it only once profiling shows the lock is the bottleneck.

What breaks at scale

The most common real bug in a library like this is a sizeOf function that lies. Undercounting a large object means the cache silently uses far more memory than its limit, and that shows up as an OutOfMemoryError somewhere completely unrelated to the cache. Under real memory pressure the library should also hook ComponentCallbacks2.onTrimMemory() and shrink or clear itself. A cache that only evicts on its own writes never learns that the rest of the app is starving for memory right now.

Watch