JVM Configuration and Startup
How to actually configure a JVM for a real service: sizing the heap with -Xms and -Xmx, choosing a garbage collector, the startup flags that come up constantly in practice, and how all of this affects a Spring Boot application's time-to-first-request.
Learning objectives
- Configure heap size correctly with -Xms and -Xmx and explain the trade-off between the two
- Explain why a fixed heap size is often preferable to the JVM's auto-sizing defaults in containerized environments
- Select an appropriate garbage collector flag for a given workload at a basic level
- List the most commonly needed JVM startup flags and what each one actually controls
- Explain concretely why a Spring Boot application's startup time is sensitive to JVM configuration, not just application code
The Story: Renting the Right Size Office
◆ The problem
A small team moves into a building that lets them expand their floor space on demand, paying nothing for space they aren't currently using. It sounds ideal, until the day the team doubles in size overnight and the building has to spend an entire afternoon knocking down walls and reinforcing floors to make room, during which nobody can get any work done. A competing team, in a different building, pays a flat rate for a fixed, generously sized floor from day one — more expensive on paper every single month, but with zero disruption ever, because the space they might eventually need was already reserved.
A JVM's heap faces exactly this choice, and most Java developers have run a program for years without ever deliberately making it. Left completely unconfigured, the JVM will pick a starting heap size and a maximum heap size itself, based on a fraction of the physical memory it sees available, and it will grow the heap gradually as allocation pressure demands it, paying a real (if usually small) cost each time it has to expand. That's the "pay only for what you use" building. The alternative — explicitly setting -Xms (the starting heap size) and -Xmx (the maximum heap size) to the same value — reserves the whole floor up front: no runtime resizing pauses, completely predictable memory usage, at the cost of committing that memory even during periods when the application genuinely doesn't need it.
This isn't an abstract choice. In a containerized deployment — the default way most Spring Boot services actually run today — the practical difference is sharper than the office analogy suggests, because a container has a hard memory limit, and a JVM that doesn't know how much memory it's actually allowed to use can make genuinely bad decisions: sizing its heap off the host machine's total memory rather than the container's cgroup limit, then getting OOM-killed by the orchestrator the moment real traffic shows up. Choosing a heap size deliberately, instead of trusting the default auto-sizing heuristics, is one of the first things worth getting right when a Java service moves from "runs fine on my laptop" to "runs in a container with a memory limit and a horizontal autoscaler watching it."
This is the territory this topic covers: not writing application code differently, but handing the JVM the configuration it needs to behave predictably — heap sizing, garbage collector choice, and the handful of startup flags that come up in nearly every real production deployment, plus the surprisingly direct effect all of this has on how fast a Spring Boot application actually becomes ready to serve its first request.
Core Mechanics: Heap Sizing with -Xms and -Xmx
-Xms sets the initial heap size — how much heap memory the JVM reserves and commits the moment it starts, before your application has allocated a single object. -Xmx sets the maximum heap size — the hard ceiling the heap is allowed to grow to, no matter how much allocation pressure the application creates. If you set neither, the JVM computes defaults from the machine's visible memory (roughly a fraction of physical RAM for -Xmx, and a much smaller initial allocation for -Xms), and grows the heap incrementally between those two numbers as needed.
That growth is not free. Each time the heap needs to expand past its current committed size, the JVM has to request more memory from the operating system and, depending on the collector, potentially reorganize existing heap structures around the new size — a real, if usually brief, pause that happens at an unpredictable moment determined by allocation pressure, not by anything you scheduled. Setting -Xms and -Xmx to the same value eliminates this category of pause entirely: the full heap is committed at startup, and it simply never needs to resize for the rest of the process's life. This is close to the single most common piece of production JVM tuning advice that actually matters for a steady, long-running service: -Xms4g -Xmx4g, not -Xms512m -Xmx4g.
The trade-off is memory commitment versus memory elasticity. A fixed, equal -Xms/-Xmx commits the full amount up front, whether or not the application is currently using it — fine for a dedicated container with a known memory budget, wasteful for a shared host running many small, bursty JVMs that would benefit from giving memory back when idle. In a Kubernetes pod with a hard memory limit, giving the JVM a fixed heap sized comfortably below that limit (leaving headroom for Metaspace, thread stacks, and native memory, all covered in the next topic) is close to mandatory — a heap that can grow unpredictably toward the container's limit is a heap that can get the whole pod OOM-killed by the kubelet, a far worse failure mode than a slightly undersized heap triggering more frequent GC.
Container awareness is worth calling out specifically because it has bitten real production deployments. Modern JVMs (Java 10+) are cgroup-aware by default and will correctly read a container's memory limit rather than the host's, but older JVMs, or JVMs running under older container runtimes, could see the full host memory and size a default heap accordingly — a JVM that thinks it has 64GB to play with inside a container actually capped at 2GB is a JVM that is going to get killed. Checking java -XX:+PrintFlagsFinal -version | grep -i maxheapsize inside the actual deployment environment, not just on a laptop, is the way to confirm what the JVM actually believes its ceiling is.
💻 Code example
# ---- Heap sizing flags, as you'd actually pass them ---- # Auto-sized (no flags): JVM guesses based on visible memory. # Risky in containers with older runtimes or cgroup v1 quirks. java -jar app.jar # Fixed heap: committed fully at startup, never resizes. # The standard recommendation for a steady-state production service. java -Xms4g -Xmx4g -jar app.jar # Elastic heap: starts small, grows on demand up to the ceiling. # Saves memory when idle, pays resize pauses when growing. java -Xms256m -Xmx4g -jar app.jar # Confirm what the running JVM actually believes its heap ceiling is -- # run this INSIDE the real deployment container, not just locally. java -XX:+PrintFlagsFinal -version | grep -iE "maxheapsize|initialheapsize"
Deeper Nuance: Choosing a Collector and Common Startup Flags
Heap size answers "how much memory," but a completely separate question is "which algorithm actually reclaims that memory, and what does it optimize for." This topic introduces the choice at a practical level; the full mechanics of each collector and the detailed tuning flags are covered in the two garbage-collection topics later in this category. For now, the thing worth internalizing is that collector choice is a flag, not a code change, and picking the wrong one for your workload is a routinely overlooked source of latency problems that have nothing to do with application logic.
Since Java 9, G1 (Garbage First) is the default collector for most workloads, and it's a genuinely reasonable default: it balances throughput and pause time without demanding much tuning, which is exactly why it's the right starting point for the overwhelming majority of services. -XX:+UseG1GC makes that choice explicit (useful for clarity even when it matches the default). For a service where raw throughput matters far more than any individual pause — a batch job processing a nightly file, where nobody is waiting on a response in real time — Parallel GC (-XX:+UseParallelGC) can outperform G1, because it spends zero effort on the bookkeeping G1 does to keep pauses short, and puts all of that effort into raw collection speed instead. For a latency-sensitive service where even G1's typically-short pauses are occasionally too long — think a trading system or a service with strict p99.9 latency SLOs — ZGC (-XX:+UseZGC) trades some throughput for pause times that stay in the single-digit-millisecond range essentially regardless of heap size. None of these is universally "best"; each is a genuine trade-off, and picking one deliberately based on what your service actually needs beats inheriting whatever happened to be the JDK version's default.
Beyond heap size and collector choice, a handful of other startup flags show up constantly enough in real deployments to be worth knowing by name even before you need them:
-XX:MetaspaceSize/-XX:MaxMetaspaceSize— bounds the memory used to store loaded class metadata (covered in depth in the memory architecture topic). An application that loads an unusually large number of classes — heavy use of dynamic proxies, many deployed microservice JARs, or an application server hosting several deployed apps — can genuinely need this raised above the default.-Xss— sets the stack size per thread. The default (roughly 512KB–1MB depending on platform) is enough for the overwhelming majority of code, but deeply recursive algorithms can legitimately need more, and a service spawning an unusually large number of threads can benefit from a smaller value to reduce total memory committed to stacks.-XX:+HeapDumpOnOutOfMemoryErrorplus-XX:HeapDumpPath=...— essentially mandatory for any production service. Without it, anOutOfMemoryErrorleaves you with a stack trace and nothing else to investigate; with it, the JVM automatically writes a full heap dump at the moment of failure, which is very often the only artifact that actually explains what happened.-XX:+PrintCommandLineFlagsor-XX:+PrintFlagsFinal— prints exactly what the JVM resolved every flag to, including ones you never set explicitly. Enormously useful the first time you need to confirm "what is this JVM actually running with," especially after inheriting a deployment you didn't configure yourself.
Why does any of this matter specifically for a Spring Boot application's startup time? Spring Boot's auto-configuration does real, non-trivial work before the first HTTP request can be served: classpath scanning, bean definition resolution, proxy generation for AOP-advised beans, and — in traditional Spring MVC deployments — a burst of class loading as dozens of framework and dependency classes get loaded for the first time. All of that class loading populates Metaspace and runs entirely through the slow, uncompiled interpreter at first, since none of that startup-only code has had time to become "hot" in the JIT sense from the previous topic. A JVM with a too-small -XX:MaxMetaspaceSize can stall or fail during that burst; a JVM forced to grow its heap incrementally during the allocation spike of startup pays resize pauses exactly when you least want them — which is why -Xms/-Xmx set equal, and Metaspace sized with real headroom, measurably shortens a Spring Boot service's time to first request, independent of anything in your @RestController code.
💻 Code example
# A realistic production startup flag set for a Spring Boot service, # combining heap sizing, GC selection, and operational safety nets. java \ -Xms4g -Xmx4g \ -XX:+UseG1GC \ -XX:MaxMetaspaceSize=512m \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/var/log/app/heapdumps/ \ -jar my-spring-boot-app.jar # Confirm the JVM's fully resolved configuration at startup -- # invaluable when debugging "it behaves differently in prod than locally.": java -XX:+PrintFlagsFinal -version | grep -iE "usegc|heapsize|metaspacesize"
Quick Recap
Q: What's the difference between -Xms and -Xmx, and why set them equal?
A: -Xms is the initial heap size, -Xmx is the maximum the heap can grow to. Setting them equal commits the full heap at startup so the JVM never has to pause to resize it later — the standard recommendation for a steady, long-running production service. Leaving them unequal trades that stability for memory elasticity, useful on shared hosts running many small, bursty JVMs.
Q: Why is container-awareness a real concern for heap sizing?
A: A JVM that doesn't correctly detect a container's memory limit (via cgroups) can size a default heap off the host's full memory instead, leading to an OOM-kill by the orchestrator the moment real memory pressure shows up. Modern JVMs (10+) are cgroup-aware by default, but it's still worth confirming the resolved heap size inside the actual deployment environment, not just locally.
Q: How do you choose between G1, Parallel GC, and ZGC at a basic level?
A: G1 is the sensible default for most services, balancing throughput and pause time with little tuning needed. Parallel GC favors raw throughput over pause time, fitting batch jobs with no one waiting on a response. ZGC trades some throughput for pause times that stay extremely low essentially regardless of heap size, fitting services with strict latency SLOs.
Q: Name three startup flags worth knowing even before you need them.
A: -XX:MaxMetaspaceSize (bounds class-metadata memory), -XX:+HeapDumpOnOutOfMemoryError with -XX:HeapDumpPath (captures a heap dump automatically on OOM instead of leaving nothing to investigate), and -XX:+PrintFlagsFinal (shows exactly what the JVM resolved every flag to, including defaults you never set).
Q: Why does JVM configuration affect a Spring Boot app's startup time specifically?
A: Spring Boot's startup does a genuine burst of class loading and bean/proxy construction, all running uncompiled through the interpreter since nothing is hot yet. A too-small Metaspace ceiling can stall that burst, and a heap forced to resize during the allocation spike pays pauses exactly during startup — so fixed heap sizing and adequate Metaspace headroom measurably shorten time-to-first-request, independent of application code.
Q: What's the risk of leaving heap sizing entirely to JVM defaults in a containerized deployment?
A: Default sizing is based on visible memory, and on older runtimes or certain cgroup configurations the JVM can miscalculate how much memory it actually has available inside the container, sizing a heap toward the host's capacity rather than the container's actual limit — a mismatch that risks an orchestrator-triggered OOM-kill the moment real memory pressure appears, rather than a controlled, predictable GC response.
Want a visual for this concept?
Generate a diagram tailored to “JVM Configuration and Startup” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →