Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 

Repository files navigation

ttl-cache

A small, thread-safe, zero-dependency in-memory cache for Java with per-entry TTL (time-to-live) and LRU (least-recently-used) eviction.

Built as a focused utility: one clear problem, solved carefully, with deterministic tests and no frameworks. Compiles and runs with nothing but a JDK (11+).

Features

  • Per-entry TTL with a configurable default, enforced lazily on read (no background thread)
  • LRU eviction with a hard size cap, using LinkedHashMap in access-order mode for O(1) tracking
  • getOrCompute cache-aside helper that invokes the loader outside the lock, so slow loads (e.g. database calls) never block other readers
  • Statistics (hits, misses, hit rate, evictions, expirations) for logging and monitoring
  • Injectable clock (Ticker) so time-dependent behaviour is tested deterministically, without Thread.sleep()
  • Zero dependencies — the test suite is hand-rolled and runs from a main method

Usage

TtlCache<String, User> cache = TtlCache.builder()
        .maxSize(1_000)
        .defaultTtl(Duration.ofMinutes(5))
        .build();

// Basic put/get
cache.put("user:42", user);
Optional<User> u = cache.get("user:42");

// Override the TTL for a specific entry
cache.put("session:abc", session, Duration.ofSeconds(30));

// Cache-aside in one call: load on miss, cached thereafter
User loaded = cache.getOrCompute("user:99", key -> userRepository.load(key));

// Observability
System.out.println(cache.stats());
// CacheStats{hits=12, misses=3, hitRate=0.80, evictions=0, expirations=1, size=14}

Build and test (JDK only, no Maven/Gradle needed)

javac -d out $(find src -name "*.java")
java -cp out dev.ttlcache.TtlCacheTest

Expected output ends with:

13 passed, 0 failed

Design decisions

Why LinkedHashMap for LRU? With accessOrder = true, every read moves an entry to the tail, so the head is always the least recently used entry — and overriding removeEldestEntry gives eviction in O(1) with no auxiliary data structures.

Why lazy expiry instead of a cleanup thread? A background thread adds lifecycle complexity (who shuts it down?) for a utility this size. Expired entries are removed on read, and purgeExpired() is available for callers who need proactive memory bounds.

Why is the loader in getOrCompute called outside the lock? Holding the lock during a slow load would serialise every cache operation behind one database call. The trade-off is that two threads missing the same key simultaneously may both compute (last write wins) — the same choice made by many production caches.

Why an injectable Ticker? Tests that call Thread.sleep() are slow and flaky. Abstracting the clock lets the test suite advance time instantly and assert exact expiry boundaries.

Limitations (deliberate scope)

  • Not distributed; single-JVM only
  • No weak/soft references or size-by-weight eviction
  • Statistics counters are for observability, not billing-grade accuracy

License

MIT

About

A small, thread-safe, zero-dependency in-memory cache for Java with per-entry TTL (time-to-live) and LRU (least-recently-used) eviction.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages