skip to content

Dead-Code Elimination & Blackhole

If nothing consumes your benchmark's result, the JIT deletes the work and you measure an empty loop — so return the value or feed it to a Blackhole. Constant folding and unrolled in-benchmark loops are the sibling traps interviewers like to raise.

part ofJavaoverview, primer and where to startread it →
on this pageshow

questions

5

What is dead-code elimination in the context of a JMH benchmark, and why can it make a microbenchmark report a misleadingly fast result?

level: juniorimportance: must knowfreq 70%

answer

  1. Unused result -> JIT deletes the work
  2. Return the value or Blackhole.consume it
  3. Empty/near-zero score = suspect DCE
  4. JMH defends against the trap
  5. C2 proves result unobserved -> removes

basics

~20 s

Dead-code elimination is when the JVM deletes code whose result is never used. In a benchmark, if you compute something but never use the value, the JIT may delete the work, so the benchmark times nothing and reports an impossibly fast result.

solid answer

~40 s

Dead-code elimination (DCE) is a standard compiler optimization: if a computation's result is never observed, it has no effect on the program, so the compiler is free to delete it. In a JMH microbenchmark this is a trap. If your @Benchmark method computes a value but discards it, the JIT (HotSpot's C2 compiler) can prove the result is unused and remove the whole calculation. The benchmark then measures an empty method and reports a wildly fast, meaningless score (often near the cost of an empty loop). The fix is to make the result observable: return it from the @Benchmark method so JMH consumes it, or pass it to a Blackhole. JMH was built specifically to make these defenses easy, because hand-written benchmarks routinely fall into this trap.

go deeper

for a junior

Can state that unused results may be deleted by the JIT and knows to return the value from the benchmark.

for a middle

Explains it as C2 dead-code elimination, knows both fixes (return value vs Blackhole), and recognizes a near-zero score as the symptom.

for a senior

Reasons about why DCE is correct for real code but a hazard for benchmarks, and can pick return-vs-Blackhole based on number of results and loop structure.

for a principal

Frames JMH's whole design (return-consume, Blackhole, @State) as defenses against JIT optimizations, and can audit a benchmark for all the traps (DCE, constant folding, loop unrolling) before trusting its numbers.

## What is a benchmark and why does this problem exist A **microbenchmark** measures how long a small piece of code takes to run. **JMH** (Java Microbenchmark Harness) is the standard OpenJDK tool for writing them; you annotate a method with `@Benchmark` and JMH calls it many times and reports the average time. The **JIT compiler** ('Just-In-Time') is the part of the JVM that, while your program runs, translates hot bytecode into optimized native machine code. HotSpot has two JIT compilers: **C1** (fast, lightly optimized) and **C2** (slower to compile but heavily optimized). C2 applies aggressive optimizations — and the one that breaks benchmarks is **dead-code elimination**. ## What dead-code elimination (DCE) is **Dead code** is code whose result is never used and that has no observable side effect. A compiler is allowed to *delete* dead code because removing it cannot change the program's output — that is the whole point of the optimization, and it is correct for real programs. Example of dead code: ```java int x = expensiveComputation(); // x is never read again // ... x unused ... ``` Since `x` is never observed, `expensiveComputation()` could be removed entirely (assuming it has no side effects). ## Why it ruins a benchmark In a *real* program, you compute things to use them, so DCE rarely deletes anything important. But in a *benchmark* you often compute something purely to time it and then throw the result away — which is exactly the pattern DCE targets. Consider: ```java @Benchmark public void measure() { Math.log(value); // result discarded } ``` C2 sees the result of `Math.log` is never used, proves it has no side effect, and deletes the call. Your `@Benchmark` method becomes effectively empty. JMH then reports a time close to the cost of an empty method call (nanoseconds or less) — a number that has nothing to do with `Math.log`. You'd conclude `Math.log` is essentially free, which is false. ## How to defend against it The rule: **make every result observable** so the compiler cannot prove it is dead. 1. **Return the value.** If a `@Benchmark` method returns its result, JMH takes the returned object and feeds it into a Blackhole internally, so the JIT must actually produce it: ```java @Benchmark public double measure() { return Math.log(value); // returned -> consumed by JMH } ``` 2. **Use a `Blackhole`** when you produce more than one result or a result mid-loop. A Blackhole is a JMH-provided 'sink' you pass values to; it is engineered so the JIT cannot optimize the consumption away: ```java @Benchmark public void measure(Blackhole bh) { bh.consume(Math.log(value)); bh.consume(Math.exp(value)); } ``` ## Key takeaway DCE is a *correct* optimization that becomes a *measurement hazard* in benchmarks. JMH exists largely to give you the tools (returning values, `Blackhole`) to stop the JIT from deleting the very work you are trying to measure. If a benchmark reports a suspiciously tiny or zero time, suspect DCE first.

  • If returning the value already defeats DCE, why does Blackhole exist at all?
    A @Benchmark method can return only one value. When you produce several results, or a result inside a loop, or want to time a void operation, you need a sink for each — Blackhole.consume() lets you sink any number of values without the JIT folding or eliminating them.
  • How would you notice DCE happened just from the JMH output?
    The score is implausibly low — often the same as a baseline empty benchmark, or near the timer resolution. JMH also prints a warning hinting your benchmark may be optimized away if it can detect it.

saying these in an interview costs you the question

  • Thinking DCE is a JMH bug rather than a correct general compiler optimization
  • Believing returning void from a benchmark is fine when you compute a single result
  • Concluding a method is 'basically free' from a near-zero score without checking for DCE
  • Assuming -Xint or disabling the JIT is the right fix (it makes the benchmark unrepresentative)

context

open as a page

When should you return a value from a @Benchmark method versus using a Blackhole, and how do you sink multiple results correctly?

level: middleimportance: must knowfreq 68%

basics

~20 s

Return the value when your benchmark produces a single result. Use a Blackhole when you produce more than one result, or a result inside a loop, calling bh.consume() on each so none of them gets deleted by the JIT.

open as a page

What is constant folding in a benchmark, and how does the JMH @State pattern prevent the JIT from precomputing your inputs?

level: seniorimportance: must knowfreq 60%

basics

~20 s

Constant folding is when the JIT precomputes an expression whose inputs are compile-time constants, so the benchmark never actually runs the work. Putting inputs in a non-final @State field that the JIT can't treat as constant forces the real computation to happen every time.

open as a page

Why are manual loops inside a @Benchmark body discouraged, and what JIT optimizations can corrupt the measurement?

level: seniorimportance: should knowfreq 52%

basics

~20 s

A loop inside a benchmark can be unrolled or partly optimized by the JIT, and repeated identical work can be folded so only one iteration really runs. Prefer letting JMH do the repetition, and if you must loop, consume each iteration's result and vary the inputs.

open as a page

You inherit a JMH benchmark reporting 0.3 ns/op for a non-trivial computation. How do you systematically audit whether the JIT optimized the work away?

level: principalimportance: should knowfreq 40%

basics

~20 s

A sub-nanosecond result for real work is a red flag. Check that inputs come from non-final @State fields (not constants), that every output is returned or Blackhole-consumed, and that any loop varies its data. Then re-run, ideally inspecting the generated assembly to confirm the work runs.

open as a page