Spring Boot Caching: Best Practices for Memory Use

Spring Boot Caching: Best Practices for Memory Use

Caching in Spring Boot can drastically improve application performance by reducing database queries and API calls. However, it requires careful memory management to avoid crashes or inefficiencies. Here’s the essence of effective caching:

  • Why Cache? It reduces latency (e.g., database queries drop from 50–200ms to 1–5ms) and eases database/server strain.
  • How It Works: Use annotations like @Cacheable to store frequently accessed data in memory for quick retrieval.
  • Memory Management: Set limits (e.g., Time-To-Live, max size) to prevent memory exhaustion.
  • Cache Providers:
    • Caffeine: Fast, JVM-based, with eviction policies.
    • Redis: Distributed, persistent, better for scaling.
    • Ehcache/Hazelcast: Multi-tiered storage for advanced setups.
  • Best Practices:
    • Use TTL and eviction policies to manage memory.
    • Optimise cache keys to avoid collisions or inefficiencies.
    • Monitor cache performance (e.g., hit rates) using Spring Boot Actuator.

Caching boosts speed and scalability, but poor configurations (e.g., no size limits) can lead to issues like memory leaks or stale data.

How Spring Boot Caching Works

spring boot homepage

Spring Boot’s caching system acts as a bridge between your business logic and the cache storage, simplifying operations. When you call a method marked with a caching annotation, Spring sets up an AOP (Aspect-Oriented Programming) proxy to intercept the call. This proxy checks if the requested data is already in the cache. If it is, the data is returned immediately. If not, the method runs as usual, and the result is stored in the cache for future use.

The mechanism relies on two main interfaces: org.springframework.cache.Cache, which represents the actual cache storage, and org.springframework.cache.CacheManager, which handles cache instances. This setup makes it easy to switch cache providers – like moving from Caffeine to Redis – without changing your core business logic. As Antonello Zanini, a software engineer at Auth0, puts it:

“The caching abstraction allows consistent use of various caching solutions with minimal impact on the code”.

Enabling and Configuring Caching in Spring Boot

Getting started with caching in Spring Boot is straightforward. First, include the spring-boot-starter-cache dependency in your project. If you need help architecting your implementation, our Spring Boot consultancy can provide expert guidance. This brings in the spring-context-support module, which is essential for most cache providers. Next, add the @EnableCaching annotation to a configuration class. It’s a good idea to use a dedicated configuration class for this purpose, as placing the annotation on your main application class could unintentionally enable caching during tests.

Once caching is enabled, Spring Boot sets up a caching infrastructure automatically. If no specific cache provider is found in your classpath, Spring defaults to a simple implementation using a ConcurrentHashMap. To make use of caching, annotate your service methods with:

  • @Cacheable to fetch data from the cache if available.
  • @CachePut to update the cache directly.
  • @CacheEvict to remove entries from the cache.

For production environments, you can configure your preferred cache provider in the application.properties file. Use the spring.cache.type property to specify the provider and spring.cache.cache-names to define the cache names.

This setup integrates smoothly with Spring’s abstraction layer, reducing the need for repetitive cache-handling code.

Spring’s Cache Abstraction Explained

Spring’s cache abstraction simplifies the process of managing cached data. Instead of writing repetitive “check-cache-then-fetch-or-store” logic, you can simply annotate your methods, and Spring takes care of everything using proxies. By default, Spring generates cache keys using a SimpleKeyGenerator. Here’s how it works:

  • If there are no parameters, it uses SimpleKey.EMPTY.
  • For a single parameter, it uses that value directly.
  • For multiple parameters, it combines them into a SimpleKey.

However, there are a couple of limitations to keep in mind. First, caching only works for external method calls. If a method within the same class calls another cached method (known as an internal method call), the cache is bypassed because the proxy doesn’t intercept such calls. Second, cache annotations only apply to public methods when using the default proxy mode. If you annotate protected or private methods, they’ll be ignored without any warning.

Selecting a Cache Provider

spring boot cache providers compared

Spring Boot Cache Providers Comparison: Performance and Features

Picking the right cache provider is essential for managing your application’s memory efficiently. The choice largely depends on whether you’re working with a single-instance setup or a distributed system, as well as whether you need data persistence across application restarts. Here’s a breakdown of key providers and their memory management capabilities.

