Advanced Java and Spring: Runtime, Concurrency, Proxies, and Reactive Failure Modes
A production field guide to GC choice, thread-pool saturation, Spring lifecycle traps, AOP boundaries, and blocking work in reactive services.
On this page
1. Diagnose the runtime before tuning it
G1 is a general-purpose collector that aims for bounded pauses while managing a large heap through regions. ZGC targets very short pauses on large heaps by doing most expensive work concurrently, trading some throughput and memory headroom depending on workload and JDK version. Do not choose a collector by reputation alone: compare allocation rate, live-set size, pause distribution, CPU headroom, and tail latency with production-like traffic.
Capture unified GC logs and correlate pause cause, heap occupancy before and after collection, promotion, allocation stalls, and request latency. jcmd <pid> GC.heap_info gives a quick view. A heap dump can expose retention paths:
jcmd <pid> GC.heap_dump /secure/tmp/heap.hprof
# Alternative on supported JDKs:
jmap -dump:format=b,file=/secure/tmp/heap.hprof <pid>
Heap dumps can contain credentials and user data and may be as large as the heap. Secure storage, disk headroom, and incident approval matter. Heap growth after GC suggests retained objects; high allocation with a stable live set suggests churn instead. G1 and ZGC flags are JDK-version-specific, so verify the exact runtime documentation before changing production options.
2. Thread pools: bounded work beats thread multiplication
A ThreadPoolExecutor is governed by core size, maximum size, queue type/capacity, keep-alive, and rejection policy. With an unbounded queue, the pool may never grow beyond core size while latency and memory grow in the queue. With a bounded queue, saturation becomes visible and the rejection policy defines backpressure.
Spring's ThreadPoolTaskExecutor should be configured explicitly for workload type and shutdown behavior:
@Bean
ThreadPoolTaskExecutor reportExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("report-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(20);
return executor;
}
Size from measured service time and downstream capacity, not CPU count alone. Track active threads, queue depth, completed tasks, and rejections. During CPU spikes, compare pool saturation with CPU, blocked time, and dependency latency; a large pool can amplify load on an already slow database.
Collect repeated thread dumps rather than a single snapshot:
jstack -l <pid> > /secure/tmp/threads-1.txt
sleep 5
jstack -l <pid> > /secure/tmp/threads-2.txt
For Spring Boot, inspect executor and JVM metrics through Actuator/Micrometer, for example executor.active, executor.queued, jvm.threads.states, and process CPU. Metric names vary with instrumentation and Spring versions. A thread dump explains what workers are doing; metrics show how broadly it is happening.
3. Spring lifecycle and proxy boundaries
BeanCurrentlyInCreationException during startup often signals a circular dependency: bean A needs B while B needs A. Constructor injection exposes this cycle clearly and is preferable to hiding it with lazy references. Break the cycle by extracting shared behavior, introducing an application service, or in a true event-driven boundary publishing an event after construction. @Lazy can defer resolution but does not remove architectural coupling.
Spring AOP usually applies advice through a proxy. JDK dynamic proxies expose interfaces; class-based proxies use CGLIB-style subclassing and cannot advise final classes/methods in the same way. A call from one method to another method on this bypasses the proxy, so self-invocation can skip @Transactional, caching, or security advice. Move the advised operation behind another bean or use programmatic transaction boundaries when the boundary must be explicit.
Bean scopes are lifecycle contracts: singleton is per application context, prototype creates instances on request, and web scopes bind objects to request/session contexts. Injecting a prototype directly into a singleton does not magically create a new prototype for each method call; use a provider or a scoped proxy. Always close request-scoped resources and avoid storing request state in singleton fields.
@Transactional(propagation = Propagation.REQUIRES_NEW) suspends an existing transaction and opens an independent one. It can be useful for an audit record that must commit independently, but it consumes another connection and may commit data even if the outer operation later rolls back. Proxy boundaries still apply, and self-invocation may bypass propagation entirely.
Spring Data JPA can hide N+1 queries when code loads parent entities and then lazily traverses a collection for each one. Confirm it with SQL logging or tracing before changing fetch behavior. Use a projection for read-only screens or an entity graph for a known fetch path:
@EntityGraph(attributePaths = "lines")
@Query("select o from PurchaseOrder o where o.id in :ids")
List<PurchaseOrder> findWithLinesByIdIn(Collection<Long> ids);
For paginated parent lists, a collection fetch join can multiply rows or conflict with database pagination. Consider a two-step ID page plus bounded batch fetch, or a DTO projection. Configure Hibernate batch fetching only after measuring query count and memory use.
4. Reactive Streams and blocking JDBC
Reactor publishers are lazy descriptions until subscribed. A cold publisher runs per subscription; a hot publisher can emit independently of a particular subscriber. flatMap allows concurrent inner work and may interleave results; concatMap preserves sequential subscription/order at lower concurrency. Backpressure communicates demand between compliant stages, but it does not make a blocking driver non-blocking.
Calling JDBC directly on a Netty event-loop thread blocks the small set of threads that must process many connections. Symptoms include broad latency spikes, event-loop pending-task warnings, and threads parked in JDBC socket reads. Use a reactive driver for an end-to-end reactive path, or isolate unavoidable blocking work on a bounded scheduler and cap concurrency. Do not simply increase the event-loop thread count.
5. Memory leak from ThreadLocal in a web container
ThreadLocal values belong to worker threads, which outlive individual HTTP requests. A request context, security object, or large payload left in a pooled worker can retain memory or contaminate the next task. Reproduce under a reused pool, inspect heap dominators for ThreadLocalMap paths, and check cleanup in finally or framework lifecycle hooks. Remove values even on exceptional exits; setting a value to null is not always equivalent to removing the entry.
6. Interview and troubleshooting practice
Interview: What does BeanCurrentlyInCreationException mean during circular dependency resolution? Explain the A → B → A construction cycle, why constructor injection makes it visible, and how extracting a responsibility removes the cycle rather than deferring it.
Troubleshooting: A Boot service shows rising heap after GC and thread-pool starvation. Capture a heap dump and repeated jstack output, inspect ThreadLocal retention and executor queue/active metrics, identify whether blocked workers wait on JDBC or a lock, then fix cleanup or pool boundaries. Validate with load and shutdown tests.
Challenge: Thread A writes payload = 1, then volatile ready = true; Thread B reads ready, then enters a synchronized block and reads payload. The volatile write/read establishes visibility for writes before the volatile write when B observes that write. The monitor only adds a happens-before edge if A released the same monitor B later acquires. High contention changes timing and throughput, not these ordering rules. Never infer ordering from the synchronized block alone when the producer does not use that monitor.