🧠 Java Memory Optimization with Guava Cache: A Real-World Guide

Java is a powerful language, but if you're not careful, it can also be a memory hog. In this post, I’ll walk you through a real-world scenario where a Java service was running into memory issues — and how we solved it using Guava’s Cache.

TL;DR: Use bounded caches, close your streams, and profile before you optimize.

🚨 The Problem: OutOfMemoryError in Production

We had a Spring Boot service that:

  • Accepted PDF uploads
  • Parsed file content
  • Cached parsed data for reuse

Under heavy load, the app began throwing this:

java.lang.OutOfMemoryError: Java heap space

Let’s dive into what went wrong and how we fixed it.


🔬 Step 1: Profile Before You Panic

We used tools like:

  • VisualVM
  • Eclipse Memory Analyzer (MAT)

These revealed:

  • Many unclosed FileInputStreams
  • A massive HashMap
  • Large byte[] objects retained indefinitely

🔍 The Code That Broke Things

Map<String, byte[]> documentCache = new HashMap<>();

public byte[] processFile(String fileName) throws IOException {
    if (documentCache.containsKey(fileName)) {
        return documentCache.get(fileName);
    }

    File file = new File("/docs/" + fileName);
    FileInputStream fis = new FileInputStream(file); // 🚨 Leaking stream
    byte[] data = fis.readAllBytes();               // 🚨 No close()
    documentCache.put(fileName, data);              // 🚨 Unbounded map
    return data;
}

What’s wrong?

  • No stream cleanup
  • No eviction policy in the cache
  • Large byte arrays living forever

✅ The Solution: Guava Cache + Stream Safety

🧪 Step 1: Close Your Streams!

try (FileInputStream fis = new FileInputStream(file)) {
    byte[] data = fis.readAllBytes();
    // ...
}

Using try-with-resources ensures your streams are closed properly, avoiding file descriptor leaks.


🧪 Step 2: Add a Bounded Cache

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;

Cache<String, byte[]> documentCache = CacheBuilder.newBuilder()
    .maximumSize(100) // Max 100 files cached
    .expireAfterWrite(10, TimeUnit.MINUTES) // Evict after 10 mins
    .build();

This creates a smart cache:

  • 🔄 Automatically evicts unused entries
  • 🧠 Prevents memory bloat
  • ⚡️ Still fast (in-memory)

🧪 Step 3: Use It Safely

public byte[] processFile(String fileName) throws IOException {
    byte[] cached = documentCache.getIfPresent(fileName);
    if (cached != null) return cached;

    File file = new File("/docs/" + fileName);
    try (FileInputStream fis = new FileInputStream(file)) {
        byte[] data = fis.readAllBytes();
        documentCache.put(fileName, data);
        return data;
    }
}

Now your code:

  • ✅ Frees up memory
  • ✅ Avoids leaks
  • ✅ Stays performant

🔧 JVM Tuning (Bonus)

We also added these JVM flags in Docker:

-Xms512m -Xmx1024m -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError

These help manage memory pressure and auto-restart the service on OOM.


📈 Results

Metric Before After
Memory Usage ~1.4GB ~850MB
Crashes/Day 3–4 0
GC Pause Time High Low

🧠 Key Takeaways

  • Profile first, don’t blindly optimize.
  • Always close your I/O resources.
  • Use Guava’s Cache to avoid writing your own LRU logic.
  • Tune your JVM based on the workload.

🙌 What’s Your Java Memory War Story?

Have you ever battled memory leaks or slow GCs? Share your experience in the comments!

If you found this useful, follow me for more real-world Java, Spring Boot, and performance engineering tips!