← Java articles

Java notes

Allocation Hotspot Hunting with JFR and async-profiler

Why collection-heavy code hurts your tail latency, and how to measure before optimizing

Shubham Anandani··~9 min read

Step 1: find allocation spikes with JFR

  • Which classes are allocated the most by bytes (not by count)?
  • Which call paths create them?
  • Does the allocation rate rise during the slow periods?

Step 2: corroborate with async-profiler

# Allocation profile for 30 seconds, written as an HTML flame graph
asprof -e alloc -d 30 -f alloc.html <pid>

Step 3: understand why boxing and collections allocate

  • HashSet<Integer> and HashMap<Integer, ...> box every primitive into an Integer object (small values are cached; larger ones are not) and allocate a node per entry.
  • Temporary lists and streams created per request.
  • String concatenation and formatting in hot paths.

Step 4: prove an alternative with JMH

import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

import java.util.HashSet;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.TimeUnit;

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
public class SetBenchmark {

    @Param({"1000", "100000"})
    int size;

    int[] values;

    @Setup
    public void setup() {
        Random r = new Random(42);
        values = new int[size];
        for (int i = 0; i < size; i++) values[i] = r.nextInt();
    }

    @Benchmark
    public void boxedHashSet(Blackhole bh) {
        Set<Integer> set = new HashSet<>();
        for (int v : values) set.add(v);
        int hits = 0;
        for (int v : values) if (set.contains(v)) hits++;
        bh.consume(hits);
    }

    @Benchmark
    public void primitiveSet(Blackhole bh) {
        IntOpenHashSet set = new IntOpenHashSet();
        for (int v : values) set.add(v);
        int hits = 0;
        for (int v : values) if (set.contains(v)) hits++;
        bh.consume(hits);
    }
}
java -jar target/benchmarks.jar -prof gc

Step 5: validate in the real service

Failure modes

  • Optimizing the wrong layer. A faster set does nothing if the allocation comes from JSON parsing upstream.
  • Benchmarking unrealistic data. Use sizes and distributions that match production.
  • Trading readability for nothing. Adopt a specialized library only where the profile justifies it.
  • Forgetting dependencies. A new library adds maintenance and upgrade cost.

Takeaways

  • Profile allocation with two tools before touching code.
  • Boxing and per-entry nodes in collections are common sources of garbage.
  • Prove a fix in JMH with -prof gc, then confirm with a production-like recording.