Lab 02 — GC & Allocation Profiling

Goal: Measure the allocation cost of autoboxing, understand when parallel streams hurt rather than help, and confirm that pre-sizing a StringBuilder eliminates internal resizing allocations.


Prerequisites

  • JDK 11+ (java -version)
  • Maven 3.8+ (mvn -version)

Five-Step Lab

Step 1 — Understand the GC / allocation connection

Every object allocated on the heap is eventually collected. The more you allocate, the more often the GC runs and the longer your pauses. The core insight:

Allocation rate drives GC pressure. Reducing allocations reduces GC pauses, not just memory usage.

Three common allocation hot-spots in Java:

PatternWhat allocatesFix
List<Integer> with int valuesInteger box per elementUse int[] or primitive collections
StringBuilder with default capacityGrows by doubling (System.arraycopy)Pre-size with expected length
.parallel() on small arraysForkJoinTask objects per splitOnly parallelize large workloads

Step 2 — Read the benchmark annotations

All three benchmarks in this lab use:

@Fork(0)  // run in the test JVM (smoke-test safe; use Fork(1) for real data)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)

@Fork(0) lets Maven Surefire invoke the JMH runner inside the test JVM — no separate process needed. For production measurements, always use @Fork(1) or higher to get a clean JIT state.

Step 3 — Run the smoke tests

mvn clean test
# Expected: Tests run: 6, Failures: 0, Errors: 0

The smoke tests confirm:

  1. JMH annotation processor generated META-INF/BenchmarkList.
  2. Each benchmark class executes without throwing.
  3. Boxed and primitive sums agree, stream variants agree, and StringBuilder variants produce identical strings.

Step 4 — Run the real benchmarks

# Autoboxing cost: ArrayList<Integer> vs int[]
mvn compile exec:java \
  -Dexec.mainClass="org.openjdk.jmh.Main" \
  -Dexec.args="BoxingBenchmark -wi 3 -i 5 -f 1"

# Stream vs loop vs parallel stream
mvn compile exec:java \
  -Dexec.mainClass="org.openjdk.jmh.Main" \
  -Dexec.args="StreamVsLoopBenchmark -wi 3 -i 5 -f 1"

# StringBuilder capacity strategies
mvn compile exec:java \
  -Dexec.mainClass="org.openjdk.jmh.Main" \
  -Dexec.args="StringBuilderBenchmark -wi 3 -i 5 -f 1"

Step 5 — Interpret the results

BoxingBenchmark (n=1000) — expected:

Benchmark                    (n)  Mode  Cnt     Score  Error  Units
BoxingBenchmark.sumBoxed    1000  avgt    5  3241.442        ns/op
BoxingBenchmark.sumPrimitive 1000  avgt    5   441.811        ns/op

sumPrimitive is ~7× faster. The cost isn't just the Integer objects — it's the GC scanning them, the pointer-chasing during unboxing, and the cache misses from scattered heap objects vs. a contiguous int[].

StreamVsLoopBenchmark (n=1000) — expected:

Benchmark                          (n)  Mode  Cnt   Score  Error  Units
StreamVsLoopBenchmark.imperativeSum  1000  avgt    5  0.241        µs/op
StreamVsLoopBenchmark.streamSum      1000  avgt    5  0.314        µs/op
StreamVsLoopBenchmark.parallelStreamSum 1000 avgt  5  8.427        µs/op

parallelStreamSum is ~35× slower for n=1000 because ForkJoinPool task creation cost dwarfs the tiny summation. Parallelism pays off at n > 50 000.

StringBuilderBenchmark (n=1000) — expected:

Benchmark                          (n)  Mode  Cnt     Score  Error  Units
StringBuilderBenchmark.defaultCapacity 1000  avgt    5  68241.102  ns/op
StringBuilderBenchmark.preSized       1000  avgt    5  55312.847  ns/op

Pre-sizing shaves ~20% by eliminating ~6 capacity-doubling array copies.


Source Files

src/main/java/org/example/perf/
├── BoxingBenchmark.java        ← autoboxing ArrayList vs primitive int[]
├── StreamVsLoopBenchmark.java  ← imperative vs stream vs parallel stream
└── StringBuilderBenchmark.java ← default capacity vs pre-sized

