Microbenchmarking Fundamentals: JVM Optimization Pitfalls in Java

The problem

You write a microbenchmark in Java to measure the performance of a method. You hit run, get a number, and call it done.

The problem is that the JVM doesn’t execute your code the way you expect. Hot methods get compiled into native code by the JIT (Just-In-Time) compiler. Methods with unused results get eliminated entirely. Memory writes can reorder across threads. And data sitting in cold cache lines costs dozens of cycles more than hot ones.

Any of these factors can make a benchmark measure “almost nothing” when you expected heavy work, or miss a real optimization that would help production. This post walks through four pitfalls every Java microbenchmark needs to handle — with a single runnable program that shows what happens when you get each one wrong and how to fix it.

The code

The file BenchmarkPitfalls.java has four self-contained demos, all runnable from the command line:

import java.util.concurrent.atomic.AtomicLong;

class BenchmarkPitfalls {

    // volatile sink — prevents C2 from eliminating computation
    private static volatile long sink = 0;

    // ===== 1. Dead Code Elimination: guard vs no-guard =====
    static class GuardedVsUnGuarded {
        void run() throws InterruptedException {
            int benchmarkIterations = 5_000_000;
            int measurementRuns = 20;

            System.out.println("=== Dead Code Elimination: guard vs no-guard ===");

            // Guarded: volatile sink forces C2 to keep the work
            long totalGuardedNs = 0;
            for (int m = 0; m < measurementRuns; m++) {
                long start = System.nanoTime();
                doWork(benchmarkIterations); // has volatile sink!
                totalGuardedNs += System.nanoTime() - start;
            }
            double avgGuardedMs = (double) totalGuardedNs / measurementRuns / 1_000_000.0;

            // Warm up with sink-protected calls so C2 compiles doWork
            for (int w = 0; w < 5_000; w++) {
                doWork(50_000);
            }

            // Un-guarded: void method discards result —
            // C2 sees no side effects and eliminates the loop!
            long totalPureNs = 0;
            for (int m = 0; m < measurementRuns; m++) {
                long start = System.nanoTime();
                unguardedMeasure(benchmarkIterations);
                totalPureNs += System.nanoTime() - start;
            }
            double avgPureMs = (double) totalPureNs / measurementRuns / 1_000_000.0;

            // Inner-loop test: tight loop of pure calls
            int innerLoops = 5_000_000;

            long startG = System.nanoTime();
            for (int m = 0; m < innerLoops; m++) {
                doWork(1);
            }
            long elapsedG = (System.nanoTime() - startG) / 1_000_000L;

            for (int w = 0; w < 5_000; w++) {
                doWork(50_000);
            }

            long startP = System.nanoTime();
            for (int m = 0; m < innerLoops; m++) {
                pureCompute(1);
            }
            long elapsedP = (System.nanoTime() - startP) / 1_000_000L;

            System.out.printf("  Guarded   (volatile sink): %.3f ms%n", avgGuardedMs);
            System.out.printf("  Pure      (void method, no side effects): %.3f ms%n", avgPureMs);

            System.out.printf("%n  --- Inner-loop test (%d iterations) ---%n", innerLoops);
            System.out.printf("  Guarded: %d ms%n", elapsedG);
            System.out.printf("  Pure:    %d ms <<< DCE killed it! >>>%n", elapsedP);
        }

        static long doWork(int iterations) {
            long result = 0;
            for (int i = 0; i < iterations; i++) {
                result += i * i + i;
            }
            sink = result; // VOLATILE: prevents DCE
            return result;
        }

        static long pureCompute(int iterations) {
            long result = 0;
            for (int i = 0; i < iterations; i++) {
                result += i * i + i;
            }
            return result; // NO volatile store — purely local!
        }

        static void unguardedMeasure(int iterations) {
            for (int m = 0; m < 100; m++) {
                pureCompute(iterations);
            }
        }
    }

