Problem and Applicable Scenarios
A latency-sensitive Java API runs on HotSpot JDK 25 with G1 and a fixed 12 GiB heap: -Xms12g -Xmx12g. After a cache feature is released, request p99 rises from 120 milliseconds to 2–4 seconds every 3–5 minutes. Host CPU is not saturated during the spikes. Monitoring also shows that old-generation occupancy after collection rises from 6.1 GiB to 9.2 GiB over two hours, while the application's allocation rate increases from about 600 MiB/s to 1.4 GiB/s. The team labels the incident a “GC problem” and proposes increasing the heap to 24 GiB first.
All numbers are interview assumptions. The candidate must explain how to prove whether the latency spikes actually correspond to JVM pauses; how to use unified GC logging, Java Flight Recorder (JFR), and bounded heap diagnostics to distinguish high allocation rate, live-set growth, G1 humongous objects, explicit System.gc(), and non-GC safepoints; and how to produce a reversible remediation and validation plan. The goal is not to recite collector flags. It is to build a causal chain from symptom to evidence, hypothesis, experiment, and SLO.
Public Java GC interview material treats stop-the-world events and JVM pause times as core topics. Oracle's HotSpot guidance for long-pause troubleshooting directly covers insufficient heap, heap fragmentation, operating-system activity, and explicit GC. Those sources make this a representative JVM performance-diagnostics question. No reliable company attribution is public, so companyName is left empty.
What the Interviewer Is Testing
The first signal is whether the candidate aligns the timeline before tuning. Request p99, JVM GC logs, JFR events, container CPU throttling, disk events, and host metrics need the same time basis. A sawtooth heap graph only proves that collection happened; it does not prove that a particular three-second request spike was caused by GC. Conversely, G1 performs substantial concurrent work, so a long GC cycle does not mean the application was paused for the entire cycle.
The second signal is whether the candidate separates an excessively long individual pause from an excessive total pause ratio. The former calls for the pause type and phase timing, before/after live data, copied bytes, and OS timing. The latter often tracks allocation rate and collection frequency. Average GC duration alone hides both rare tail pauses and a large number of short pauses.
The third signal is whether evidence selects the remediation. Allocation pressure calls for finding allocation hot spots. A rising old-after-GC baseline calls for proving that unwanted objects remain reachable. Growth in Humongous regions calls for tracing objects that cross half a G1 region. A log cause of System.gc() calls for finding the caller. “Increase the heap, lower the pause target, or switch to ZGC” is not a diagnosis that fits every symptom.
Finally, the interviewer is testing production risk awareness. JFR heap statistics trigger extra old collections. Oracle marks jcmd GC.class_histogram and heap dumps as high-impact operations, and a heap dump requests a Full GC by default. A strong answer gathers low-overhead evidence first, obtains heavyweight evidence on a replica, off peak, or in a controlled environment, and uses a same-load canary to prove that lower pauses were not purchased with unacceptable throughput, CPU, or memory.
Questions to Clarify Before Answering
- What exactly is “stalled”? Is it server handler latency, client end-to-end latency, all Java
threads making no progress, or only some requests queueing? Are spikes isolated to one instance, and do the load balancer, dependencies, or network show the same event?
- Can logs and metrics be correlated precisely? We need the same instance ID, UTC timestamp, JVM
uptime, and release version. If logs have only minute-level timestamps, improve observability before drawing a conclusion from two curves that merely look similar.
- What does the G1 log contain? At minimum, retain GC ID, cause, pause type, heap before and after,
and duration. Enable gc+heap, gc+phases, and gc+cpu when needed. Process RSS or a heap percentage alone is insufficient.
- Does the post-collection old-generation baseline keep rising under comparable load? A plateau
after cache warm-up may be the expected live set. Continued growth with diminishing reclaimed bytes is more consistent with retention or a leak.
- What limits apply to the container and host? Check CPU quota and throttling, swap, page faults,
memory pressure, blocking log I/O, and noisy neighbors. Long GC wall time with little CPU time may mean the JVM was descheduled or its pages were swapped out.
- Which allocation and reference relationships changed in the release? Inspect cache capacity,
TTL, key and value sizes, serialization buffers, batch size, concurrency, and retention through ThreadLocals, listeners, queues, or static collections.
- What are the latency and throughput goals? Define pause p99 and max, request p99, throughput,
error rate, CPU, and memory limits. -XX:MaxGCPauseMillis is a goal hint for G1, not a hard bound on every pause.
- Can heavyweight evidence be collected safely? If only one production instance exists, add
capacity or drain traffic first. Do not dump a 12 GiB heap during the incident and then mistake the diagnostic Full GC for the original failure.
30-Second Answer Framework
“I would not change the heap first. I would correlate each request spike, by instance and timestamp, with -Xlog:gc*, JFR jdk.GCPhasePause events, CPU throttling, page faults, and dependency latency to measure how long the application actually stopped. Then I would split the evidence into three groups: the type and phase of each pause; total pause ratio per time window; and trends in allocation rate, promotion rate, old-after-GC occupancy, and humongous regions.
If the cache raises allocation but the post-collection baseline is stable, I would use JFR to find allocation hot spots and reduce temporary objects. If old-after-GC keeps rising, I would compare class histograms and obtain a heap dump on a controlled replica to inspect dominators and retention paths. If Full GCs are preceded by evacuation failures or many humongous regions, I would inspect and split large arrays, buffers, and batches. If the cause is System.gc(), I would identify the caller. Every change would be tested in a same-load canary against request p99, pause p99 and max, total pause ratio, allocation rate, post-collection live set, CPU, throughput, and errors before rollout.”
Step-by-Step Deep Dive
Step 1: Build a falsifiable timeline and prove whether GC is involved.
For every latency spike, record the instance, request window, release version, and UTC time. Correlate it with unified logging and JFR pause events by GC ID. A baseline startup configuration can preserve timestamped, tagged GC and safepoint data in rotating files:
-Xlog:gc*,safepoint:file=/var/log/app/gc-%p.log:time,uptime,level,tags:filecount=10,filesize=100mThis is a diagnostic example; adapt the path, retention, and disk budget to the environment. In JFR, focus on jdk.GCPhasePause duration while also examining CPU load, threads, socket/file I/O, and allocation events. Oracle's guidance notes that, for a concurrent collector, the total cycle length is less meaningful than the time for which the application was actually paused.
Create three outcome branches. If spikes align one-for-one with JVM pauses, continue into GC root-cause analysis. If the reported GC pause is short but a safepoint is long, inspect the safepoint reason and time to reach it. If neither aligns, investigate CPU throttling, locks, I/O, networking, and downstream services. This makes the GC hypothesis falsifiable instead of the default explanation for all latency.
Step 2: Describe the GC symptom with a metric set, not one graph.
Retain at least the following time series and compare the same load window before and after release:
Requests: p50 / p95 / p99 / max, throughput, timeouts, error rate
Pauses: pause p50 / p95 / p99 / max, paused time per minute, pause cause
Heap: young / old usage, old-after-GC, reclaimed bytes, promotion rate
Allocation: bytes/s, top allocation sites by class and thread, inside/outside TLAB
G1: young / mixed / Full counts, evacuation failures, humongous regions
System: process CPU, GC CPU, CPU throttling, RSS, swap, major page faults, disk latencyThe used-before → used-after (heap-capacity) format shows what one collection reclaimed, but one point is not a trend. A monotonically rising old-after-GC baseline at comparable load suggests growth in the live set or promotion pressure. A stable baseline with increasingly frequent collections more often points to high allocation. When gc+cpu=info shows real time far above user plus system time, investigate scheduling, quotas, or paging before adding GC threads.
Step 3: Use evidence to separate five root-cause paths.
- High allocation rate. Old-after-GC is stable, while young-pause count and paused time per minute
rise. JFR allocation events point to cache-key construction, serialization, or temporary collections. Reduce copying, reuse buffers with a clear lifecycle, and control batch size. Do not introduce broad object pooling and shared state without evidence.
- Live-set growth or a memory leak. Old-after-GC keeps rising and each collection reclaims less.
Compare class histograms at multiple times. Obtain a heap dump only on an off-peak replica or in a traffic-replay environment, then use retained size, a dominator tree, and GC roots to distinguish a legitimate cache from unbounded retention. A cache that plateaus at capacity is a sizing issue; expired objects that remain referenced indicate a leak.
- G1 humongous objects. G1 treats an object at least half a region in size as humongous and places
it directly in contiguous old regions. If Humongous regions: X → Y stays high, trace large byte[], char[], compressed blocks, or batches and reduce individual object or batch size first. Treat a region-size change as an experiment only after measurement, because it also changes region count and collection granularity.
- Concurrent marking falls behind or evacuation fails. If the log shows insufficient destination
space or regions that cannot be evacuated, the worst case is a whole-heap Full GC. Reduce old-region allocation or promotion, give concurrent marking sufficient headroom, and check the safety margin between heap capacity and the live set. Lowering MaxGCPauseMillis alone may make each collection do less work and leave the system closer to exhaustion.
- Explicit GC or a non-GC safepoint. If the GC cause is
System.gc(), use call stacks, dependency
configuration, and operational commands to find the source before removing the call, configuring the library, or isolating the task. Use DisableExplicitGC only after checking semantic impact. If the safepoint is long but the GC pause is short, inspect the actual cause—such as deoptimization, class redefinition, or another VM operation—rather than changing collectors.
Step 4: Layer diagnostic tools and control observation risk.
Start with the continuously available, lower-cost layer: application SLIs, unified GC/safepoint logs, and standard JFR. For live confirmation, run jcmd PID JFR.start, JFR.check, and JFR.dump on the same host as the same effective user. Check the options supported by that JVM first with jcmd PID help COMMAND.
Then move to the high-cost layer. Oracle marks GC.classhistogram as high impact. GC.heapdump is also high impact and requests a Full GC by default. JFR heap statistics trigger extra old collections at the beginning and end, so do not enable heap statistics by default during a latency investigation. Drain traffic or reproduce the issue on a replica, confirm disk capacity and sensitive-data handling, and only then obtain a histogram or dump. Comparing two captures separated by a stable load interval explains growth better than one list of the largest objects.
Step 5: Keep causality singular when changing code, capacity, or collector settings.
Start with the new cache. Is it unbounded? Does its TTL actually expire entries? Is maximum weight measured in bytes rather than entry count? Does each value copy a large array? Do concurrent misses build the same value independently? Candidate code fixes include bounding maximum weight, coalescing same-key loads, streaming serialization, reducing batch size, or dropping references to temporary objects sooner. Change one main variable in each experiment.
A larger heap can increase the interval between collections, but it can also hide unbounded retention, raise container-memory risk, and increase the amount of live data that must eventually be processed. Before changing IHOP, young-generation sizing, region size, or the pause target, identify the logged phase or resource deficit that the flag is expected to change. Switching to a collector such as ZGC is an architectural experiment requiring new throughput, CPU, heap-headroom, container, and operational validation. It is not the first incident command.
Step 6: Close the incident with a same-load canary and counterfactual evidence.
Replay production-shaped requests, object sizes, cache hit rate, and concurrency against the baseline and fixed versions. The canary must span several original 3–5 minute spike cycles and include cache cold start and steady state. Define acceptance thresholds before the test, for example: request p99 below 200 ms; GC pause p99 below 100 ms and max below 500 ms; less than 1% paused time per minute; old-after-GC no longer rising after warm-up; and no regression in throughput, CPU budget, OOMs, or errors.
Then test counterfactuals. Does rolling back the cache restore both allocation rate and the spikes? Does bounding cache weight alone stabilize old-after-GC? Does reducing only the batch size reduce humongous regions? When the predicted metrics move together, the remediation has a causal link to the root cause. Roll out in stages and retain automatic rollback criteria.
High-Quality Sample Answer
“The current data makes GC worth investigating, but it does not prove that GC caused the 2–4 second request spikes. I would first align request SLIs, -Xlog:gc*,safepoint, JFR jdk.GCPhasePause events, CPU throttling, page faults, and dependency latency by instance and UTC time. If spikes align with pauses, I would distinguish one long pause from many accumulated short pauses. If only the safepoint is long, I would follow the safepoint cause. If neither aligns, I would leave the GC branch.
The release raised allocation from 600 MiB/s to 1.4 GiB/s while old-after-GC rose from 6.1 GiB to 9.2 GiB. That gives me at least two hypotheses: the cache created substantial temporary allocation, and the cache or related objects expanded the live set. I would inspect young, mixed, and Full causes; heap before and after; promotion; evacuation failures; and humongous regions. JFR allocation events identify classes, threads, and call sites. Histograms from multiple times show which classes continue to grow. I would obtain a heap dump only on a drained replica or in replay because it is high impact and can request a Full GC by default.
If the post-collection baseline is stable but young pauses are frequent, I would reduce allocation in cache-key construction, serialization, and temporary collections. If the baseline keeps rising, I would use retained size and GC-root paths to distinguish a bounded cache from a leak. If large arrays cross half a G1 region and humongous-region use grows, I would split buffers or batches. If the cause is System.gc(), I would find the caller. If real time greatly exceeds GC CPU time, I would investigate quotas, swap, and host contention.
I would not treat a 24 GiB heap as the fix. I would test each candidate change independently in a same-load canary across cache cold start and several original spike cycles. The final gate covers request p99, pause p99 and max, total pause ratio, allocation rate, old-after-GC, humongous regions, throughput, CPU, RSS, errors, and OOMs. Only then would I ramp traffic, with rollback preserved.”
Common Mistakes
- **Mistake: declaring GC the cause after seeing a sawtooth heap graph → Why it fails: the graph proves
collection occurred, not that a request spike and stop-the-world pause overlapped on one instance → Correction: correlate requests, logs, and JFR with instance, GC ID, and one timeline.**
- **Mistake: looking only at average GC duration → Why it fails: the average hides a single three-second
tail event and ignores the total cost of many short pauses → Correction: measure pause percentiles, max, paused time per minute, and cause.**
- **Mistake: treating GC-cycle duration as application-pause duration → Why it fails: much of G1's
marking work can run concurrently with the application → Correction: use actual pause events and request SLIs.**
- **Mistake: increasing the heap from 12 GiB to 24 GiB during the incident → Why it fails: this may hide
unbounded retention and increase memory risk without proving that the live set has a valid bound → Correction: separate allocation rate from post-collection live-set growth, then run a reversible capacity experiment.**
- **Mistake: immediately taking a heap dump on the only production instance → Why it fails: the command
is high impact, may request a Full GC by default, writes a large file, and exposes sensitive data → Correction: drain traffic and collect it on a replica or controlled replay.**
- **Mistake: changing G1 region size after noticing large objects → Why it fails: a large object may not
cross the half-region threshold, and the flag changes region granularity across the heap → Correction: confirm humongous objects with gc+heap and allocation evidence, then compare experiments.**
- **Mistake: treating
-XX:MaxGCPauseMillis=50as an SLA → Why it fails: it is a goal hint and G1 is not
a real-time collector → Correction: validate the real pause distribution and budget the tradeoff among the target, throughput, and heap headroom.**
- **Mistake: globally disabling explicit GC as soon as
System.gc()appears → Why it fails: a dependency
or operational workflow may rely on the semantics → Correction: identify the caller and intent before removing, configuring, or isolating it.**
- **Mistake: checking only that p99 falls after switching collectors → Why it fails: lower pauses may
come with higher CPU, lower throughput, or more memory → Correction: test latency, throughput, CPU, RSS, errors, and recovery under the same load.**
Follow-up Questions and Responses
Follow-up 1: How do you distinguish a memory leak from normal cache warm-up?
Examine the live set after multiple old collections at comparable load, not just the initial slope after process start. A normal bounded cache plateaus after reaching its maximum weight and stable hit rate, and eviction and TTL expiry should be observable. A leak leaves objects with no business value reachable from a GC root, so the baseline keeps rising. Use class histograms from several times to find growing classes, then inspect dominators, retained size, and reference paths in a controlled heap dump. Even if the cache eventually plateaus, a 9.2 GiB platform in a 12 GiB heap may leave inadequate headroom for concurrent marking and bursts, making it a capacity-design problem.
Follow-up 2: Why not lower MaxGCPauseMillis first?
It guides how much work G1 attempts per collection; it is not an enforced upper bound. If the live set is too large, concurrent marking falls behind, or the container cannot supply CPU, a lower target may make each mixed collection reclaim less, increase frequency, and move the process closer to evacuation failure. First form a hypothesis from phase timing, heap headroom, and allocation or promotion rate, then validate one flag change under the same load.
Follow-up 3: What does long real time but short user and system time in the GC log suggest?
It suggests that GC threads did not consume CPU for the entire wall-clock interval. Candidates include container CPU throttling, host contention, swap or major page faults, virtualization pauses, and log I/O. Inspect cgroup quota and throttled time, run queue, page faults, swap, disk latency, and colocated-host events. Adding parallel GC threads can intensify contention; address evidence of external resource supply first.
Follow-up 4: When would you consider moving from G1 to ZGC?
Consider it when the service has an explicit low-latency goal, allocation and live-set growth are under control, G1 still misses the pause SLO at the target heap and load, and the team can validate additional CPU, heap headroom, JDK compatibility, and operations. Run an A/B test with production-shaped traffic, including cold start, steady state, bursts, failure recovery, and container limits. Collector migration is a capacity and runtime-model choice; it does not replace fixing an object leak or unbounded cache.
Follow-up 5: How do you prove the fix lasts instead of merely delaying the spike?
Run the canary long enough to cover several original spike cycles, cache steady state, and the expected peak. Compare the slope of old-after-GC, hourly Full GCs and evacuation failures, allocation rate, humongous regions, total pause ratio, and request SLIs, and test beyond forecast peak for headroom. If a larger heap only moves the first spike from five minutes to ten while the baseline slope is unchanged, the remediation has failed.