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, Set
  • String
  • byte[]
  • 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 space

High 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 MB

Three 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.readAllBytes is also bounded by the JVM’s maximum array size, so a file over roughly 2 GB fails with OutOfMemoryError: Requested array size exceeds VM limit no 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 client

transferTo

Since Java 9:

try (
    InputStream in = Files.newInputStream(source);
    OutputStream out = Files.newOutputStream(target)
) {
    in.transferTo(out);
}
InputStream
    ↓
small buffer
    ↓
OutputStream

No 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 well

Streaming 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
      ↓
    client

Keep 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-Length up 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 relations

With 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=75

With 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   # off

Do 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/path

On 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=prod
String 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

KindExamplePurpose
plain-jarJava launcher
-X...-Xmx3gextended JVM setting
-XX:...-XX:+UseG1GCHotSpot setting
-D...-Dfoo=barJava system property
env varFOO=barOS / 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 memory

The heap is only one part of the container’s memory, so a 4 GiB limit does not mean -Xmx4g is sensible.

SymptomMeaning
OutOfMemoryError: Java heap spacethe heap is full; the JVM throws and may keep running
exit 137, reason OOMKilledthe 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; an InputStream lets 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.

-X and -XX configure the JVM; -D sets Java system properties.

System.getProperty() and System.getenv() are different things.

Java heap ≠ the container’s total memory use.

Java heap space ≠ Kubernetes OOMKilled.

High memory use is not automatically a leak.