🧠 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
FileInputStream
s - 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!