    // ===== 2. JIT warm-up curve =====
    static class WarmUpCurve {
        void run() throws InterruptedException {
            int iterations = 2_000_000;
            int runs = 25;

            System.out.printf("=== JIT warm-up curve (%%d iterations per step) ===%n", iterations);

            long[] timings = new long[runs];
            for (int i = 0; i < runs; i++) {
                long start = System.nanoTime();
                sink = 0;
                long result = 0;
                for (int j = 0; j < iterations; j++) {
                    result += j * j + j;
                }
                sink = result;
                timings[i] = (System.nanoTime() - start);
            }

            System.out.printf("  %-8s  %s%n", "Run", "Time (ms)");
            System.out.println("  " + "-".repeat(32));
            for (int i = 0; i < runs; i++) {
                double ms = timings[i] / 1_000_000.0;
                int bars = Math.max(1, Math.min(30, (int) (ms * 8)));
                String bar = new String(new char[bars]).replace('\0', '\u2588');
                System.out.printf("  %2d      %,8.4f ms   %s%n", i + 1, ms, bar);
            }

            double firstFiveAvg = 0;
            for (int i = 0; i < 5; i++) firstFiveAvg += timings[i];
            firstFiveAvg /= 5;

            double last5Avg = 0;
            for (int i = runs - 5; i < runs; i++) last5Avg += timings[i];
            last5Avg /= 5;

            int stableFrom = 1;
            for (int i = 0; i < runs; i++) {
                if (timings[i] <= last5Avg * 1.1) { stableFrom = i + 1; break; }
            }

            System.out.printf("%n  First-5 avg:   %.4f ms%n", firstFiveAvg / 1_000_000.0);
            System.out.printf("  Stable (runs %d+): ~%.4f ms%n", stableFrom, last5Avg / 1_000_000.0);
            double ratio = firstFiveAvg / Math.max(last5Avg, 0.0001);
            System.out.printf("  Early runs are %.1fx slower than steady state.%n%n", ratio);
        }
    }

    // ===== 3. Volatile memory ordering =====
    static class PublisherSubscriber {
        private int data = 42;
        volatile boolean ready = false;

        void publish() {
            data = 137;
            ready = true;
        }

        int readData() {
            if (!ready) return -1;
            return data;
        }

        boolean isReady() { return ready; }

        void reset() { data = 42; ready = false; }
    }

    static class VolatileOrderingTest {
        void run() throws InterruptedException {
            PublisherSubscriber ps = new PublisherSubscriber();
            AtomicLong observedLateReads = new AtomicLong(0);
            final int cycleCount = 5;

            for (int trial = 0; trial < cycleCount; trial++) {
                ps.reset();
                Thread publisher = new Thread(() -> {
                    try { Thread.sleep(10); } catch (InterruptedException e) {}
                    ps.publish();
                });
                Thread subscriber = new Thread(() -> {
                    while (!ps.isReady()) {}
                    int data = ps.readData();
                    if (data != 137) observedLateReads.incrementAndGet();
                });
                publisher.start();
                subscriber.start();
                publisher.join(5000);
                subscriber.join(5000);
            }

            System.out.println("=== Volatile memory ordering ===");
            System.out.printf("  Cycles run:    %d%n", cycleCount);
            System.out.printf("  Late reads:    %d (expected 0)%n%n",
                observedLateReads.get());

            if (observedLateReads.get() == 0) {
                System.out.println("  volatile ensures publication ordering — subscriber");
                System.out.println("  always sees data=137 after seeing ready=true.");
            } else {
                System.out.printf("  WARNING: %d late reads detected!\n",
                    observedLateReads.get());
                System.out.println("  volatile should prevent this on any JSR-133 compliant JVM,");
                System.out.println("  but this can happen if ready were not volatile.");
            }
        }
    }

    // ===== 4. Cache-line false sharing =====
    static class FalseSharingBenchmark {
        volatile long a;
        volatile long b;

        static class PaddedLong {
            long p0, p1, p2, p3, p4, p5, p6, p7; // 8 * 8 = 64 bytes padding
            volatile long value;
            long v9, v10, v11, v12, v13, v14, v15, v16;

            void add(long delta) { value += delta; }
        }

        PaddedLong pa = new PaddedLong();
        PaddedLong pb = new PaddedLong();

