Back to CI/CD Pipeline
Q14Question 14 of 56

Why increasing the heap size can make performance WORSE, and why "just raise `-Xmx`" is often the wrong first response

Key Concepts
  • A bigger heap delays collections but makes each one scan more live memory and pause longer.
  • GC cost is proportional to live data (survivors), not to garbage freed.
  • High allocation rate plus large heap gives the worst of both: frequent collections that are also large.
  • A larger heap worsens CPU cache locality and risks OS paging or container OOM-kill if it exceeds physical RAM.
  • The real causes are usually excessive allocation, memory leaks, or the wrong collector: more heap masks symptoms.
  • The correct approach is to diagnose with GC logs, measure allocation rate, and take a heap dump before tuning.

Definition

A bigger heap delays collections but makes each one scan more and pause longer, so raising `-Xmx` treats a symptom: the real fix is usually reducing allocation, plugging a leak, or choosing the right collector.
  • -Xmx – the JVM flag that sets the maximum heap size; increasing it gives more room before GC must run.
  • Live set – the surviving, still-reachable objects that the GC must trace during collection; GC cost scales with live data, not garbage.
  • Allocation rate – how fast the application creates new objects; a high rate fills even a large heap quickly.
  • Memory locality – how close together in memory the objects the CPU accesses are; a larger heap spreads them out and hurts cache performance.
  • Paging / OOM-kill – if the heap exceeds physical RAM, the OS swaps to disk (catastrophically slow) or the container kills the process.

Imagine National Bank of India's filing room. A bigger room lets clerks pile up more paperwork before anyone has to tidy, which sounds good. But when the tidy-up finally comes, there is a far bigger heap of paper to sort through, so everyone has to stop working for much longer. And if the room is bigger than the building can physically hold, clerks start running to an off-site warehouse for every file, which is painfully slow. A bigger room did not fix the real issue: the clerks are producing too much paper, or someone never throws anything away.

Understanding the Concepts

Why does a bigger heap make collections pause longer?

The cost of a collection, for classic collectors, scales with the amount of live memory, the surviving reachable objects, that the collector must trace and relocate. A GC does not pay in proportion to the garbage it frees; it pays in proportion to the live data it must walk.

BASH
# Compare pause times with different heap sizes
# 4 GB heap: frequent short pauses
java -XX:+UseG1GC -Xmx4g -Xlog:gc*:file=gc-4g.log -jar app.jar

# 16 GB heap: rare but LONG pauses
java -XX:+UseG1GC -Xmx16g -Xlog:gc*:file=gc-16g.log -jar app.jar
TEXT
gc-4g.log:  Pause Young 15ms  (every 2 seconds)
gc-16g.log: Pause Young 45ms  (every 8 seconds)
gc-16g.log: Pause Full  800ms (rare but devastating at p99)

How does allocation rate make this worse?

With a high allocation rate, the space you added fills with garbage quickly. When the collection finally runs, it must deal with a larger region.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class QuotePricingService {

    // WRONG: allocates a huge object per quote
    public BigDecimal priceQuote(String instrument) {
        // 8 MB byte array per quote at 1000 quotes/sec = 8 GB/sec!
        byte[] marketData = new byte[8 * 1024 * 1024];
        loadMarketData(instrument, marketData);
        return calculatePrice(marketData);
    }

    // CORRECT: reuse a buffer
    private final byte[] sharedBuffer = new byte[8 * 1024 * 1024];

    public BigDecimal priceQuoteOptimized(String instrument) {
        synchronized (sharedBuffer) {
            loadMarketData(instrument, sharedBuffer);
            return calculatePrice(sharedBuffer);
        }
    }

    private void loadMarketData(String instrument, byte[] buf) { }
    private BigDecimal calculatePrice(byte[] data) {
        return BigDecimal.ZERO;
    }
}

What are the non-GC penalties of a large heap?

A larger heap spreads live objects across more memory, hurting CPU cache locality. And if the heap exceeds physical RAM or the container limit, the OS either pages to disk or kills the process.

BASH
# Container with 4 GB limit, heap set to 3.6 GB (90%)
# During a GC cycle, total RSS exceeds 4 GB → OOMKilled!
docker run -m 4g \
  -e JAVA_OPTS="-Xmx3600m" \
  registry.nbi.com/transfer-service:2.5.0

# CORRECT: heap at 70-75% of container limit
docker run -m 4g \
  -e JAVA_OPTS="-XX:MaxRAMPercentage=72" \
  registry.nbi.com/transfer-service:2.5.0

What are the real causes that more heap masks?

When an application is slow or pausing, the symptom is often high GC activity. The naive response is more heap. But the real cause is usually one of three things.

TEXT
Cause 1: Excessive allocation rate
  Fix: allocate less -- reuse buffers, stream instead of materialize