src/test/java/org/example/perf/
└── AllocationTest.java         ← 3 JMH smoke tests + 3 correctness assertions

BoxingBenchmark.java

package org.example.perf;

import org.openjdk.jmh.annotations.*;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * Demonstrates the allocation cost of autoboxing.
 *
 * <p>
 * sumBoxed() stores integers in an ArrayList<Integer>: every element is an
 * autoboxed Integer object on the heap, adding GC pressure for n allocations.
 *
 * <p>
 * sumPrimitive() stores integers in an int[]: no heap allocation per element,
 * only the array itself — roughly 5–10× faster for large n.
 */
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Fork(0)
public class BoxingBenchmark {

    @Param({ "1000" })
    int n;

    @Benchmark
    public long sumBoxed() {
        List<Integer> list = new ArrayList<>(n);
        for (int i = 0; i < n; i++) {
            list.add(i); // autoboxing: int -> Integer
        }
        long sum = 0;
        for (Integer val : list) {
            sum += val; // unboxing: Integer -> int
        }
        return sum;
    }

    @Benchmark
    public long sumPrimitive() {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = i;
        }
        long sum = 0;
        for (int val : arr) {
            sum += val;
        }
        return sum;
    }
}

StreamVsLoopBenchmark.java

package org.example.perf;

import org.openjdk.jmh.annotations.*;

import java.util.Arrays;
import java.util.concurrent.TimeUnit;

/**
 * Compares imperative sum, sequential stream sum, and parallel stream sum.
 *
 * <p>
 * Key insight: parallel stream has thread-pool overhead that dominates for
 * small arrays. It only pays off when n is large enough that parallelism beats
 * the ForkJoinPool cost (typically n > 10_000 on most hardware).
 */
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Fork(0)
public class StreamVsLoopBenchmark {

    @Param({ "1000" })
    int n;

    private int[] data;

    @Setup(Level.Trial)
    public void setup() {
        data = new int[n];
        for (int i = 0; i < n; i++) {
            data[i] = i;
        }
    }

    @Benchmark
    public long imperativeSum() {
        long sum = 0;
        for (int v : data) {
            sum += v;
        }
        return sum;
    }

    @Benchmark
    public long streamSum() {
        return Arrays.stream(data).asLongStream().sum();
    }

    @Benchmark
    public long parallelStreamSum() {
        return Arrays.stream(data).parallel().asLongStream().sum();
    }
}

StringBuilderBenchmark.java

package org.example.perf;

import org.openjdk.jmh.annotations.*;

import java.util.concurrent.TimeUnit;

/**
 * Illustrates how pre-sizing a StringBuilder avoids internal array resizing.
 *
 * <p>
 * StringBuilder.append() doubles the internal char[] when capacity is
 * exceeded (System.arraycopy + new allocation). Pre-sizing to n * 5 (generous
 * estimate: average 5 chars per token) prevents any resize for typical inputs.
 */
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Fork(0)
public class StringBuilderBenchmark {

    @Param({ "100", "1000" })
    int n;

    @Benchmark
    public String defaultCapacity() {
        StringBuilder sb = new StringBuilder(); // initial capacity: 16
        for (int i = 0; i < n; i++) {
            sb.append("token").append(i);
        }
        return sb.toString();
    }

    @Benchmark
    public String preSized() {
        StringBuilder sb = new StringBuilder(n * 9); // "token" + up to 4 digits
        for (int i = 0; i < n; i++) {
            sb.append("token").append(i);
        }
        return sb.toString();
    }
}

AllocationTest.java

package org.example.perf;

import org.junit.jupiter.api.Test;
import org.openjdk.jmh.results.RunResult;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.openjdk.jmh.runner.options.TimeValue;

import java.util.Collection;

import static org.junit.jupiter.api.Assertions.*;

/**
 * Smoke tests: run each benchmark class in-process (fork=0) with minimal
 * iterations to verify the harness starts, executes, and returns a result.
 *
 * <p>
 * Correctness tests: call benchmark methods directly to assert that boxed and
 * primitive variants produce the same numeric result, stream variants agree,
 * and
 * StringBuilder variants produce identical strings.
 */