ConcurrentHashMap is used as a fallback by Spring Boot when no other cache provider is available. It’s fast, lightweight, and requires no external dependencies, making it a good option for testing or basic use cases. However, it has some major limitations: no eviction policies, no support for TTL (Time-to-Live), and no advanced management features. This means cached data remains in memory indefinitely unless explicitly removed, which can lead to memory issues in production environments.

Caffeine is the default in-memory cache provider for Spring Boot. It’s designed for high performance, capable of handling hundreds of millions of operations per second. Its standout feature is the TinyLFU eviction policy, which balances frequency and recency tracking to achieve high hit rates. As Max explains:

“Caffeine focuses on high throughput and an optimal eviction policy (TinyLFU) for a high hit rate”.

Caffeine also supports size-based eviction and both Time-to-Live and Time-to-Idle configurations, giving developers precise control over memory usage – all while staying within the JVM heap. This makes it an excellent choice for applications that need fast, efficient memory utilisation.

Redis is well-suited for microservices and horizontally scaled systems. It allows multiple instances to share a common cache state and includes data persistence options. Redis can process more than 100,000 operations per second per shard, with a network latency of 5–10ms. While this is slower than Caffeine’s near-instant lookups, Redis makes up for it by enabling distributed caching with consistent performance.

For those seeking a balance between in-memory speed and extended storage options, Ehcache and Hazelcast offer compelling features. Ehcache supports multi-tiered storage, including heap, off-heap, and disk, which helps offload less frequently accessed data from the JVM heap, reducing garbage collection overhead. Hazelcast, on the other hand, provides elastic scaling and built-in coherence for distributed RAM caching. Both providers are JSR-107 compliant and offer data persistence, though they require more configuration compared to Caffeine or Redis. These multi-tiered approaches are particularly useful for reducing JVM heap pressure, aligning with the broader goal of memory optimisation in application design.

Best Practices for Memory-Efficient Caching

Setting Up Cache Expiration and Eviction Policies

When it comes to managing memory effectively, setting clear expiration and eviction policies is essential. It’s easy to assume that Spring Boot will handle these automatically, but it doesn’t configure TTL (Time to Live), TTI (Time to Idle), or eviction policies by default. This means developers need to take charge.

Explicit limits are a must. As software engineer Naidukr puts it:

“Never cache forever in production”.

To avoid memory issues, always define maximum sizes and TTL values. For instance, Caffeine offers options like maximumSize to cap heap usage and expireAfterWrite to refresh data at set intervals. It’s better to use expireAfterWrite over expireAfterAccess, as the latter retains read data indefinitely, which can lead to unnecessary memory use.

If you’re using LRU (Least Recently Used) eviction, it’s an effective way to control cache growth and prevent memory leaks. For distributed systems like Redis, configure TTL policies at the provider level to ensure consistent behaviour across all instances.

Conditional caching can also help minimise memory usage. Use annotations like @Cacheable(unless = "#result == null") to prevent caching null values. The condition parameter filters based on input, while unless works on output, keeping your cache efficient.

Designing Effective Cache Keys

Poorly designed cache keys can waste memory and cause inefficiencies. By default, Spring uses all method parameters to create cache keys, which often includes unnecessary data that doesn’t affect the result’s identity.

To optimise, use SpEL (Spring Expression Language) to select only the parameters that uniquely identify your data. For example, instead of caching an entire User object, use key = "#user.id" to focus on the unique identifier. In multi-tenant systems, combine fields like key = "#tenantId + '_' + #userId" to ensure uniqueness without overloading the cache.

A fintech startup reportedly improved response times by 40% by refining cache keys and implementing conditional caching. As Naidukr warns:

“Poor key design leads to cache misses, wasted memory, and subtle bugs”.

For more complex scenarios that SpEL can’t handle, consider implementing the KeyGenerator interface. This allows you to create a centralised and memory-efficient key strategy. These adjustments pave the way for avoiding common caching mistakes.

Common Caching Mistakes to Avoid

Even with well-configured caches and optimised keys, certain pitfalls can undermine your efforts to use memory efficiently.

