Understanding the Java memory model and garbage collection tuning
Many Java performance incidents begin with a misleading symptom: CPU rises, requests queue, or an application suddenly slows after deployment. The Java memory model explains what one thread may safely observe from another, while garbage collection explains how unreachable objects are reclaimed and when that work can affect latency.
Tuning works best when these concerns are separated. Reproduce the workload, measure visibility and allocation behaviour, then change one JVM setting at a time. That approach keeps memory correctness separate from heap sizing and collector selection.
Memory visibility and happens-before
The Java memory model defines when a write by one thread is visible to another. Without a happens-before relationship, the JVM may expose stale values or reordered observations. That is a data race, even when the code appears correct on a developer’s single-core laptop.
Common happens-before edges include volatile write/read pairs, unlocking and later locking the same monitor, thread start, and thread join. These guarantees matter because optimising compilers and CPUs can reorder work that the source code executes in a different order. Treat unsynchronised shared state as a correctness defect, not merely a rare timing glitch.
Shared state and safe publication
Visibility is only one part of thread safety. A volatile flag can be seen promptly, but flag++ still combines a read and write and is not atomic. Use AtomicInteger, AtomicReference, or locks when an operation must change shared state indivisibly.
Prefer immutable objects and final fields because safe publication then becomes straightforward. For mutable collections, ConcurrentHashMap provides atomic operations on its entries, but compound workflows still require synchronisation. Guard shared invariants with one clear owner or lock instead of scattering volatile fields throughout the class.
Heap sizing and collector choice
Treat Xmx as the application’s heap budget, not the container’s whole memory allowance. Metaspace, thread stacks, direct byte buffers, the code cache, and JVM overhead also consume native memory, so an Xmx equal to the container limit can trigger an OutOfMemoryError even when the Java heap has room.
Size the heap above the measured live set with operating headroom, then reconsider it when retention changes. G1 is the default for many modern HotSpot services; Parallel can suit batch throughput, while ZGC or Shenandoah may help latency-sensitive workloads where supported. Collector choice is a hypothesis: compare pauses, throughput, CPU use, and footprint on the real workload.
Reading GC logs and runtime metrics
Enable unified garbage collection logging with a flag such as -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags. Track allocation rate, promotion, evacuation failures, post-collection live-set size, and pause percentiles. A heap that is nearly full after collection suggests retention pressure; a heap that fills between collections suggests allocation pressure.
Java Flight Recorder adds allocation and lock timelines without requiring a debugger attached for the whole run. Take heap histograms during a controlled window and use a heap dump when object retention needs identification. Avoid continuous production dumps: they pause the application, consume disk, and may contain credentials or personal data.
Diagnosing pauses and concurrency stalls
Slow requests do not automatically mean slow garbage collection. A stop-the-world pause usually appears in GC logs, while lock contention appears as threads waiting for monitors, CPU may remain high during allocation, and native-memory pressure may sit outside the Java heap. Compare request traces with GC and safepoint timestamps before changing flags.
Thread dumps reveal runnable and blocked states, while JFR or a sampling profiler identifies hot methods and contention. A cross-runtime example is debugging asynchronous code: building a timeline, identifying state ownership, and testing one causal assumption at a time all transfer well to JVM investigations.
Production realities for Australian teams
Australian deployments add distinct constraints. Sydney and Melbourne host major cloud regions, but services used from Brisbane or Perth can experience different network round-trip times and regional peering, so test from the actual audience. Transport and parking apps often peak around commuting periods, while the Melbourne Cup and seasonal retail events produce short, concentrated traffic bursts. Those patterns can expose collector settings that work poorly under sudden allocation spikes.
Cloud expenditure is normally quoted in Australian dollars, making idle heap and excess containers visible operating costs. Covered organisations must also follow the Privacy Act 1988 and Australian Privacy Principles for personal information. Keep request bodies and identifiers out of GC logs, protect any diagnostic dump, and delete it under a defined retention policy; payment or account data should not be copied into a heap dump without a clear, authorised purpose.
A repeatable tuning process
Establish a baseline with representative data and a stated service target, such as 99.9% availability and a p99 latency ceiling. Record the JVM version, heap bounds, collector, allocation rate, live set, pause distribution, CPU, and container limits. Re-run the same workload mix after each change so improvements are attributable.
Roll out a candidate to a small production slice, watch error rate and latency, and keep a safe fallback. Record collector flags and workload assumptions in the deployment runbook alongside the rollback condition. If a change cannot be linked to a measured constraint or service target, it is configuration noise rather than useful tuning.