class AllocationTest {

    // ---- helpers -------------------------------------------------------

    private static ChainedOptionsBuilder minimal(String benchmarkSimpleRegex) {
        return new OptionsBuilder()
                .include(benchmarkSimpleRegex)
                .forks(0)
                .warmupIterations(1)
                .warmupTime(TimeValue.milliseconds(100))
                .measurementIterations(1)
                .measurementTime(TimeValue.milliseconds(100));
    }

    // ---- JMH smoke tests -----------------------------------------------

    @Test
    void boxingBenchmarkRunsWithoutError() throws RunnerException {
        Options opts = minimal("BoxingBenchmark")
                .param("n", "100")
                .build();
        Collection<RunResult> results = new Runner(opts).run();
        assertFalse(results.isEmpty(), "Expected at least one JMH result");
    }

    @Test
    void streamVsLoopBenchmarkRunsWithoutError() throws RunnerException {
        Options opts = minimal("StreamVsLoopBenchmark")
                .param("n", "100")
                .build();
        Collection<RunResult> results = new Runner(opts).run();
        assertFalse(results.isEmpty(), "Expected at least one JMH result");
    }

    @Test
    void stringBuilderBenchmarkRunsWithoutError() throws RunnerException {
        Options opts = minimal("StringBuilderBenchmark")
                .param("n", "10")
                .build();
        Collection<RunResult> results = new Runner(opts).run();
        assertFalse(results.isEmpty(), "Expected at least one JMH result");
    }

    // ---- correctness tests ---------------------------------------------

    @Test
    void boxedSumMatchesPrimitiveSumForSameInput() {
        BoxingBenchmark bm = new BoxingBenchmark();
        bm.n = 500;
        assertEquals(bm.sumPrimitive(), bm.sumBoxed(),
                "boxed and primitive sums must agree");
    }

    @Test
    void streamSumMatchesImperativeSum() {
        StreamVsLoopBenchmark bm = new StreamVsLoopBenchmark();
        bm.n = 200;
        bm.setup();
        long imperative = bm.imperativeSum();
        assertEquals(imperative, bm.streamSum(), "stream sum mismatch");
        assertEquals(imperative, bm.parallelStreamSum(), "parallel stream sum mismatch");
    }

    @Test
    void preSizedBuilderProducesSameResult() {
        StringBuilderBenchmark bm = new StringBuilderBenchmark();
        bm.n = 50;
        assertEquals(bm.defaultCapacity(), bm.preSized(),
                "pre-sized and default StringBuilder must produce identical strings");
    }
}

Profiling Allocation with -prof gc

To see per-operation allocation rates (bytes allocated per invocation), add the -prof gc flag:

mvn compile exec:java \
  -Dexec.mainClass="org.openjdk.jmh.Main" \
  -Dexec.args="BoxingBenchmark -wi 3 -i 5 -f 1 -prof gc"

Look for the ·gc.alloc.rate.norm metric:

BoxingBenchmark.sumBoxed:·gc.alloc.rate.norm    1000  avgt    5  24040.008  B/op
BoxingBenchmark.sumPrimitive:·gc.alloc.rate.norm 1000 avgt    5   4016.003  B/op

sumBoxed allocates ~24 KB per operation (n Integer objects + ArrayList internal array) vs ~4 KB for sumPrimitive (just the int[] itself).


Interview Talking Points

  • Why is autoboxing expensive? — heap allocation per element, GC scanning, pointer indirection during unboxing, cache miss on scattered Integer objects.
  • When does parallel stream hurt? — for small data sets, ForkJoinPool task overhead exceeds parallelism benefit. Rule of thumb: parallelize when the operation per element is non-trivial or n > 10 000.
  • How does pre-sizing StringBuilder help? — avoids O(log n) capacity-doubling array copies; each doubling is an O(current_size) System.arraycopy.
  • What is escape analysis? — JIT analysis that determines if an object can never be referenced outside its allocating thread/method; if so, the JIT may allocate it on the stack instead of the heap (stack allocation), eliminating GC pressure entirely.