Resource efficiency is a major concern when it comes to optimizing the performance and scalability of an application. In the Java world, one aspect that usually dominates resource concerns is memory usage - more specifically, the size of the Java heap.
In contrast to .NET and the CLR, the JVM is known for the need to size the heap at startup time without any chance of space reclamation, which would make resources available to other processes. Does that still hold true today? Let’s find out.
The garbage collector is in charge of managing the heap and associated OS allocations. Since JDK 9, the G1 garbage collector has been the default GC. Generally speaking, the GC tries to avoid the deallocation of heap space (to give memory back to the OS) because that can lead to performance degradation if that very space is needed at a later point-in-time.
Heap shrinking depends on the collection cycle, which is comprised of the young-only and space reclamation phases.
The young-only phase happens if the associated eden space, where new objects are allocated initially, is exhausted. Reachable objects are evacuated (moved) to the survivor space. If the survivor space is exhausted, reachable objects are evacuated to another survivor space or the old generation (if objects are reachable or “survive” a given number of collections).
The space reclamation happens depending on the size of the old generation, controlled by the Initiating Heap Occupancy Percent (IHOP). The default (-XX:InitiatingHeapOccupancyPercent
) is 45 percent, but the IHOP is adjusted by the GC to optimize the collection interval (known as “Adaptive IHOP”). This phase also handles the young generation, therefore it’s called a “mixed collection”.
According to the G1 source in JDK 9, shrinking is only done after full collections (explicit collection or not) and only in case specific thresholds are met. G1CollectedHeap::do_full_collection
calls G1CollectedHeap::resize_if_necessary_after_full_collection
, which handles the shrinking process and generates the following GC log output:
log_debug(gc, ergo, heap)(
"Shrink the heap. requested shrinking amount: "
SIZE_FORMAT "B aligned shrinking amount: "
SIZE_FORMAT "B attempted shrinking amount: "
SIZE_FORMAT "B",
shrink_bytes, aligned_shrink_bytes, shrunk_bytes);
The G1 GC aims to
[…] provide the best balance between latency and throughput […]
G1 attempts to defer garbage collections for as long as possible and tries to avoid full garbage collections in general. For details, see Garbage Collection Cycle.
[…] if the application runs out of memory while gathering liveness information, G1 performs an in-place stop-the-world full heap compaction (Full GC) like other collectors […]
The collector only performs full collections in case of space exhaustion unless triggered manually (e.g. using java.lang.System.gc()
). That’s when heap shrinking can happen.
According to the G1 source in JDK 15, shinking is done for full collections as well as concurrent mark-and-sweep collections. G1FullCollector::complete_collection
calls G1CollectedHeap::prepare_heap_for_mutators
which in turn calls G1CollectedHeap::resize_heap_if_necessary
. The actual shrinking process and log message is identical to JDK 9. In contrast to JDK 9, JDK 12 and higher also performs shrinking during the so-called re-mark phase (G1ConcurrentMark::remark
calls G1CollectedHeap::resize_heap_if_necessary
), in the context of the normal collection cycle.
The change was introduced with 08041b0d7c08.
A straightforward means of monitoring is -verbose:gc
. Use -Xlog:gc=debug
to see the log output mentioned above in case of shrinking.
So, despite popular belief, the JVM does return memory to the OS. Until JDK 11, heap shrinking was only triggered after full collections, given the change is significant enough to warrant that action without affecting performance. With JDK 12+ (checked until 15), heap shrinking is also triggered during the normal collection cycle, making it more likely for applications that typically do not involve full collections to see heap shrinkage.