The problem: a pod that keeps getting killed

A Spring Boot service runs fine for two days, then the container platform restarts it. The restart reason says the process was killed for exceeding its memory limit. The team raises the heap size, and the service survives a little longer before the same thing happens again. Nobody can say whether the application leaks memory, whether the heap is simply too large for the container, or whether the memory is not on the heap at all.

To answer those questions you need a map of where a JVM keeps its data and a basic model of what the garbage collector does. This article gives you both, and shows how to read the evidence the JVM already produces.

Where memory lives

A running JVM process uses several distinct regions. Only one of them is controlled by -Xmx.

  • Heap. All objects created with new, including arrays, strings and the collections you put them in. The garbage collector manages this region. It is the part most people mean by "Java memory".
  • Thread stacks. Each platform thread has its own stack that holds frames: local variables, parameters and return addresses. Stack size is set per thread (-Xss), so many threads mean a large total. A StackOverflowError means one stack is exhausted, usually by runaway recursion.
  • Metaspace. Class metadata: loaded classes, method data, constant pools. It lives in native memory and grows when more classes are loaded. Applications that generate classes at runtime or reload class loaders repeatedly can exhaust it.
  • Other native memory. The JIT compiler's code cache, direct byte buffers (used by networking libraries such as Netty), memory-mapped files, GC bookkeeping structures and native libraries.

The consequence is simple and often forgotten: the operating system limit applies to the whole process, so heap size plus everything else must fit. A container with a 1 GiB limit and -Xmx1g is already misconfigured, because the heap alone can fill the whole limit.

+------------------------- process memory -------------------------+
| heap (-Xmx)  | metaspace | thread stacks | code cache | direct    |
| objects      | classes   | N threads x   | JIT code   | buffers,  |
| GC-managed   | native    | -Xss          |            | native    |
+------------------------------------------------------------------+
        container limit must cover the whole box above

The generational idea

Most objects die young: a request DTO, a temporary string or an iterator is useless once the request finishes. Generational collectors exploit this observation. The heap is split logically into a young generation and an old generation.

New objects are allocated in the young generation. When it fills, a young collection copies the survivors out and reclaims the rest, which is cheap because few objects survive. Objects that survive several young collections are promoted to the old generation. The old generation is collected less often, and that work is more expensive because it holds long-lived data such as caches, connection pools and singletons.

This is why allocation rate matters as much as heap size. A service that creates a large number of short-lived objects triggers frequent young collections. A service that retains data too long fills the old generation and triggers expensive collections.

G1 and ZGC at a high level

You choose a collector with a JVM flag, and the right choice depends on what you optimise for.

  • G1 is the default collector in current JDKs on most server-class machines. It divides the heap into many regions, collects the young regions together and cleans old regions incrementally. It aims at a configurable pause-time goal (-XX:MaxGCPauseMillis), which is a target, not a promise. It is a good general default.
  • ZGC does most of its work concurrently with application threads, so pauses stay very short and do not grow with heap size. In JDK 21 there is a generational mode (-XX:+UseZGC -XX:+ZGenerational). It trades some CPU and memory overhead for low latency, and it suits services with large heaps or strict latency requirements.

Do not change the collector because of a blog post. Change it when measurements show that pauses hurt your latency objectives and you have compared both collectors with your own traffic.

Sizing the JVM inside a container

Modern JVMs read the container limit. Instead of a fixed -Xmx, you can express the heap as a fraction of the limit:

java -XX:MaxRAMPercentage=65.0 -XX:+ExitOnOutOfMemoryError -jar app.jar

With a 1 GiB limit this gives a heap of roughly 650 MiB and leaves the remainder for metaspace, stacks, the code cache and direct buffers. The default percentage is intentionally conservative, and raising it is a decision you must support with measurements of non-heap usage. -XX:+ExitOnOutOfMemoryError makes the process exit on an OutOfMemoryError so the orchestrator restarts it cleanly instead of leaving a half-working instance.

Two related points. First, a container killed by the platform for exceeding its limit is different from a JVM OutOfMemoryError: Java heap space. The first usually means non-heap or total memory; the second means the heap. Second, if you set a CPU limit, the JVM derives the number of GC and compiler threads from it, so very small CPU limits can slow collection.

Reading a GC log line

Enable unified logging with -Xlog:gc (or -Xlog:gc* for detail). A typical G1 line looks like this (the numbers here are illustrative):

[10:15:32.123][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 612M->198M(1024M) 14.532ms

Read it from left to right:

  • GC(42) is the sequence number of the collection.
  • Pause Young (Normal) says this was a young collection that stopped application threads.
  • 612M->198M(1024M) is heap occupancy before and after, with the current committed heap in parentheses.
  • 14.532ms is the pause duration.

What you look for is the trend, not one line. After young collections the occupancy should fall back to a similar level. If the post-collection level keeps climbing and full collections reclaim less and less, the live set is growing, which points to a leak or an unbounded cache. If the occupancy returns to a stable level but collections happen too often, the allocation rate is high and you should look at what creates so many objects.

Leak patterns in services

Java has no dangling pointers, but it has unintended reachability. Common patterns:

  • Unbounded caches. A HashMap used as a cache that never evicts. Use a bounded cache with a size limit and expiry.
  • Static collections. A static List or Map that collects listeners, sessions or request data and is never cleared.
  • Thread-local values. A ThreadLocal set on a pooled thread and never removed keeps its value alive as long as the thread lives.
  • Unclosed resources. Streams, HTTP response bodies and JDBC result sets that stay open hold buffers. Use try-with-resources.
  • Listeners and callbacks. An object registered with a long-lived publisher but never unregistered stays reachable.
  • Big result sets. Loading an entire table into a list instead of paging or streaming.
// A leak: the map grows with every distinct user id and never shrinksprivate static final Map<String, UserProfile> CACHE = new HashMap<>();UserProfile profile(String userId) {    return CACHE.computeIfAbsent(userId, this::loadFromDatabase);}

To investigate, capture a heap dump when memory is high (jcmd <pid> GC.heap_dump file.hprof, or -XX:+HeapDumpOnOutOfMemoryError for automatic dumps) and open it in a profiler. Look at the dominator tree: the objects that retain the most memory and the path from a GC root explain why they are not collected. Heap dumps can contain personal data, so treat them as sensitive files.

Common mistakes

  1. Setting -Xmx equal to the container limit.
  2. Treating a growing heap graph as a leak without checking whether it is simply the collector delaying work. Look at post-collection occupancy.
  3. Adding a larger heap as the first reaction to every OutOfMemoryError. A bigger heap delays a leak and makes the eventual dump bigger.
  4. Tuning a dozen GC flags copied from the internet. Start with defaults and change one thing at a time.
  5. Forgetting that thread count is memory. Thousands of platform threads each reserve a stack.

Trade-offs

A larger heap means fewer collections but longer worst-case work for a stop-the-world collector and a higher memory bill. A smaller heap saves memory but risks frequent collections. Low-pause collectors cost CPU and memory. There is no universal setting, only a setting that matches your latency objective, your traffic shape and your container budget, verified by measurement.

Checklist

  • The container limit is larger than the heap, with room for non-heap memory.
  • MaxRAMPercentage (or -Xmx) is set deliberately, not by default accident.
  • GC logging is enabled and shipped with the application logs.
  • Heap and non-heap memory are exported as metrics and alert on trends.
  • Caches have a size limit and an expiry policy.
  • The service exits on OutOfMemoryError so it can be restarted.

Where to go next