If VisualVM and Java Flight Recorder (JFR) have already shown you that allocation pressure is high, I usually move from "where is memory going?" to "who is allocating it?" using progressively more targeted tools.
Here are the techniques I find most effective.
1. JFR Allocation Events (often underused)
Many people use JFR primarily for CPU or GC, but the allocation events are extremely valuable.
Enable events like:
ObjectAllocationInNewTLAB
ObjectAllocationOutsideTLAB
Then look for:
- Allocation hot methods
- Unexpected framework paths
- Request handlers creating large temporary graphs
This frequently identifies things like:
- repeated
ObjectMapper creation
- unnecessary
List copies
- excessive
StringBuilder
- lambda allocations
- regex compilation
- reflection-generated collections
2. Async Profiler Allocation Profiling
For production-like environments, allocation profiling with Async Profiler is one of the best tools available because the overhead is much lower than many traditional profilers.
Example:
./profiler.sh -e alloc -d 60 -f alloc.html <pid>
The flame graph shows:
- who allocated
- how much
- allocation call stacks
Sometimes CPU looks perfectly fine while allocation flame graphs immediately reveal millions of short-lived objects.
3. Allocation Sampling with Java Mission Control
Instead of only watching heap usage, sample allocations over time.
Questions I ask:
- Which endpoint spikes allocations?
- Which scheduled job?
- Which Kafka consumer?
- Which batch process?
Correlating allocation rate with application activity often narrows the search dramatically.
4. Heap Dump Differencing
Instead of a single heap dump:
- dump after startup
- dump after load
- dump after GC
- compare
Tools like Eclipse MAT help identify:
- retained size
- duplicate collections
- object dominators
- unexpected caches
Sometimes the problem isn't leaks—it's huge temporary graphs surviving multiple GCs.
5. Instrument Allocation Hotspots
If I suspect a service:
long before = Runtime.getRuntime().totalMemory()
- Runtime.getRuntime().freeMemory();
service.process();
long after = Runtime.getRuntime().totalMemory()
- Runtime.getRuntime().freeMemory();
This isn't precise because of GC timing, but it can quickly identify methods that deserve deeper profiling. For more accurate measurement, use JFR or an allocation profiler.
6. Watch for Spring "Convenience" APIs
Common hidden allocation sources include:
Rebuilding DTOs
orders.stream()
.map(OrderDto::new)
.toList();
On every request, this creates:
- stream pipeline objects
- lambdas
- DTOs
- lists
Perfectly reasonable individually, but expensive at high request rates.
Repeated Bean Creation
Example:
new ObjectMapper()
inside request handlers.
Instead:
@Bean
ObjectMapper mapper() { ... }
Repeated Regex
String.matches(...)
compiles patterns repeatedly.
Better:
private static final Pattern PATTERN = Pattern.compile(...);
Logging
log.debug("{}", expensiveObject.toString());
or
log.debug("Result: " + result);
Even when debug logging is disabled, string concatenation allocates. Parameterized logging avoids most unnecessary allocations.
7. Look at Allocation Rate, Not Just Heap Size
A service can have:
- 300 MB heap
- 8 GB/sec allocation rate
That causes frequent young-generation GCs even though memory usage appears stable.
Metrics worth monitoring include:
- allocation rate
- promotion rate
- survivor usage
- young GC frequency
- average object lifetime
High allocation throughput is often a better indicator of GC pressure than heap occupancy.
8. Benchmark Individual Methods
If a suspicious service method allocates heavily, use JMH with the GC profiler:
@Benchmark
public void processOrders() {
service.processOrders();
}
Run with:
-prof gc
This reports:
- bytes allocated per operation
- GC activity
- throughput
It's an excellent way to verify whether a code change actually reduces allocations.
9. Search for Hidden Collection Copies
These patterns are common allocation culprits:
new ArrayList<>(existingList)
map.values().stream().toList()
Collectors.toList()
Arrays.asList(...)
List.copyOf(...)
Repeated copying inside loops or request processing can create significant allocation churn.
10. Inspect Serialization Paths
JSON serialization often dominates allocation in Spring applications.
Look for:
- Jackson creating intermediate trees (
JsonNode)
- repeated DTO mapping
- converting objects to
Map<String, Object>
- serializing large collections
- unnecessary conversions between JSON and Java objects
Profiling serialization code frequently uncovers large numbers of temporary objects.
Common allocation hotspots in Spring services
In my experience, the most frequent sources of unexpected allocation pressure are:
- Jackson serialization/deserialization
- Stream-heavy transformations on hot paths
- Hibernate creating entity graphs and proxies
- Repeated DTO mapping (especially with reflection-based mappers)
- Logging with eager string construction
- Temporary collections inside loops
- Regex compilation
- Date/time formatter creation
- Reflection and bean introspection
- Large caches that constantly churn entries
The most productive workflow is usually: use JFR or Async Profiler to identify the hottest allocation stacks, correlate them with specific endpoints or background jobs, and then inspect those paths for repeated object creation or unnecessary intermediate collections. That approach tends to be much faster than manually reviewing Spring service code for potential allocations.