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:
| Pattern | What allocates | Fix |
|---|---|---|
List<Integer> with int values | Integer box per element | Use int[] or primitive collections |
StringBuilder with default capacity | Grows by doubling (System.arraycopy) | Pre-size with expected length |
.parallel() on small arrays | ForkJoinTask objects per split | Only 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:
- JMH annotation processor generated
META-INF/BenchmarkList. - Each benchmark class executes without throwing.
- 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.