        void run() throws InterruptedException {
            int iterations = 50_000_000;
            int numRuns = 3;

            System.out.println("=== False sharing (cache-line alignment) ===");

            long totalNoPadMs = 0;
            for (int r = 0; r < numRuns; r++) {
                a = 0; b = 0;
                long start = System.nanoTime();
                for (int i = 0; i < iterations; i++) {
                    a += i;
                    b += (long) i * 2;
                }
                totalNoPadMs += (System.nanoTime() - start) / 1_000_000;
            }
            double avgNoPad = totalNoPadMs / numRuns;

            long totalPaddedMs = 0;
            for (int r = 0; r < numRuns; r++) {
                pa.value = 0; pb.value = 0;
                long start = System.nanoTime();
                for (int i = 0; i < iterations; i++) {
                    pa.add(i);
                    pb.add((long) i * 2);
                }
                totalPaddedMs += (System.nanoTime() - start) / 1_000_000;
            }
            double avgPadded = totalPaddedMs / numRuns;

            System.out.printf("  Unpadded (volatile a,b): %.3f ms%n", avgNoPad);
            System.out.printf("  Padded (PaddedLong):     %.3f ms%n", avgPadded);
            double speedup = avgNoPad / Math.max(avgPadded, 1.0);
            System.out.printf("  Speedup:                 %.2fx%n%n", speedup);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new GuardedVsUnGuarded().run();
        new WarmUpCurve().run();
        new VolatileOrderingTest().run();
        new FalseSharingBenchmark().run();
    }
}

Running it

The output shows what each pitfall looks like in practice.

DCE: single-call vs tight-loop

The first section compares guarded and unguarded timing. In the first part (20 measured runs), both paths ran at roughly the same speed (~2.7 ms) because C2 needs more aggressive optimization to eliminate a single isolated call. But then the inner-loop test tells the real story: after 5 million iterations, the pure version runs in 0 ms — C2 completely eliminated the loop body.

This is exactly how microbenchmarks break in practice. A method that computes i*i + i in a tight loop with no observable side effects becomes free. The guard (a volatile store to sink) forces the JIT to preserve the work.

JIT warm-up

The second section measures 25 consecutive runs and plots each as a bar chart:

   1        3.2795 ms   ██████████████████████████
   2        3.3638 ms   ██████████████████████████
   3        1.0286 ms   ████████
   4        1.0286 ms   ████████
   ... (runs 5-25 all ~1.03ms) ...

  First-5 avg:   1.9474 ms
  Stable (runs 3+): ~1.0286 ms
  Early runs are 1.9x slower than steady state.

The first two runs took 3.3 ms — more than triple the steady-state 1.03 ms. By run 3, C2 had already compiled and optimized the hot loop, cutting execution time nearly in half. The JIT warm-up period can last from a handful of iterations to tens of thousands depending on method complexity.

Volatile memory ordering

The third section spins up a publisher/subscriber thread pair that alternately sets data=137; ready=true and reads back the data after spinning on ready. The volatile keyword here acts as a memory barrier — it guarantees that the write to data happens before the write to ready, so any thread that observes ready == true will also see data == 137. This is the happens-before relationship defined by JSR-133: a volatile store happens-before every subsequent volatile load of the same variable.

Cache-line false sharing

The fourth section writes to two volatile long fields side-by-side (unpadded) versus with padding between them. On this platform:

  Unpadded (volatile a,b): 445.000 ms
  Padded (PaddedLong):     423.000 ms
  Speedup:                 1.05x

The unpadded version is 1.05× slower. Even in this single-threaded test, both fields are volatile, which on some architectures implies a memory barrier per access — and the two adjacent volatile writes can contend for the same cache line through other CPU cores’ snooping. Padding each field to its own cache line (64 bytes) prevents that contention.

The effect is real but modest here because this benchmark runs sequentially within one thread. In a multi-threaded workqueue or ring buffer where producer and consumer write adjacent counters, the penalty can be several times slower — that’s why @sun.misc.Contended padding exists in JDK internals.

Takeaway

Every JVM microbenchmark needs three things: a volatile sink to prevent dead code elimination, warm-up iterations before measurement, and cache-line awareness for hot paths touching multiple variables. Without all three, you’re not measuring the code you think you are — you’re measuring the compiler’s ability to make it disappear. }