Cache Me If You Can: The Art and Science of In-Memory Caching

In the fast-paced world of modern software development, speed is king, and latency is its arch-nemesis. Enter cache, the superhero that swoops in to save the day with blazing-fast responses and a smug grin. But like all heroes, cache has its flaws—and sometimes, it can even become the villain. Let’s dive into why caching is vital, how using a separate software for caching comes with trade-offs, and why building a simple in-memory cache in Java might be the perfect solution for certain use cases.


Why Cache Matters

Imagine you’re running an online bookstore. Every time someone searches for “thriller novels,” your database grinds away, querying gigabytes of data to serve up the perfect list. Now multiply that by thousands of users, and your poor database might just file for early retirement.

This is where a cache steps in. By storing frequently accessed data in memory, it allows you to serve requests in milliseconds instead of seconds. It's like having your favorite snacks within arm’s reach instead of trekking to the store each time you’re hungry.


The Case for a Separate Cache Software

Many modern applications leverage dedicated caching software like Redis or Memcached. These tools are highly optimized and offer features like clustering, persistence, and distributed caching. However, they come with their own set of trade-offs:

Cons of Using a Separate Cache Software

  1. Cost
    While Redis and Memcached themselves may be open source, the infrastructure to host and scale them isn't free. Managed services add ongoing costs, and self-hosted setups require additional servers, increasing the total cost of ownership.

  2. Maintenance Overhead
    Managing a separate caching software involves setup, configuration, monitoring, and scaling. Keeping the software updated and ensuring its availability becomes another operational responsibility.

  3. Operational Complexity
    Deploying and integrating a separate caching layer introduces more moving parts to your system. Network latency, cache synchronization, and security concerns like access control further complicate the architecture.

  4. Overkill for Simple Use Cases
    If your application is small or doesn't require features like distributed caching, a full-blown caching solution might be unnecessary and excessive.


Motivation for Building a Simple In-Memory Cache

For applications with lightweight caching needs, such as storing frequently accessed data within a single instance, a simple in-memory cache can be a cost-effective and efficient solution. Here's why:

  1. Minimal Cost
    An in-memory cache implemented directly in your application leverages existing resources like RAM, avoiding the need for additional infrastructure or services.

  2. Simpler Maintenance
    There’s no external software to monitor or manage. Everything resides within the application, simplifying operations and reducing the cognitive load.

  3. Tailored to Your Needs
    You can design your in-memory cache to fit your application’s specific requirements, such as supporting TTL-based eviction or LRU strategies.

  4. Faster Access
    With data stored directly in memory, there’s no network latency to worry about—your cache hits are as fast as they come.


Implementing a Simple In-Memory Cache in Java

If your use case justifies a lightweight approach, you can roll up your sleeves and implement a custom in-memory cache in Java. Here’s what the implementation can include:

Features to Implement

  1. TTL (Time-to-Live) Eviction
    Each cache entry should have an expiration time to ensure stale data is automatically removed.

  2. LRU (Least Recently Used) Eviction
    When the cache reaches its size limit, the least recently accessed entries are evicted to make room for new ones.

  3. Thread-Safety
    The cache should support concurrent access without compromising data integrity.

  4. Configurable Parameters
    Allow developers to configure cache size and default TTL values.


Code Placeholder

