Practical Tuning Guide: Running Spring Boot on a 512MB Low-Memory VPS

Practical guide to tuning Spring Boot for stable operation on 512MB VPS with JVM optimization.
A developer successfully deployed a full-stack Spring Boot application on a 512MB VPS with 256MB swap, demonstrating that Java's memory reputation is outdated. Key techniques include using SerialGC over G1GC, limiting Metaspace, leveraging JDK 25 Compact Object Headers for 10-20% heap savings, and monitoring RSS instead of heap usage. With JDK 25 optimizations, even 256MB becomes viable.
Why Run Spring Boot Under Extreme Memory Constraints
As cloud service costs become increasingly sensitive, many developers and indie site owners want to run their applications with minimal resources. However, the Java ecosystem has long carried the reputation of being a "memory hog" — especially full-featured frameworks like Spring Boot, which are often considered unsuitable for low-spec environments.
A developer shared his hands-on experience on Reddit: deploying a representative Spring Boot application to a minimal VPS tier while maintaining lightweight monitoring on the same machine. His core question was: How large is the gap between the JVM's configured heap memory and the actual process memory consumption?
This question is critical. Many people assume that setting -Xmx256m means the JVM will only use 256MB, but reality is far more complex. JVM memory usage extends well beyond the heap alone. Metaspace stores class metadata information and replaced PermGen starting from JDK 8 — by default, it can grow indefinitely until system memory is exhausted. Thread Stacks allocate independent call stack space for each thread, typically 512KB to 1MB by default; an application with 200 threads could consume over 100MB on thread stacks alone. The JIT Compiler (Just-In-Time Compiler) compiles hot bytecode into native machine code and caches it in the Code Cache, which has a default upper limit of 240MB. On top of that, there's the GC algorithm's own data structure overhead (such as G1GC's Remember Sets), Direct Memory (commonly used for NIO operations), and JVM-internal native memory allocations. Adding all of this together, a JVM process configured with a 256MB heap can easily reach 400MB or more in actual RSS.
Tech Stack of the Test Application
The author deliberately chose a "representative" rather than "toy-level" application, covering components commonly found in real production projects:
- Spring Boot 3.5.x + Spring MVC: Web framework core
- JPA / Hibernate: ORM persistence layer
- H2: Embedded database
- Embedded Tomcat: Servlet container
- Actuator: Health checks and metrics exposure
- Scheduled work: Scheduled tasks
- Outbound HTTP: Outgoing HTTP calls
This combination means the application loads a large number of Spring context beans, Hibernate entity metadata, Tomcat thread pools, and more — a typical "full-stack" configuration.
From a memory consumption perspective, each component has its own overhead sources. Spring Boot's Auto-Configuration mechanism greatly improves development efficiency but also means the framework scans and initializes a large number of bean definitions at startup. Spring's IoC container needs to maintain the complete bean dependency graph and proxy objects in memory. Hibernate, as an ORM framework, builds the entity Metamodel at startup, including field mappings, association relationships, and query plan caches for each entity — these data structures remain resident in memory. Hibernate's first-level cache (Session-level) and optional second-level cache also continuously consume heap memory. Embedded Tomcat's default thread pool (typically 200 max threads) and connector buffers likewise require significant memory. The Actuator module collects and retains various runtime metrics, and the time-series storage of these metrics also has a certain memory footprint.
Using this kind of application to test low-memory boundaries is far more informative than testing with an endpoint that just returns Hello World.
256MB Is Too Tight — 512MB Is the Stable Floor
The author's first conclusion is direct and practical: The original 256MB configuration was too tight and couldn't reliably complete the tests.
This confirms a common misconception — you can't simply set the JVM heap to some value and assume total memory equals that value. Using a Spring Boot full-stack application as an example, framework initialization and class loading alone consume significant Metaspace memory. Add Hibernate and Tomcat initialization overhead, and with only 256MB of physical memory, very little remains for the heap, easily triggering OOM or frequent GC jitter.
Ultimately, the author reliably completed the tests with a 512MB RAM + 256MB swap configuration. The introduction of swap here is a critical engineering trade-off:
In memory-constrained scenarios, moderate swap can serve as a buffer to accommodate infrequently accessed memory pages (such as class metadata that's rarely touched after startup), thus preventing the process from being killed by the OOM Killer due to transient memory spikes.
The OOM Killer (Out-Of-Memory Killer) in the Linux kernel is a protection mechanism triggered when system memory is critically low. When both available memory and swap space are nearly exhausted, the kernel selects a process to forcefully terminate based on each process's oom_score, freeing memory to keep the system alive. JVM processes, due to their typically large memory footprint, often become the OOM Killer's primary target. Swap space is virtual memory simulated on disk — the Linux kernel swaps out memory pages that haven't been accessed for a period (called "cold pages") to swap, freeing up physical RAM for active memory demands. Although swap read/write speeds are far slower than physical memory (even SSD-based swap is two orders of magnitude slower than RAM), for data that's almost never accessed after startup (such as the large amount of metadata created during class loading), being swapped out has no noticeable impact on runtime performance. By setting a reasonable swappiness parameter (recommended 60-80 on low-memory VPS instances), you can make the kernel more aggressively utilize swap to relieve memory pressure.
For low-spec VPS instances, "512MB RAM + swap" is essentially the practical minimum combination for running a complete Spring Boot application.
The Breakthrough from JDK 25 Compact Object Headers
What's truly exciting is the author's follow-up experiment: he successfully got the same application running on a 256MB VPS + swap environment, leveraging two key technologies.
How Compact Object Headers Save Memory
Compact Object Headers is an optimization in the JDK designed to reduce the metadata overhead of every Java object. Its predecessor is Project Lilliput from the OpenJDK community — a long-running project aiming to compress object headers on 64-bit JVMs from the traditional 128 bits (16 bytes) down to 64 bits (8 bytes).
Traditional object headers consist of two parts: the Mark Word (storing hash code, GC age, lock state, etc., occupying 8 bytes) and the Klass Pointer (pointing to the object's class metadata — 4 bytes with compressed oops enabled, but effectively 8 bytes after alignment padding). Compact Object Headers cleverly encode the class pointer information into the Mark Word, compressing the entire object header to 8 bytes. This means each Java object saves 4-8 bytes — seemingly small, but considering that a typical application may have millions of live objects, the cumulative savings are substantial.
This feature was introduced as an experimental feature in JDK 24 (enabled via -XX:+UseCompactObjectHeaders) and further matured in JDK 25. For frameworks like Hibernate that create large numbers of entity objects and collection wrappers, as well as the numerous proxy objects and bean wrappers in the Spring container, this optimization is particularly impactful — saving 10%-20% of heap memory in object-intensive applications is not uncommon.
JVM Parameter Tuning Strategies for Low-Memory Scenarios
Beyond leveraging new features, the author also tightened JVM settings. Common tuning directions for low-memory scenarios include:
- Limiting Metaspace:
-XX:MaxMetaspaceSize=128mprevents Metaspace from growing indefinitely - Choosing SerialGC:
-XX:+UseSerialGCuses less memory than G1GC in low-memory environments - Reducing thread stack size:
-Xss256kor smaller to reduce per-thread memory overhead - Disabling tiered compilation or limiting Code Cache: Reduces JIT compiler memory usage
- Enabling heap memory return: Allows the JVM to return memory to the OS when idle
The GC strategy choice deserves special attention. G1GC (Garbage-First Garbage Collector) has been the default garbage collector since JDK 9, designed to provide predictable low-pause times on large heaps (multi-GB scale). G1GC divides the heap into multiple equally-sized Regions and uses Remember Sets to track cross-Region references. These data structures themselves consume 5%-20% of heap memory — when the heap is only 128MB or 256MB, this percentage translates to an absolute overhead that feels very wasteful. Additionally, G1GC requires multiple background threads for concurrent marking and mixed collection, each with its own stack space and work buffers. In contrast, SerialGC is the simplest single-threaded collector with minimal data structure overhead — it doesn't need to maintain Remember Sets or additional GC threads. Although SerialGC pauses all application threads during collection (Stop-The-World), with small heaps, a full GC pause typically lasts only tens of milliseconds — perfectly acceptable for non-high-concurrency personal projects or edge deployments. Low-latency collectors like ZGC and Shenandoah have even shorter pause times but carry memory overhead even higher than G1GC, making them unsuitable for low-memory scenarios.
All these combined measures share a single goal: compressing the JVM's "hidden memory" overhead beyond the heap.
Practical Lessons for Deploying Spring Boot on Low-Spec VPS
Although this experiment was small in scale, it provided several very practical engineering insights:
Don't equate heap memory with process memory. When monitoring, always watch RSS (actual physical memory usage) rather than just heap utilization — the gap between the two often exceeds expectations. RSS (Resident Set Size) is the core metric in Linux for measuring a process's actual physical memory usage, viewable through the VmRSS field in /proc/[pid]/status or tools like top/htop. In JVM application monitoring, relying solely on heap utilization exposed via JMX will severely underestimate actual memory consumption, since JMX-reported data doesn't include Metaspace, thread stacks, Code Cache, Direct Memory, and other off-heap components. For an accurate process-level memory picture, it's recommended to combine Native Memory Tracking (enabled via -XX:NativeMemoryTracking=summary) with OS-level RSS monitoring. NMT can provide detailed breakdowns of JVM memory allocation by category (heap, Metaspace, threads, Code Cache, GC, internal, etc.), making it an invaluable tool for diagnosing unexpected memory growth.
Swap is essential for low-spec environments. It can't replace memory, but it effectively absorbs startup peaks and infrequently accessed pages, significantly improving stability.
Keeping up with new JDK features pays real dividends. Low-level optimizations like Compact Object Headers are genuine cost-reduction tools for memory-constrained scenarios. Java has been actively working toward "cloud-native" and "small memory" goals — from GraalVM native images to various GC improvements — and performance and footprint are nothing like they were ten years ago. It's worth mentioning that GraalVM's Native Image technology can ahead-of-time compile Spring Boot applications into standalone native executables, reducing startup time from seconds to milliseconds and significantly lowering memory usage. However, this path requires handling compatibility issues with reflection, dynamic proxies, and similar features, serving as a complement to the standard JVM path discussed in this article.
Spring Boot is not impossible to slim down. Through reasonable dependency trimming and fine-grained JVM tuning, a full-featured Spring Boot application can absolutely run in 256MB-512MB environments — this is hugely significant for personal projects, edge deployments, and cost-sensitive scenarios.
Conclusion
"Running Spring Boot on a 512MB or even 256MB VPS" sounds like an extreme challenge, but practice proves it's not only feasible — it's becoming increasingly easier as the JDK continues to evolve. For developers still hesitating because Java applications are "too memory-hungry," this is a positive case study worth taking seriously. With properly configured swap, an appropriate GC strategy, and the latest JDK features, a low-memory VPS can reliably host a full-stack Spring Boot application.
Related articles

AI Agent Cost Optimization in Practice: Engineering Wisdom That Saved $1 Million in One Hour
Databricks eliminated $1M/year in wasted AI Agent spend in just one hour. Learn the root causes of Agent cost overruns and key strategies like model tiering, context pruning, and caching.

How the FDA Is Building an AI-Ready Data Foundation on Databricks
Explore how the FDA leverages Databricks for Government to build a unified Lakehouse architecture and AI-ready data foundation while meeting federal security and compliance standards.

The Power of Security Collaboration: Why Vulnerability Discovery Cannot Do Without Human Intelligence
Explore how security collaboration outperforms tool dependency, the value of vulnerability stories, cross-team knowledge sharing practices, and building stronger defenses by investing in people and collaboration.