The most surprising thing about Java garbage collection is that it’s often not about releasing memory, but about organizing it to prevent future collections from becoming prohibitively expensive.
Let’s see how this plays out. Imagine a web server processing requests. Each request might create a bunch of temporary objects: request parameters, parsed JSON, database query results, etc.
// Simplified example of request processing
public class RequestHandler {
public void handle(HttpRequest request) {
// ... lots of object creation for request parsing ...
DatabaseResult result = database.executeQuery(request.getQuery());
// ... more objects for result processing ...
HttpResponse response = buildResponse(result);
// ... and so on ...
}
}
If these objects are short-lived, the garbage collector (GC) will happily clean them up in the young generation. But what happens when you have a lot of simultaneously active requests, each with its own set of these objects? The young generation fills up fast, triggering frequent Minor GCs. This churn can start to impact performance.
The core problem isn’t necessarily the total amount of memory used, but how it’s allocated and deallocated. High allocation rates and frequent object churn, especially for objects that just miss being collected in the young generation and are promoted to the old generation, lead to "GC pressure." This pressure means the GC has to work harder and more often, consuming CPU cycles and potentially causing application pauses.
Reducing the Footprint:
-
Object Pooling: For frequently created, short-lived objects that are expensive to create (e.g.,
byte[]buffers,StringBuilderinstances for common operations), pooling can dramatically reduce allocation.- Diagnosis: Monitor allocation rates and object creation in your profiling tools (JProfiler, YourKit, VisualVM). Look for high rates of specific object types.
- Fix: Implement a simple pool. For a
StringBuilderpool:public class StringBuilderPool { private final List<StringBuilder> pool = Collections.synchronizedList(new ArrayList<>()); private final int maxSize; public StringBuilderPool(int maxSize) { this.maxSize = maxSize; } public StringBuilder acquire() { if (!pool.isEmpty()) { return pool.remove(0); } return new StringBuilder(); } public void release(StringBuilder sb) { if (pool.size() < maxSize) { sb.setLength(0); // Clear the buffer pool.add(sb); } } } // Usage: // StringBuilderPool pool = new StringBuilderPool(100); // StringBuilder sb = pool.acquire(); // ... use sb ... // pool.release(sb); - Why it works: Instead of creating a new
StringBuilderand then letting the GC reclaim it, you reuse existingStringBuilderobjects, avoiding both allocation and deallocation overhead for those specific objects.
-
Primitive Types and
int/long: Avoid boxing primitive types into their wrapper classes (Integer,Long) when not strictly necessary.- Diagnosis: Profile and look for high counts of
IntegerorLongobjects, especially in collections or method arguments whereintorlongwould suffice. - Fix: Use primitive types wherever possible.
// Bad: List<Integer> // Good: int[] or use primitive ints in local variables/fields - Why it works: Primitive types don’t get allocated on the heap as objects, thus consuming no memory and not contributing to GC pressure.
- Diagnosis: Profile and look for high counts of
-
Efficient Data Structures: Choose collections wisely.
ArrayListis generally efficient for sequential access and additions, butLinkedListcan be better for frequent insertions/deletions in the middle. For primitive types, consider specialized libraries like Trove or fastutil.- Diagnosis: Profile and observe memory usage of collection objects. Look for large numbers of
java.lang.Integerobjects within aHashMaporArrayList. - Fix: Replace
HashMap<Integer, MyObject>withInt2ObjectHashMap<MyObject>from fastutil.// Before: // Map<Integer, String> map = new HashMap<>(); // map.put(1, "one"); // After (using fastutil): // import it.unimi.dsi.fastutil.ints.Int2ObjectMap; // import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; // Int2ObjectMap<String> map = new Int2ObjectOpenHashMap<>(); // map.put(1, "one"); - Why it works: Libraries like fastutil provide maps and lists that store primitive keys/values directly, avoiding the overhead of wrapper objects and their associated heap allocations.
- Diagnosis: Profile and observe memory usage of collection objects. Look for large numbers of
Reducing GC Pressure:
-
Tuning the Young Generation: The young generation is where most objects are allocated and collected. Making it too small causes frequent Minor GCs; too large can lead to longer pauses when a Major GC eventually happens.
- Diagnosis: Monitor GC logs (using
-Xlog:gc*or-XX:+PrintGCDetails). Look for very frequent Minor GC cycles, or Minor GCs that are taking a noticeable amount of time. - Fix: Adjust
-Xmn(total young generation size) or-XX:NewRatio(ratio of old to young generation). A common starting point for high-throughput applications is to make the young generation larger. For example, if you have 8GB heap (-Xmx8g), a-Xmn2g(2GB young gen) might be beneficial. - Why it works: A larger young generation can hold more short-lived objects, delaying the need for Minor GCs. This reduces the frequency of collections and allows more objects to be collected before they need to be promoted to the old generation.
- Diagnosis: Monitor GC logs (using
-
Choosing the Right Garbage Collector: Different GCs have different trade-offs. G1 (Garbage-First) is the default in modern JVMs and aims for predictable pause times. Parallel GC is good for throughput but can have longer pauses. ZGC and Shenandoah aim for ultra-low pause times.
- Diagnosis: Observe GC pause times in your monitoring tools. If application responsiveness is critical and you’re seeing pauses > 100ms with G1, consider alternatives.
- Fix: Experiment with different collectors. For low-latency requirements:
# For G1 (default in Java 9+) java -XX:+UseG1GC ... # For Parallel GC (throughput-oriented) java -XX:+UseParallelGC ... # For ZGC (very low pause, Java 11+) java -XX:+UseZGC ... # For Shenandoah (very low pause, Java 12+) java -XX:+UseShenandoahGC ... - Why it works: Each collector uses different algorithms to scan and reclaim memory. ZGC and Shenandoah perform most of their work concurrently with the application threads, drastically reducing the time the application is stopped.
-
Tuning Old Generation and Promotion: Objects that survive Minor GCs are promoted to the old generation. If too many objects are promoted too quickly, the old generation fills up, triggering expensive Major (or Full) GCs.
- Diagnosis: GC logs showing frequent Major GCs or a consistently high old generation occupancy.
- Fix: Increase
-Xmn(as mentioned above) to give objects more time to die in the young generation. Also, tune the-XX:MaxGCPauseMillis(for G1) to guide the GC’s efforts. For G1, setting a target pause time can influence how aggressively it tries to collect in the old generation. - Why it works: By allowing more objects to be collected before promotion or by making the young generation larger, you reduce the rate at which the old generation fills up, thus reducing the frequency of Major GCs.
-
Lazy Release of Resources: For resources that are not directly managed by the JVM’s GC (like database connections, file handles), ensure they are closed promptly. However, for objects within the JVM, sometimes delaying their effective release can be beneficial if they are likely to be reused shortly. This is a more advanced technique.
- Diagnosis: Look for objects that are live for a surprisingly long time and are not being actively used, but might be referenced by long-lived objects or the GC itself.
- Fix: This is highly application-specific. It might involve refactoring to ensure objects go out of scope sooner, or in very specific cases, using weak/soft references if the object can be recreated without data loss. A common pattern is ensuring
try-with-resourcesis used for allAutoCloseableobjects. - Why it works: Ensures that objects that should be eligible for GC actually become eligible, rather than being held alive by lingering references.
The next frontier after optimizing memory footprint and GC pressure is often understanding and tuning the specific garbage collector’s phases and how they interact with application threads.