In this lesson, we will peel back the layers of the Java Virtual Machine (JVM) to understand how it handles memory allocation and reclamation. By mastering the internals of the heap and the mechanics of garbage collection, you will be equipped to write high-performance applications that minimize pauses and maximize throughput.
To understand performance, we must first look at where objects live. The JVM heap is divided into multiple generations based on the generational hypothesis, which states that "most objects die young." The heap is typically segmented into the Young Generation and the Old Generation (formerly called the Tenured space).
The Young Generation is further subdivided into the Eden Space and two Survivor Spaces (S0 and S1). When you instantiate a new object, it is allocated in the Eden space. When Eden fills up, a Minor GC (Young GC) is triggered. During this process, surviving objects are moved to one of the Survivor spaces, and their age is incremented. If an object survives long enough, it is promoted (tenured) to the Old Generation. This segmentation is crucial because it allows the collector to focus on the short-lived objects in the Young Generation without having to scan the entire heap, significantly reducing the "stop-the-world" time.
Note: The Metaspace (which replaced the PermGen) stores class metadata and is allocated outside the heap in native memory, meaning its size is limited by the host system’s memory rather than the heap configuration.
Garbage collection is not a single process but a collection of algorithms designed to identify unreachable objects. The most basic approach is Mark-and-Sweep, where the JVM traverses the object graph to "mark" live objects and then "sweeps" away the memory occupied by unmarked ones. However, this leads to memory fragmentation.
To combat this, modern GCs use Compaction or Copying. In the Young Generation, the JVM uses a copying collector: it simply moves all live objects from the current Eden/Survivor space to an empty Survivor space. This effectively "compacts" memory by layout, eliminating gaps entirely. In the Old Generation, the collectors often use Mark-Sweep-Compact to reclaim space. The challenge here is balancing the cost of compaction against the latency of the pauses. Understanding whether your application is latency-sensitive (requiring short, frequent pauses) or throughput-oriented (prioritizing total work done) will dictate which GC algorithm you choose from the JVM's toolbox.
The JVM provides several collectors, each tailored to different performance profiles. The Serial GC is best for low-footprint, single-threaded applications. The Parallel GC (Throughput Collector) uses multiple threads to perform GC, making it ideal for batch processing where minimizing total application pause time is less important than raw throughput.
For modern enterprise applications, the G1 (Garbage First) GC is the industry standard. G1 partitions the heap into equal-sized regions and tracks the "liveness" of these regions. It performs GC on the regions that contain the most garbage first—hence the name. If you have an application requiring ultra-low latency, the ZGC or Shenandoah collectors are preferred. These are known as Concurrent Collectors because they perform the majority of their work, including compaction, while the application threads are still running, leading to sub-millisecond pause times regardless of the heap size.
Performance tuning is useless without data. You should always start by enabling Garbage Collection Logging (-Xlog:gc*), which provides a detailed timeline of GC events, pause durations, and heap usage after each collection. Analyzing these logs reveals if you are suffering from GC Overhead Limit Exceeded errors or excessive Promotion Failure, where the Old Generation fills up and cannot accommodate promoted objects from the Young Generation.
When adjusting settings, avoid the pitfall of manually setting the heap size (-Xms and -Xmx) to extreme values without evidence. A heap that is too small triggers frequent collections, while a heap that is too large can lead to massive, infrequent, but disastrously long "stop-the-world" pauses. Instead, use tools like VisualVM or JConsole to view live memory metrics. If you observe high CPU usage coupled with high GC frequency, your application might be creating excessive short-lived objects that trigger premature promotions, suggesting that optimizing your internal data structures is more effective than tweaking GC flags.