The one idea
Most “the service ran out of memory” incidents are not leaks. They are code that holds a whole thing in memory when it only ever needed a window onto it — a whole file, a whole archive, a whole result set. Streaming is the fix, and it is usually a two-line change.
What lives on the heap
The heap is where Java objects go:
- entities and DTOs
List,Map,SetStringbyte[]- file contents you loaded
- large JSON/XML object graphs
- caches
When the JVM cannot fit an object and the garbage collector cannot free enough:
java.lang.OutOfMemoryError: Java heap spaceHigh memory use is not automatically a leak. The application may simply be holding too much legitimate data at once. A leak is memory that is no longer needed but stays reachable. Telling the two apart is what a heap dump is for — and they have completely different fixes.
Files
The trap
byte[] data = Files.readAllBytes(path);A 500 MB file is now a 500 MB byte[] on the heap. Under concurrency it multiplies:
User 1 → 500 MB
User 2 → 500 MB
User 3 → 500 MBThree users just needed 1.5 GB of heap for one endpoint. This is why load tests find these bugs and local testing does not — alone, it always works.
Usual suspects: file uploads, downloads, ZIP exports, PDF generation, Excel generation, attachments, files from external storage, BLOBs from the database.
Files.readAllBytesis also bounded by the JVM’s maximum array size, so a file over roughly 2 GB fails withOutOfMemoryError: Requested array size exceeds VM limitno matter how much heap you give it.
Streaming instead
try (InputStream in = Files.newInputStream(path)) {
// read piece by piece
}An InputStream is conceptually:
a source I can read bytes from, one chunk at a time
so the whole file never has to be resident at once.
I/O streams are not the Stream API
Two unrelated things that share a word:
// Stream API — processing objects in a collection
users.stream().filter(...).map(...).toList();// I/O streams — moving bytes
file → InputStream → OutputStream → HTTP clienttransferTo
Since Java 9:
try (
InputStream in = Files.newInputStream(source);
OutputStream out = Files.newOutputStream(target)
) {
in.transferTo(out);
}InputStream
↓
small buffer
↓
OutputStreamNo large intermediate byte[].
ZIP files can be streamed too
Buffering the whole archive in memory:
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(buffer)) {
for (Path file : files) {
zip.putNextEntry(new ZipEntry(file.getFileName().toString()));
zip.write(Files.readAllBytes(file)); // each file on the heap
zip.closeEntry();
}
}
byte[] completeZip = buffer.toByteArray(); // and the whole archive as wellStreaming it straight to the response:
try (ZipOutputStream zip = new ZipOutputStream(response.getOutputStream())) {
for (Path file : files) {
zip.putNextEntry(new ZipEntry(file.getFileName().toString()));
try (InputStream in = Files.newInputStream(file)) {
in.transferTo(zip);
}
zip.closeEntry();
}
}storage / file
↓
InputStream
↓
ZipOutputStream
↓
HTTP response
↓
clientKeep the chain unbroken. A single byte[] in the middle undoes everything around it:
byte[] file = storageService.loadFile(); // breaks the chain
zip.write(file);InputStream file = storageService.openStream(); // keeps it
file.transferTo(zip);Three things to know before streaming into a response:
- You cannot set
Content-Lengthup front, so it goes out chunked and the client gets no progress bar. - Once the first bytes are flushed the response is committed — you can no longer turn a later failure into a 500. Decide up front what a mid-stream error should look like; usually truncate and log loudly.
- A slow client holds the request thread for the entire download. Streaming trades heap for occupied threads, so check your pool.
Warning signs in file code
byte[]
Files.readAllBytes(...)
InputStream.readAllBytes()
ByteArrayOutputStream
getBytes()None of these are wrong by default. For potentially large files, the question is:
does the entire file really have to be in memory at the same time?
MultipartFile.getBytes() is the same trap on the upload side. Spring already spills large uploads to a temp file above a threshold — call getInputStream() and let it.
Large database result sets
List<Verfahren> verfahren = repository.findAll();Every row becomes an entity on the heap.
Pagination helps
Pageable pageable = PageRequest.of(0, 50);
Page<Verfahren> page = repository.findAll(pageable);But only if the pagination actually reaches the database. Loading everything and paging in memory is the worst of both worlds, and it looks fine in the code review.
Pagination alone is not enough
50 root entities are not 50 Java objects:
Verfahren
├── Personen
├── Dokumente
├── Buchungen
├── Teilnehmer
└── further relationsWith EAGER associations, 50 Verfahren can become thousands of objects. Worth checking:
- EAGER associations
- large entity graphs
- fetch joins
- N+1 queries
- large collections
- LOB/BLOB fields
Pagination bounds the number of root records, not the size of the loaded entity graph.
For search results a slim DTO or projection usually beats a full entity — three columns instead of an object tree. For genuinely large exports, stream the result set instead of paging it: a JPA Stream<T> with a fetch-size hint, kept inside the transaction and closed properly.
JVM flags: -X, -XX, -D
-X…
Extended launcher options.
-Xms512m # initial heap size
-Xmx3g # maximum heap size-XX:…
More detailed HotSpot options.
-XX:MaxRAMPercentage=75With 4 GiB of available RAM that is roughly 3 GiB of maximum heap, unless something like -Xmx sets the bound instead.
Boolean options use + and -:
-XX:+HeapDumpOnOutOfMemoryError # on
-XX:-HeapDumpOnOutOfMemoryError # offDo not read 75 as a recommended default. Non-heap overhead is roughly constant rather than proportional, so a percentage that is safe at 8 GiB will get a 512 Mi container killed. Sizing table in OOMKilled without Java OOM.
Diagnosing an OOM
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/some/pathOn a heap OOM the JVM writes a dump, which you can open in Eclipse MAT. What to look at: which classes retain the most, byte[], entity instances, large collections, caches, the dominator tree, retained size.
Two caveats: the dump path has to outlive the container, and this only fires on a real OutOfMemoryError — never when the process is SIGKILLed.
-D… — Java system properties
-Dmy.application.mode=prodString mode = System.getProperty("my.application.mode");So -D sets Java system properties, which is a different mechanism from heap tuning with -X or -XX.
System properties vs environment variables
java -Dmy.value=hello -jar app.jar → System.getProperty("my.value")
MY_VALUE=hello → System.getenv("MY_VALUE")Technically unrelated mechanisms. Spring Boot merges both into its own configuration system, which is why they feel interchangeable there — outside Spring, they are not.
Overview
| Kind | Example | Purpose |
|---|---|---|
| plain | -jar | Java launcher |
-X... | -Xmx3g | extended JVM setting |
-XX:... | -XX:+UseG1GC | HotSpot setting |
-D... | -Dfoo=bar | Java system property |
| env var | FOO=bar | OS / container env |
In a container, heap is not the whole story
Kubernetes memory limit
│
├── Java heap
├── metaspace
├── thread stacks
├── direct buffers
├── code cache
└── other native memoryThe heap is only one part of the container’s memory, so a 4 GiB limit does not mean -Xmx4g is sensible.
| Symptom | Meaning |
|---|---|
OutOfMemoryError: Java heap space | the heap is full; the JVM throws and may keep running |
exit 137, reason OOMKilled | the container passed its limit; killed — no exception, no dump |
The second can happen while the heap is still below its maximum, because native memory counts toward the limit too. Full treatment in OOMKilled without Java OOM.
Checklist for high memory use
Files
- Loaded as
byte[]?readAllBytes()?ByteArrayOutputStream? - ZIPs built entirely in RAM?
- Could it stream instead?
Database
- Huge result sets?
- Is pagination really happening in the database?
- Do search results need complete entities?
- Large EAGER entity graphs?
- LOBs/BLOBs loaded unnecessarily?
Concurrency
- One large operation may work fine.
- Ten at once may still fill the heap — size for peak concurrency, not for one request.
Caches and collections
- Are maps or lists growing without bound?
- Any static collections?
- Do caches have size or expiry limits?
Then measure
-XX:+HeapDumpOnOutOfMemoryError, analyse the dump in Eclipse MAT.- At a production OOM, don’t guess.
Key points
Streaming does not mean no data is in RAM — it means only small parts have to be there at a time.
byte[]holds the bytes on the heap; anInputStreamlets you read them step by step.
InputStream.transferTo(OutputStream)passes data through without reading it all first.
A ZIP can be built while it is being sent.
-Xand-XXconfigure the JVM;-Dsets Java system properties.
System.getProperty()andSystem.getenv()are different things.
Java heap ≠ the container’s total memory use.
Java heap space≠ KubernetesOOMKilled.
High memory use is not automatically a leak.