package com.hashnode.alankar.java.cache;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class TimedLRUCache<K, V> {
    private final int maxSize;
    private final long defaultTTLMillis;
    private final Map<K, CacheEntry<V>> cache;
    private final ScheduledExecutorService cleanupExecutor;

    private static class CacheEntry<V> {
        private final V value;
        private final long expirationTime;

        CacheEntry(V value, long expirationTime) {
            this.value = value;
            this.expirationTime = expirationTime;
        }

        boolean isExpired() {
            return System.currentTimeMillis() > expirationTime;
        }
    }

    public TimedLRUCache(int maxSize, long defaultTTLSeconds) {
        this.maxSize = maxSize;
        this.defaultTTLMillis = TimeUnit.SECONDS.toMillis(defaultTTLSeconds);

        // Using LinkedHashMap with access order to implement LRU
        this.cache = new LinkedHashMap<K, CacheEntry<V>>(maxSize, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, CacheEntry<V>> eldest) {
                return size() > maxSize;
            }
        };

        // Initialize cleanup scheduler
        this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor();
        startCleanupTask();
    }

    public synchronized void put(K key, V value) {
        put(key, value, defaultTTLMillis);
    }

    public synchronized void put(K key, V value, long ttlMillis) {
        long expirationTime = System.currentTimeMillis() + ttlMillis;
        cache.put(key, new CacheEntry<>(value, expirationTime));
    }

    public synchronized V get(K key) {
        CacheEntry<V> entry = cache.get(key);

        if (entry == null) {
            return null;
        }

        if (entry.isExpired()) {
            cache.remove(key);
            return null;
        }

        return entry.value;
    }

    public synchronized void remove(K key) {
        cache.remove(key);
    }

    public synchronized void clear() {
        cache.clear();
    }

    public synchronized int size() {
        return cache.size();
    }

    private void startCleanupTask() {
        cleanupExecutor.scheduleAtFixedRate(this::cleanupExpiredEntries, 
            1, 1, TimeUnit.SECONDS);
    }

    private synchronized void cleanupExpiredEntries() {
        cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
    }

    public void shutdown() {
        cleanupExecutor.shutdown();
        try {
            if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
                cleanupExecutor.shutdownNow();
            }
        } catch (InterruptedException e) {
            cleanupExecutor.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }

    // Thread-safe builder pattern
    public static class Builder<K, V> {
        private int maxSize = 100; // default size
        private long defaultTTLSeconds = 3600; // default 1 hour

        public Builder<K, V> maxSize(int maxSize) {
            this.maxSize = maxSize;
            return this;
        }

        public Builder<K, V> defaultTTL(long seconds) {
            this.defaultTTLSeconds = seconds;
            return this;
        }

        public TimedLRUCache<K, V> build() {
            return new TimedLRUCache<>(maxSize, defaultTTLSeconds);
        }
    }

    // Example usage of the cache
    public static void main(String[] args) throws InterruptedException {
        TimedLRUCache<String, String> cache = new TimedLRUCache.Builder<String, String>()
            .maxSize(3) // max entires in the cache
            .defaultTTL(5) // 5 seconds TTL
            .build();

        // Add some entries
        cache.put("key1", "value1");
        cache.put("key2", "value2");
        cache.put("key3", "value3");

        // Test LRU eviction
        System.out.println("Initial cache size: " + cache.size()); // Should be 3
        cache.put("key4", "value4"); // This should evict the least recently used entry
        System.out.println("After LRU eviction cache size: " + cache.size()); // Should still be 3

        // Test time-based expiration
        Thread.sleep(5000); // Wait for entries to expire
        System.out.println("Value for key4: " + cache.get("key4")); // Should be null due to expiration
        System.out.println("Cache size after expiration: " + cache.size()); // Should be smaller due to cleanup

        cache.shutdown();
    }
}

This implementation leverages Java’s LinkedHashMap for LRU behavior, a background task for TTL eviction, and synchronized methods for thread safety. It's lightweight, fast, and perfect for applications that need a no-frills caching solution.


Best Practices for Your Custom Cache

  1. Define Clear Use Cases
    Use an in-memory cache for lightweight needs like session management, configuration storage, or frequently accessed query results.

  2. Don’t Overcomplicate
    Stick to essential features. If your caching needs grow significantly, consider transitioning to a dedicated caching software.


The Final Verdict

Using a separate caching solution like Redis or Memcached can be powerful, but it isn’t always the best fit for every application. For small-scale needs, implementing a custom in-memory cache in Java can save you costs, reduce complexity, and provide a tailor-made solution.

But as with all things in software, balance is key. If you go the DIY route, remember: building a cache is the easy part. Maintaining and optimizing it? That’s where the real work begins. So, proceed thoughtfully—and may your cache hits always be high!

0
Subscribe to my newsletter

Read articles from Alankar Srivastava directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Alankar Srivastava
Alankar Srivastava