Could Not Create the JVM: Fix -Xmx and Bad Options
Lower -Xmx to fit the machine, drop the removed flag, and unset JAVA_TOOL_OPTIONS.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓A JDK installed so you can run java -version
- ✓Access to the launch script or Dockerfile that sets JVM flags
- ✓Basic shell skills: env, grep, and reading cgroup files
- Your heap flags ask for the impossible: a typo like -Xmx512mb, a space after -Xmx, or -Xms larger than -Xmx kills the JVM before main.
- A JDK upgrade removed a flag your config still passes, usually CMS or PermSize — the error names the dead option directly.
- A leaked JAVA_TOOL_OPTIONS or _JAVA_OPTIONS injects bad flags into every java launch, so even java -version fails.
- Your -Xmx exceeds real memory: 32-bit ceilings near 4 GB or a Docker limit smaller than the heap — size from the cgroup, not the host.
Think of the JVM as a restaurant that must reserve its tables before opening. You hand it a booking slip: how many tables (-Xmx), which menu (VM flags), and the room size (machine memory). If the slip asks for 500 tables in a 50-table room, names a dish removed from the menu years ago, or contains a coffee stain from someone else's note (a leaked env var), the manager cancels opening entirely. Nobody gets seated — that's this error. Fix the slip, not the recipes.
You change one flag, restart, and instead of your app you get a dead JVM and a single cryptic line. Nothing was deployed. No code changed. Yet the process won't even reach main. This error is the JVM telling you its own birth conditions were impossible — the heap you asked for can't exist, a flag you passed doesn't exist, or the box you're on can't honor the request.
It's disorienting because the failure sits below your application entirely. Your code is never loaded, your logs never open, and your usual debugging tools never get a chance. Engineers burn hours reviewing recent commits when the culprit is a -Xmx typo, a JAVA_TOOL_OPTIONS export they forgot about, or a JDK upgrade that deleted a flag their config still references.
Containers added a fresh trap. A heap sized generously for the host becomes a fantasy inside a Docker limit half its size, and the JVM dies at birth with no flag to blame. You'll swear the configuration is identical to staging while the cgroup ceiling quietly differs.
This guide covers all five causes: invalid -Xmx and -Xms pairs, VM options that died in a JDK upgrade, JAVA_TOOL_OPTIONS leaking across every launch, 32-bit heap ceilings, and container memory limits. You'll get a production incident where one global export broke every JVM on shared CI hosts, a debug guide with exact commands, and a table that maps each error line to its fix.
Invalid -Xmx and -Xms: Typos and Backwards Heaps
Heap flags are validated before anything else, so a typo reads like a dead runtime. -Xmx512mb looks plausible but the JVM only accepts k, m, and g suffixes — mb is nonsense and the process aborts. A space between -Xmx and the number splits one flag into two broken arguments. A lowercase -xmx isn't a flag at all. Each failure prints a slightly different line, but all of them mean the same thing: the request couldn't be parsed, so no heap was ever built.
The subtler killer is an -Xms above -Xmx. Asking for 4 GB initial inside a 1 GB maximum is a contradiction, and the JVM refuses to start rather than guess which number you meant. This often arrives via layered configs: a base script sets -Xmx1g, someone's override adds -Xms4g, and the merged command line is impossible. Neither value is wrong alone — together they're fatal.
Always test flags without your app. java -Xmx512m -Xms256m -version either prints version info (flags valid) or dies (flags invalid), and that verdict takes a second with zero deployment. When it dies, bisect: drop -Xms first, then simplify -Xmx to a plain value like -Xmx1g. The moment version prints, you've isolated the offender.
Lock the working pair into one place — a shared launch script or a Dockerfile ENV — instead of scattering -Xms and -Xmx across profiles, unit files, and CI variables. One source of truth can't contradict itself. Add a CI step that runs java with the production flags; if a future edit breaks the pair, the pipeline catches it instead of your pager.
Unrecognized VM Options After a JDK Upgrade
Every major JDK retires flags, and your old configs don't get the memo. The CMS collector (-XX:+UseConcMarkSweepGC) was removed in JDK 14, MaxPermSize died with PermGen back in JDK 8, and each removal turns a working launch line into an instant abort on the new runtime. The error names the dead flag plainly, which is generous — but only if you read it instead of blaming your code.
The pain is that the flag often comes from somewhere you didn't write. Build plugins inject collectors, base Docker images set -XX defaults, monitoring agents append their own options, and shared profiles export extras. Your app's own config can be pristine while the merged command line carries a corpse. That's why the same artifact starts on the old image and dies on the new one with zero code changes.
Triage by testing the named flag in isolation: java -XX:+UseConcMarkSweepGC -version. If that dies, you've convicted the flag, not the app. Then grep the carriers — /etc, /opt, dotfiles, Dockerfiles, agent configs — until you find who passes it. Replace CMS with G1GC (the default since JDK 9) or ZGC for latency-sensitive heaps, and validate the replacement the same isolated way.
Make flag audits part of every JDK upgrade runbook. List every -XX option from every layer, run each against the new runtime in CI, and fail the upgrade on the first unrecognized one. Upgrades that validate flags first are boring; upgrades that skip it page you at midnight.
JAVA_TOOL_OPTIONS and _JAVA_OPTIONS Leaking Into Every Launch
JAVA_TOOL_OPTIONS is the JVM's auto-include: its contents are prepended to every java launch on the machine, printed to stderr as Picked up JAVA_TOOL_OPTIONS for honesty. That's handy for one debugging session and catastrophic as a permanent export, because every future JVM — your app, Maven, Gradle, Jenkins agents, even java -version — inherits whatever you left there. One stale flag poisons the whole box identically, which looks like a broken JDK rather than a broken variable.
Its shadowy sibling _JAVA_OPTIONS behaves the same way but prints no Picked up notice, making it harder to spot. Both override with silent confidence: command-line flags still win, but anything the command line doesn't mention comes from the variable. Teams that set these in /etc/profile.d or shared .bashrc files create a trap that springs months later when a JDK upgrade invalidates the injected flag.
Diagnosis is quick. env | grep -i java reveals the carriers; running env -u JAVA_TOOL_OPTIONS -u _JAVA_OPTIONS java -version shows whether the stripped JVM lives. If stripping revives it, don't just unset and move on — find the export's home (grep -rn JAVA_TOOL_OPTIONS /etc/profile.d/ ~/.bashrc) and delete it there, or the next login resurrects the failure.
The durable rule: never export JVM flags machine-wide. Put service flags in the service's own definition — a systemd Environment line, a Dockerfile ENV, a per-app env file. Scoped flags break one service at worst; global flags break everything at once, including the tools you'd use to investigate.
32-Bit Heap Limits: When 4 GB Isn't Available
A 32-bit JVM can only address about 2 to 4 GB total, and the heap must also fit in one contiguous address block — which is smaller than the theoretical max once libraries and native mappings take their slices. Asking for -Xmx4g on a 32-bit runtime fails even on a host with 64 GB free, because the limit is address width, not physical memory. The error reads like starvation on a full box, but the box is nearly empty.
This still bites in production through legacy dependencies: old native libraries, 32-bit-only vendor agents, or base images nobody rebuilt since 2016. The app team sizes -Xmx for the host's RAM while the runtime underneath caps out at a fraction of it. Upgrading the host or adding memory changes nothing — you can't buy address space with RAM.
Confirm quickly with java -version (it says 32-Bit or omits 64-Bit) plus uname -m for the kernel. If the JVM is 32-bit, drop -Xmx under the ceiling — 1536m is the pragmatic safe value — to restore service immediately. Then schedule the actual fix: move to a 64-bit JDK, which raises the ceiling past any heap you'll plausibly configure.
Watch for the hybrid case too: a 64-bit kernel running a 32-bit java binary, common when JAVA_HOME points at an ancient install. The kernel reports x86_64 while the JVM reports 32-Bit, and only the JVM's answer matters. Pin JAVA_HOME to the 64-bit JDK in every launch path so the wrong binary can't sneak back.
Container Memory Limits That Kill the JVM at Birth
Containers lie about memory by omission. free -m inside Docker often shows the host's RAM, so a -Xmx4g that fits the host looks safe while the container's cgroup limit sits at 1 GB. The JVM tries to reserve its heap, the kernel refuses, and the process dies at birth with no flag to blame — just the creation failure. Staging passes because its limits are looser; production dies because they're tighter.
-Xmx is blind to cgroups by design: it's a fixed byte count that never adapts. That rigidity breaks the moment your pods move between node types or someone tightens a limit to save cost. Every reschedule becomes a gamble between the hardcoded heap and the new ceiling, and the loser gets paged.
-XX:MaxRAMPercentage ends the gamble. It sizes the heap as a fraction of the container limit the JVM detects — 75.0 means three-quarters of the cgroup max — so the heap tracks reschedules automatically. Older JDKs need -XX:+UnlockExperimentalVMOptions plus MaxRAMFraction, but anything from JDK 10 up reads cgroups natively and just works.
Verify from inside the container: cat the cgroup max, compare it against your -Xmx, and read the JVM's own accounting via -XshowSettings:system. If Xmx exceeds the max, either raise the limit or switch to the percentage flag. Never size container heaps from host metrics — the host's gigabytes aren't yours.
Reading the Error Line: Which Cause Is Yours
The error line is a tiny decision tree if you read it literally. Invalid maximum heap size or initial heap means flag syntax — test the pair with java -version and fix the suffix or the Xms/Xmx order. Unrecognized VM option plus a flag name means a dead option — test it alone and grep the configs for its carrier. Could not reserve enough space means arithmetic — compare -Xmx against free memory, bitness, and the cgroup max. No detail line at all inside Docker means the container limit — read the cgroup and switch to MaxRAMPercentage.
Resist the urge to tune the application when the JVM won't even form. No GC log, heap dump, or profiler output can exist yet, because there's no heap to profile. The only evidence that matters is the launch: the full java command, the environment, the JDK version, and the machine or container limits. Capture those four before changing anything, and most cases resolve from that snapshot alone.
Reproduce with the smallest possible command. Strip your app away (java -version with the same flags), then strip the environment (env -u the JAVA_* variables), then strip the container (run on the host). Each layer you remove either clears the failure — convicting that layer — or preserves it, narrowing the search. Three bisections beat three hours of config reading.
When it starts again, lock in the lesson: validated flags in one script, scoped env files per service, heaps derived from cgroup limits. Startup failures are the cheapest outages to prevent permanently, because the entire check runs in CI in under a second with java -version.
One Global Export Broke Every JVM on Shared CI Hosts
- Machine-wide JVM flag exports are a single point of failure for every Java process — scope flags to the service that needs them.
- When the error names a flag nobody set, search the environment before the codebase: env and profiles first, repositories second.
- JDK upgrades must include a flag audit: grep every config and image for -XX options and validate each one on the new runtime.
| File | Command / Code | Purpose |
|---|---|---|
| heap-flags-check.sh | java -Xmx512m -Xms256m -version | Invalid -Xmx and -Xms |
| jdk-upgrade-flag-audit.sh | java -XX:+UseConcMarkSweepGC -version | Unrecognized VM Options After a JDK Upgrade |
| tool-options-leak-check.sh | env | grep -i java | JAVA_TOOL_OPTIONS and _JAVA_OPTIONS Leaking Into Every Launc |
| bitness-heap-check.sh | java -version 2>&1 | head -5 | 32-Bit Heap Limits |
| container-memory-check.sh | cat /sys/fs/cgroup/memory.max 2>/dev/null || \ | Container Memory Limits That Kill the JVM at Birth |
Key takeaways
Common mistakes to avoid
5 patternsWriting -Xmx with a space or a bogus suffix like mb
Setting -Xms larger than -Xmx
Copying -XX flags across a JDK upgrade
Exporting JAVA_TOOL_OPTIONS globally on shared boxes
Giving the container less memory than -Xmx promises
Interview Questions on This Topic
Why does a bad -Xmx kill the JVM before main runs?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's JVM. Mark it forged?
6 min read · try the examples if you haven't