What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
A memory leak in a Spring Boot microservice exists when objects that should be collectable stay reachable after garbage collection, and a reference chain you can name keeps them alive. Bean scope and static fields are common places where that chain begins, but neither is a leak on its own. A singleton bean or a static field is ordinary design until it holds an object that should have died with a request, a call, or a subscription. The reliable sequence is to prove that the post-collection live set keeps growing under a steady workload, trace the retention path to the first application-controlled owner, and then shorten or clean up that owner’s lifetime. Changing a bean’s scope helps only when the owner’s lifetime is the actual problem.
What counts as a leak in a Java service
Oracle’s Troubleshoot Memory Leaks guide, in the Java SE 26 documentation, defines the mechanism this way: “A memory leak occurs when an application unintentionally holds references to Java objects or classes, preventing them from being garbage collected.” The word that matters is unintentionally. A heap that rises between collections, or a spike during a batch import, is allocation. It becomes suspicious only when the live set that survives a full collection rises across comparable workload windows.
As an Amazon Associate I earn from qualifying purchases.
Two measurement traps catch most teams. The first is committed memory. The JVM rarely returns committed heap to the operating system, so a graph of committed or resident size can look like a leak long after used heap has stabilized, and it can also hide real growth in used heap. Track used heap after collection. The second is growth outside the Java heap. If resident memory climbs while used heap stays flat, the cause is more likely native memory, thread stacks, or direct buffers, and the bean and static-field path described here will not explain it. Enable Native Memory Tracking at startup with -XX:NativeMemoryTracking=summary, then read it with jcmd <pid> VM.native_memory summary. Tracking adds some overhead, so use it for the investigation rather than as a permanent default.
Free tools Windows power users keep installed
One-click scans. No signup required.
Scope and static rules that create retention
In Spring, singleton is the default scope and means one instance per bean definition per IoC container. It does not mean one object for the whole JVM in the GoF sense. A typical microservice runs one application context per process, so its singletons live as long as the service. A JVM that runs more than one context, such as a parent and child context or some test setups, holds a separate singleton in each. Static fields work differently: they belong to the class and its class loader, so every context loaded by that loader sees the same value.
#1 Best Overall
Singleton scope is not a leak in itself, and the Spring Framework reference treats singletons as the normal choice for stateless beans. A leak forms when a singleton keeps a reference to something with a shorter lifetime, such as a request body, a per-call builder, or a listener registered for one operation.
Prototype beans are not tracked after hand-off
Prototype scope creates a new instance each time the bean is requested from the container. After Spring hands that instance to the caller, it does not manage the rest of its lifecycle and does not invoke the destruction callbacks you configured. The caller owns cleanup. If a prototype opens a connection, buffer, or file handle, close it in the caller’s try block, or implement AutoCloseable and use try-with-resources. An unclosed prototype that holds a native resource will not appear in heap class histograms at all, so check the native side as well.
Why a prototype reused inside a singleton never refreshes
A prototype injected into a singleton through a constructor or field is resolved once, when the singleton is created. The Spring Framework reference, in the section “Singleton Beans with Prototype-bean Dependencies,” states the rule directly:
Recommended Free Tools
“You cannot dependency-inject a prototype-scoped bean into your singleton bean, because that injection occurs only once, when the Spring container instantiates the singleton bean and resolves and injects its dependencies.”
This is the answer to the common question, “Why is my prototype bean reused inside a singleton?” The singleton holds one prototype for its whole life. Each call looks as if it uses a new object, but it uses the same one. If that prototype accumulates state, two problems follow: concurrent requests share mutable state, and its collections grow without bound. The second problem is the leak.
Web scopes need a web-aware context and, for singletons, a proxy
Request, session, application, and WebSocket scopes require a web-aware ApplicationContext. A longer-lived singleton that needs a request-scoped bean should receive a scoped proxy rather than a raw instance. The proxy resolves the current request’s bean on each call. Caching a request-scoped instance in a singleton field, often as a workaround for an injection failure, pins that request’s objects to the singleton for as long as it lives.
Rank #2
Diagnostic workflow
Run these steps in order. Each one narrows the search, and skipping one usually sends you after the wrong owner. Oracle’s guidance is versioned, and command options vary between JDK releases, so match the documentation to the runtime you actually deploy.
1. Baseline the growth
Record the build identifier, the Spring Framework and Spring Boot versions, the JDK version from java -version, the heap flags such as -Xms and -Xmx, the workload shape, and the garbage collector in use. Enable GC logging so collection timing is on record:
-Xlog:gc*:file=gc.log:time,uptime
Drive a repeatable workload through several full collections. Used heap after collection should form a sawtooth with a floor. A leak shows as a floor that rises from run to run. If the Actuator metrics endpoint is exposed, /actuator/metrics/jvm.memory.used?tag=area:heap gives a sampled used-heap trend. Because it is not measured after collection, use it for context and base the decision on post-collection evidence.
2. Track the classes that grow
Capture class histograms at fixed intervals under the same workload:
jcmd <pid> GC.class_histogram > histogram-01.txt
Compare instance counts and bytes between captures. Oracle recommends jcmd for current diagnostics and notes that a sequence of histograms reveals trends. By default the histogram counts only live objects, so each capture forces a collection and pauses the process; schedule captures deliberately on production instances. A histogram shows which types are accumulating, but not what holds them. A steadily rising count of HashMap$Node entries is a clue, not an owner.
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Scan for outdated or missing drivers - takes under a minute3Clear out junk files and repair common Windows errors3. Capture a heap dump
jcmd <pid> GC.heap_dump filename=heapdump.hprof
Check the exact syntax against the deployed JDK. Use an absolute path when the JVM’s working directory is unclear. In containers, run jcmd as the same user as the JVM, or from inside the container, so it can attach to the process. For failures you cannot reproduce on demand, start the JVM with -XX:+HeapDumpOnOutOfMemoryError and -XX:HeapDumpPath=/var/dumps/. That dump reflects state at the moment of failure and does not replace trend diagnosis.
Rank #3
Oracle’s guide states that “Heap dumps are the most important data to troubleshoot memory leaks,” and they are also a copy of whatever the application held in memory: request payloads, tokens, and personal data. Restrict access, store the file under your data-handling rules, and delete it on schedule.
4. Trace the retention path
Open the dump in a heap-analysis tool such as Eclipse Memory Analyzer. Start from the class that grew, pick a representative instance, and request the path to GC roots, excluding weak and soft references so the path shows strong retention. Walk up the path until you reach the first owner you control: a singleton field, a static field, a cache, a listener collection, or a thread-local map. Framework objects in the middle of the path, such as a registry, usually point to the application object that registered something, so keep walking until you reach that object.
Verify each static hypothesis in the dump itself. Find the actual static field, read its value, and confirm that it reaches the growing objects. A class that declares static fields is not evidence of a leak.
Windows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallCrashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minute5. Use JFR when timing or allocation matters
A single dump is a snapshot; Java Flight Recorder shows live objects and top growers over time. Start a recording for the suspected window:
jcmd <pid> JFR.start name=leak settings=profile duration=15m filename=leak.jfr
Oracle recommends recordings with heap statistics for observing live objects. Recording GC-root paths through the path-to-gc-roots=true option of -XX:StartFlightRecording is time-consuming and adds overhead, so enable it only when you already suspect a leak, and account for that overhead on the instance you are investigating.
6. Check the bean lifetime contract
Ask four questions of each bean on the retention path:
Rank #4
- Does it hold mutable state that changes per request, per call, or per subscription?
- Is it a singleton by default, with no scope annotation, when its work is really per operation?
- Is a prototype injected into it, so every call receives the same instance?
- Does a longer-lived bean reference a shorter-lived one without a proxy or lookup?
7. Fix the confirmed owner, then repeat the same capture
Change only the owner that the heap graph confirmed. Then rerun the identical workload and repeat the histogram, dump, or JFR capture at the same intervals. The examples in the next two sections show the patterns this step usually uncovers.
The Tool Desk
Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Owners hidden in static and singleton state
Static fields and maps
A static map is reachable for as long as its class loader lives. Any entry that is not removed on every code path, including error paths, keeps its value alive:
public final class RequestRegistry {
private static final Map<String, RequestState> ACTIVE = new ConcurrentHashMap<>();
public static void open(String id, RequestState state) { ACTIVE.put(id, state); }
public static void close(String id) { ACTIVE.remove(id); }
}
If close is skipped when a request throws, every failed request leaves a RequestState behind. In the dump, the entries of ACTIVE identify the leaked states directly. The fix is to move the removal into a finally block, or to replace the map with a cache that has a size limit and an expiry.
Singleton fields and caches
A singleton field that collects per-operation records has the same effect as a static map, and it is easy to miss because the class looks ordinary:
@Service
public class PricingService {
private final List<QuoteAudit> audits = new ArrayList<>();
public Quote price(Order order) {
Quote quote = calculate(order);
audits.add(new QuoteAudit(order, quote)); // never cleared
return quote;
}
}
Remove the in-memory list and write audit records to a store that suits their retention needs. If a cache is genuinely required, use a bounded cache library with a maximum size and time-based eviction, such as Caffeine. The unsynchronized ArrayList is also unsafe under concurrent requests, so the bug is not only a memory problem.
Listeners and registries
A singleton that registers a listener for each operation keeps every listener, and everything the listener captures, alive until something unregisters it. In the dump, look for a listener collection whose entries reference per-operation objects. Unregister in a finally block or when the subscription’s lifecycle ends.
ThreadLocal values on pooled threads
A thread-local value lives in the map of the thread that set it. Pooled threads outlive any single request, so a value set during one request stays reachable until the thread removes or overwrites it:
private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();
public void handle(TenantContext ctx) {
CONTEXT.set(ctx);
try {
process();
} finally {
CONTEXT.remove();
}
}
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Choosing the fix for each lifetime
Match each owner to the lifetime the business operation actually needs. Five axes decide the fix: the required lifetime, whether the object carries mutable per-request state, whether the dependency must be reacquired on each call, who owns cleanup, and what the heap evidence shows about the owner. Spring supports singleton, prototype, and web scopes, and the right choice follows from lifecycle semantics rather than from making every bean a prototype.
| Owner found in the dump | Lifetime the operation needs | Fix that matches it | What to confirm afterwards |
|---|---|---|---|
| Singleton field holding per-request mutable data | One request | Remove the field; pass data as method arguments, or hold it in a request-scoped bean | No per-request object is reachable from the singleton after the workload ends |
| Prototype injected into a singleton, caller needs a fresh instance per call | One call | Inject ObjectProvider<T> and call getObject() per use, or use a method with @Lookup |
Each call gets a new instance, and the caller closes its resources |
| Request-bound bean used by a singleton | One request | Use a scoped proxy on the request-scoped bean | The singleton holds only the proxy and no request object after the request completes |
| Static map or cache without eviction | Bounded, or per operation | Remove entries in a finally block, or use a bounded cache |
Entry count plateaus under steady workload |
| Listener or registry entry never removed | Subscription lifetime | Unregister when the lifecycle ends | Listener count returns to baseline after each operation |
| ThreadLocal value on a pooled thread | One request | Call remove() in a finally block |
No stale request values remain on worker threads |
The Spring Framework reference also says “As a rule, you should use the prototype scope for all stateful beans and the singleton scope for stateless beans.” Treat that as a starting point rather than a recipe. Concurrency safety, object cost, and cleanup duties still need review before a bean changes scope.
When the caller needs a fresh prototype on each use, inject a provider and ask it for an instance at the point of use. The singleton keeps only the provider:
@Component
@Scope("prototype")
public class ReportJob implements AutoCloseable {
private final StringBuilder buffer = new StringBuilder();
public void execute() {
buffer.append("rendered");
}
@Override
public void close() {
buffer.setLength(0);
}
}
@Service
public class ReportService {
private final ObjectProvider<ReportJob> jobs;
public ReportService(ObjectProvider<ReportJob> jobs) {
this.jobs = jobs;
}
public void run() {
try (ReportJob job = jobs.getObject()) {
job.execute();
}
}
}
For a request-bound dependency used by a singleton in a web application, annotate the request-scoped bean with @RequestScope, which uses a proxy by default, and inject it normally:
@Component
@RequestScope
public class AuditTrail {
private final List<String> events = new ArrayList<>();
public void record(String event) {
events.add(event);
}
}
@Service
public class OrderService {
private final AuditTrail audit;
public OrderService(AuditTrail audit) {
this.audit = audit;
}
}
Verifying the fix
- Rerun the same workload, under the same GC conditions, with the same capture intervals as the baseline.
- Used heap after collection stops rising across several full collections, not just the first one after the change.
- The suspect class count plateaus, and a fresh dump no longer shows a path from GC roots to the application owner you changed.
- If the bean’s scope changed, load-test concurrent requests and confirm that no mutable state is shared between them.
- Native memory, if you tracked it, is flat or accounted for.
A process restart or a larger -Xmx does not count as verification, because both can hide the trend without removing the reference.
Quick Recap
When the evidence points elsewhere
- Class counts rise after each context refresh or redeploy. Suspect class-loader retention. Oracle’s guide describes class-loader statistics for this case. Look for an old loader still reachable from a static field or a thread.
- The retention path ends at a JDK or framework class with no application object above it. Inspect the thread that holds it, such as an executor queue or a thread-local map, and its stack in the dump.
- The failure happens before histograms can be captured. A dump taken at the out-of-memory point may not show the state you need. Reproduce at lower load and capture earlier, at the intervals in the baseline.
- Histogram counts stay flat while used heap grows. Compare bytes per class, not only counts. A few large retained structures can grow while object counts barely move.
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