One common issue is self-invocation. If you call a @Cacheable method from another method within the same class, Spring’s proxy mechanism is bypassed, so the cache isn’t checked or updated.

Over-caching is another trap. Storing unnecessary or volatile data wastes memory and can lead to stale results. As Naidukr advises:

“Cache reads, not writes”.

Avoid caching write-heavy workloads, highly dynamic data, or information requiring strict consistency, such as account balances. Proper TTL settings can reduce backend hits by up to 70% without overloading the cache.

Finally, neglecting to set size limits or TTL values can cause caches to grow unchecked, potentially leading to OutOfMemoryError. For Redis users, avoid default Java serialisation – it’s slow and less compatible. Instead, use GenericJackson2JsonRedisSerializer for faster, more efficient JSON serialisation. Adding sync=true to @Cacheable can also prevent cache stampedes, ensuring smoother performance.

Distributed vs Local Caching

Choosing between distributed and local caching plays a key role in managing memory and reducing latency in your applications. Let’s break down when each approach makes the most sense.

When to Use Distributed Caching

Distributed caching is ideal for multi-instance Spring Boot applications. Tools like Redis or Memcached store data externally, making it accessible to all nodes in your system. This ensures that any updates to cached data are immediately reflected across the entire cluster, which is critical for maintaining consistency in scaled environments.

While distributed caches introduce a small amount of network latency, they are still significantly faster than direct database access, which can take 50–100ms. As software engineer Naidukr explains, caching is “one of the highest‑ROI performance optimisations you can add to a Spring Boot application”.

Another advantage is scalability. If your dataset outgrows the memory of a single server, you can expand the cache cluster without altering your application code. Additionally, distributed caches like Redis provide persistence to disk, preventing cold starts and retaining cached data through application restarts or deployments. This makes distributed caching particularly well-suited for microservices or cloud-native architectures where data consistency and survivability are essential.

However, for single-instance setups or situations demanding ultra-low latency, local caching might be a better fit.

Local Caching: Advantages and Limitations

Local caching shines when speed is the top priority. By storing data directly in the JVM heap, local caches deliver incredibly low latency, measured in nanoseconds or microseconds. Tools like Caffeine are perfect for single-instance applications or scenarios where the data is read-heavy and rarely changes.

That said, local caching has its challenges in multi-node environments. Each instance maintains its own cache, which can lead to stale data when one node updates information that others still hold in memory. Additionally, local caches are limited by the available memory and can put pressure on the JVM’s garbage collector. Without proper configuration, excessive caching could lead to an OutOfMemoryError. To avoid this, always set a maximumSize or implement an eviction policy.

For a balanced approach, you can combine both strategies. Use local caching for frequently accessed “hot” data and a distributed cache like Redis for shared global storage. In clustered setups, tools like Redis Pub/Sub can broadcast eviction events to synchronise local caches across nodes.

Monitoring Cache Performance

Once caching is implemented, it’s essential to measure its impact. Without proper monitoring, you won’t know whether your caching strategy is making a difference.

Tracking Cache Metrics

Spring Boot Actuator and Micrometer come equipped with tools to monitor all cache instances set up during startup. One key metric to watch is the cache hit rate – the percentage of requests served directly from the cache rather than the original data source. A high hit rate suggests your caching is working well, while a low rate might indicate issues like caching the wrong data or having an undersized cache.

To start collecting metrics, enable statistics recording for cache providers like Caffeine or Guava by calling .recordStats() when constructing the cache. With this enabled, Spring Boot Actuator provides endpoints such as:

  • /actuator/metrics/cache.gets: Tracks cache hits and misses.
  • /actuator/metrics/cache.evictions: Indicates whether the cache is running out of space.

Micrometer further enhances monitoring by using function-tracking counters to log hits and misses over time. This allows you to set alerts, such as a notification if the miss rate exceeds 10% in a finely tuned environment. As software engineer Naidukr aptly puts it:

“You can’t improve what you don’t measure”.

For real-time visualisation, tools like Grafana, paired with Prometheus, can display dashboards showing metrics like hit counts, miss rates, and memory usage. In Prometheus, you can calculate the hit ratio with this formula:

sum(rate(cache_gets_total{result="hit"}[1m])) / sum(rate(cache_gets_total[1m])) 