Cause 2: Memory leak (objects kept reachable unintentionally)
  Fix: find and remove the retention (heap dump analysis)

Cause 3: Wrong collector for the latency goal
  Fix: switch to G1 with a pause target, or ZGC for sub-ms pauses

How do you diagnose before tuning?

The senior approach is to enable GC logging and analyze before changing anything.

BASH
# Enable GC logging
java -Xlog:gc*:file=gc.log:time,level,tags \
     -XX:+UseG1GC -Xmx8g -jar transfer-service.jar

# Analyze pause times
grep "Pause" gc.log | awk '{print $NF}' | sort -n

# Check if live set keeps growing (memory leak signal)
grep "Heap:" gc.log | grep "after"
TEXT
# Healthy: live set stays stable after each Full GC
Heap: 8192M  after GC: 1200M  (stable)
Heap: 8192M  after GC: 1210M  (stable)

# Leak: live set keeps climbing
Heap: 8192M  after GC: 2400M  (growing!)
Heap: 8192M  after GC: 3600M  (growing!)

How do you find a memory leak?

Take a heap dump and analyze it with a tool like Eclipse MAT or VisualVM.

BASH
# Take a heap dump
jcmd $(pgrep -f transfer-service) GC.heap_dump /tmp/heap.hprof

# Or auto-dump on OutOfMemoryError
java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/tmp/heapdump.hprof \
     -jar transfer-service.jar
JAVA
package com.bank.service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class SessionCache {

    // WRONG: cache grows without bound -- memory leak!
    private final Map<String, Object> cache = new ConcurrentHashMap<>();

    public void put(String key, Object value) {
        cache.put(key, value);  // Never evicts!
    }
}
JAVA
package com.bank.service;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;

public class SessionCacheFixed {

    // CORRECT: bounded cache with eviction
    private final Cache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(30, TimeUnit.MINUTES)
        .build();

    public void put(String key, Object value) {
        cache.put(key, value);
    }
}

What are common mistakes?

The classic mistake is raising the heap as the first response without diagnosing the cause.

BASH
# WRONG: first response is to raise the heap
# This made pauses WORSE at NBI's quote-pricing service
java -Xmx4g -jar quote-pricing.jar   # p99 pause: 50ms
java -Xmx16g -jar quote-pricing.jar  # p99 pause: 200ms (worse!)

# CORRECT: diagnose first, then fix the cause
# 1. Enable GC logging
# 2. Measure allocation rate
# 3. Take heap dump if leak suspected
# 4. Fix: reuse buffers, plug leak, or switch collector

What should a senior engineer know?

A senior engineer diagnoses before tuning. They enable GC logging, check whether the live set keeps growing (leak signal), measure allocation rate, and take a heap dump if they suspect retention. Only after understanding the cause do they act, and the action is frequently not raising the heap.

Visual Overview

DIAGRAM
  Bigger heap → fewer collections → each collection scans more

  Small heap (4 GB):
  ─────────────────────────────────────────────────
  [alloc][alloc][GC 15ms][alloc][alloc][GC 15ms]
  Frequent, short pauses

  Large heap (16 GB):
  ─────────────────────────────────────────────────
  [alloc][alloc][alloc][alloc][alloc][alloc][GC 200ms]
  Rare, LONG pauses → worse p99 latency

  Diagnosis workflow:
  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐
  │ Enable GC log│ →  │ Measure pause│ →  │ Live set     │
  │              │    │ + alloc rate │    │ growing?     │
  └──────────────┘    └──────────────┘    └──────┬───────┘
                                                  │
                                          yes → heap dump → fix leak
                                          no  → reduce alloc or
                                                switch collector

Key Takeaway

Increasing the heap buys one thing: GC runs less often. The mistake is assuming that means better performance. For classic collectors, the cost is proportional to the live set, not the garbage freed. So a bigger heap with more live objects means each collection scans more and pauses longer. You trade frequent short pauses for rare long ones, and for a latency-sensitive service the rare long pause is usually far worse.

A high allocation rate makes this sharper: the extra space fills with garbage quickly, and survivors promote into the old generation, inflating the live set. Two more penalties are cache locality degradation from spreading objects across more memory, and the risk of OS paging or container OOM-kill if the heap exceeds physical RAM.

The real causes are usually excessive allocation, a memory leak, or the wrong collector. At National Bank of India, a quote-pricing service stalled, and raising the heap from 4 GB to 16 GB made the ninety-ninth-percentile pause worse. The fixes were stopping per-quote allocation of a huge object and adding eviction to a leaky cache.

Remember one sentence: a bigger heap delays collections but makes each one longer, so diagnose with GC logs and heap dumps before ever reaching for -Xmx.

---

Share
Use ← → arrow keys to navigate