This tracks cache effectiveness in one-minute intervals. These metrics provide the groundwork for evaluating how much caching is improving performance.

Validating Cache Effectiveness

Once you’ve collected metrics, the next step is validating your caching strategy by comparing retrieval speeds between the cache and the original data source. Monitoring ensures your cache is saving both time and resources.

A simple way to validate is by measuring latency: compare the time it takes to fetch data from the cache versus retrieving it from the original source. Observability platforms like Digma can also help by identifying “cache misses” and potential “caching candidates” through runtime analysis, highlighting inefficient query patterns. A well-implemented cache will reduce the number of database queries for cached entities.

One issue monitoring can uncover is self-invocation. If your hit rate is stuck at 0% despite correct settings, check whether cached methods are being called internally within the same class. This bypasses Spring’s proxy-based caching entirely. To avoid this, ensure cached methods are invoked from different beans to maintain proper functionality.

Conclusion

Caching in Spring Boot improves performance and optimises memory by storing only the most relevant data for an appropriate amount of time.

Key Takeaways

Here’s a quick recap of strategies to fine-tune cache performance and memory usage.

Spring’s cache abstraction offers a flexible framework with annotations like @Cacheable, @CachePut, and @CacheEvict. These allow you to integrate caching seamlessly without locking your code to a specific provider. For instance, you can start with Caffeine for local caching and later scale up to Redis as your application grows.

To prevent memory issues, enforce strict TTL (time-to-live) settings and limit cache size. Without these, caches can consume excessive memory. Eviction policies like Least Recently Used (LRU) or Least Frequently Used (LFU) help ensure that only the most useful data remains accessible. As software engineer Naidukr wisely notes:

“Caching is easy. Invalidation is hard”.

Your design choices directly influence cache efficiency. For complex scenarios, use custom SpEL keys to avoid key collisions. Pair this with conditional caching – leveraging the condition and unless attributes – to filter out null values or irrelevant data that could waste resources. Additionally, in multi-threaded environments, setting sync = true can prevent cache stampedes, where multiple threads attempt to compute the same value concurrently.

Monitoring is essential for validating your caching approach. Tools like Spring Boot Actuator and Micrometer can help track metrics such as hit ratios, eviction rates, and memory usage. For example, a hit ratio below 80% might indicate poor key design or inadequate cache size. Proper instrumentation can reduce redundant data fetching by up to 23%, improve cache hit ratios, and stabilise response times.

FAQs

How do I choose between Caffeine and Redis?

Caffeine works great for quick, in-memory caching within a single application instance. Its low latency and straightforward design make it a solid choice when speed is key. On the flip side, Redis shines in distributed setups, allowing multiple nodes to share a cache while also offering scalability and persistence. If your needs fall somewhere in between, a multi-level cache might be the answer – using Caffeine for fast local access and Redis for maintaining consistency across a distributed system.

What TTL and max size should I start with?

When setting a TTL (Time To Live) for your cache, align it with how fresh your application’s data needs to be. This could range from just a few seconds to several minutes, depending on how often the data changes and how quickly it becomes outdated.

Similarly, determine the maximum cache size based on the memory you have available and how much cache your application will likely use. The exact figures will vary depending on your app’s specific needs and the setup of your chosen cache provider. Balancing these settings is key to ensuring optimal performance without overloading resources.

Why does @Cacheable not work on internal calls?

@Cacheable doesn’t function for internal calls within the same class due to how Spring’s proxy-based caching mechanism operates. Essentially, Spring creates a proxy object to manage caching, but this proxy only intercepts external method calls. When a method within the same class calls another method annotated with @Cacheable, it bypasses the proxy entirely, and caching doesn’t occur.

To work around this, you can refactor the method into a separate Spring-managed bean. By doing so, the proxy will be applied correctly, ensuring that caching behaviour is triggered as expected.

 

David Brown founded Tucanoo Solutions in 2011 and has written software in a professional capacity for over 30 years, back to the Java/J2EE days of Sun Microsystems. He builds cloud and web applications for clients in legal tech, government and beyond, including several years consulting to the United Nations on customs and transport systems. He writes these tutorials himself, with the full code on GitHub.

Leave a Reply

Your email address will not be published. Required fields are marked *