skip to content

Classic Compiler Optimizations

The standard optimizing passes the JIT applies — dead-code elimination, constant folding and propagation, loop unrolling, and range-check elimination. Interviewers cite them both to explain why compiled JVM code approaches native speed and why an unconsumed result in your benchmark simply disappears.

on this pageshow

questions

4

What do constant folding, constant propagation, and dead-code elimination do inside a just-in-time compiler, and how does each pass create new work for the others?

level: juniorimportance: should knowfreq 52%

answer

  1. SSA graph → folding, propagation, DCE, iterate
  2. constant lattice: unknown / one constant / not constant
  3. folded condition → whole branch arm deleted
  4. side effects and possible exceptions are never dead
  5. javac folds constant variables; JIT folds runtime facts

basics

~20 s

Constant folding evaluates expressions whose operands are already known at compile time. Constant propagation substitutes those known values into later uses. Dead-code elimination deletes computations nobody reads and branches that can never be taken. Each pass exposes fresh opportunities for the others.

solid answer

~60 s

These are three of the oldest optimizing-compiler passes, and a JIT runs them repeatedly over its intermediate representation. - **Constant folding** evaluates an operation whose operands are known values and replaces it with the result: `60 * 60 * 1000` becomes `3600000`. - **Constant propagation** rewrites every downstream use of a variable that provably holds a single known value into that literal, which usually creates newly foldable expressions. - **Dead-code elimination (DCE)** deletes any node whose result is never consumed by a side effect, a return, or a live branch — including a whole branch arm whose condition folded to a constant. They are mutually reinforcing, so the compiler iterates to a fixed point: folding feeds propagation, propagation folds a comparison to `true`, DCE drops the untaken arm, that removes the last use of another computation, DCE drops that too. A JIT is stronger here than the source compiler because after class loading and inlining it knows runtime facts — an initialized `static final` value, a configuration flag read once, an actual receiver type — that were not constants in the source text.

go deeper

for a junior

Define each of the three passes in one sentence and give a tiny example of folding an arithmetic expression and deleting an unused variable.

for a middle

Show the chain: inline, propagate, fold a condition, delete the untaken arm, delete the now-unused computation — and name what may never be deleted.

for a senior

Bring in SSA, the constant lattice with merge points, and the fact that runtime facts become compile-time constants only under an assumption the JVM must be able to back out of.

for a principal

Frame it as a budget question: DCE and folding shrink code, which buys inlining headroom and i-cache locality, and explain how you would decide whether an observed speedup is real work removed or work proven unobservable.

## Where these passes live HotSpot interprets bytecode first and compiles hot methods with an optimizing compiler (C1 or C2). The compiler translates bytecode into an internal graph in **SSA form** — static single assignment, meaning every value is written exactly once and each use points directly at the node that produced it. SSA is what makes these classic passes cheap: to know what a use holds, you follow one edge to its definition rather than searching the program. ## Constant folding Folding evaluates, at compile time, any operation whose inputs are literal values, and replaces the operation node with the result. `24 * 60 * 60` becomes `86400`; `"a" + "b"` on constants becomes `"ab"`; `Integer.valueOf(5).intValue()` can fold to `5` once the calls are inlined. Folding must respect exact language semantics: integer overflow wraps the same way the hardware would, division by a constant zero cannot be folded into a value because it must still throw `ArithmeticException`, and floating-point folding must reproduce IEEE-754 results bit for bit, so the compiler evaluates with the same rounding the target would use. Note that some folding already happened before the JVM ever ran the code. The Java Language Specification defines *constant variables* — `static final` primitives and `String`s with a constant initializer — and `javac` inlines their values into the callers' class files. The JIT's folding is the runtime continuation of that: it can fold things `javac` could not, because it sees loaded classes and inlined callee bodies. ## Constant propagation Propagation answers "what does this variable hold here?" and rewrites uses accordingly. In SSA, a value produced by a constant node is trivially propagated to every use. The interesting case is a merge point: if control flow joins from two paths that both produce `7`, the merge still holds `7`; if one produces `7` and the other `9`, it holds neither and propagation stops. This is the classic *sparse conditional constant propagation* lattice — each value is either unknown, exactly one constant, or definitely non-constant. Conditional propagation is the powerful form: it prunes edges as it goes, so a branch already proven untaken never contributes its values to a merge, letting more variables stay constant than a naive analysis would allow. ## Dead-code elimination DCE runs the liveness question backwards. Start from the roots that must be preserved — returns, thrown exceptions, stores to memory, calls that might have side effects, and the state needed to reconstruct an interpreter frame if the code deoptimizes — and mark everything reachable through use edges. Whatever is unmarked is deleted. Two shapes matter in practice: 1. **Unused results.** A pure computation (arithmetic, a field read the compiler can prove has no side effect, an object allocation that escape analysis proved is confined) whose result nobody reads simply disappears. 2. **Unreachable code.** When a condition folds to a constant, the untaken arm is unreachable and is removed wholesale, along with any code only that arm used. DCE cannot remove anything the JVM must still observe. A field write to a shared object stays. A method call the compiler cannot prove pure stays. And a check that could throw — a null check, a bounds check, a class cast — stays unless the compiler proves it can never fire, because the exception is an observable effect. ## Why the passes are run in a loop Each pass is a source of inputs for the others, so the compiler iterates until nothing changes: - inlining a small method exposes its argument as a known constant; - propagation pushes that constant into a comparison; - folding turns the comparison into `true`; - DCE deletes the `false` arm; - that deletion removes the last use of a computation, which DCE also removes; - the now-simpler graph may expose the next constant. This chain is why a heavily parameterised method, once inlined at a call site with fixed arguments, can compile down to a handful of instructions — and it is the same chain that makes a naive timing harness report near-zero cost for a computation whose result is thrown away. The compiler is not cheating; it simply proved the work was unobservable. ## What this buys Folding and propagation shrink the instruction count and free registers. DCE shrinks code size, which improves instruction-cache behaviour and lets the compiler afford more inlining within its budget. Together they are the reason JIT-compiled code is often compared with statically compiled C: the passes are the same textbook passes, run against better information because they run after the program has started.

  • Why can the JIT fold expressions that javac could not?
    javac sees only the source of one compilation unit and may fold only what the JLS calls constant variables. The JIT runs after classes are loaded and after inlining, so it knows the actual receiver type, the value stored in an initialized final field, and the arguments at a particular call site. Those runtime facts become compile-time constants for the method being compiled, at the cost of having to deoptimize if an assumption later breaks.
  • Name a computation the JIT is not allowed to delete even though its result is unused.
    Anything with an observable effect: a store to a field or array that another thread could read, a synchronized or volatile operation, a call the compiler cannot prove side-effect-free, and any operation that could throw — a division that could divide by zero, an array access that could go out of bounds, a null dereference. The exception itself is the observable result, so the check survives even when the value does not.

saying these in an interview costs you the question

  • Claiming the JIT deletes code that has side effects or could throw, as long as the value is unused
  • Thinking constant folding is only a javac feature and the JIT just translates bytecode
  • Assuming floating-point expressions are folded loosely, ignoring exact IEEE-754 reproduction
  • Saying dead-code elimination is about unreachable source lines only, missing unused-result elimination
  • Describing the passes as running once, in a fixed order, rather than iterating to a fixed point

context

open as a page

The Java language requires an ArrayIndexOutOfBoundsException on any out-of-range array access, so every array read carries a bounds check. How does an optimizing JIT compiler avoid paying for that check on each iteration of a hot loop, without breaking the exception semantics?

level: middleimportance: should knowfreq 46%

basics

~20 s

It proves the whole loop is in range once. The compiler hoists a single guard — the index starts at or above zero and the highest index is below the array length — before the loop, then compiles the loop body with no per-element check. If the guard fails, control goes to a slow path that runs checked code and throws at the right iteration.

open as a page

What is loop-invariant code motion in an optimizing compiler, and which conditions prevent it from hoisting a field load or a computation out of a loop?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Loop-invariant code motion hoists a computation whose inputs never change inside the loop to a single evaluation before the loop. It is blocked when an input might change per iteration: a write inside the loop, an unanalysable call, possible aliasing, acquire semantics on a volatile read, or an operation whose exception or side effect must stay inside the loop.

open as a page

HotSpot's C2 compiler has recognised a hot int-counted loop over an array and decided to unroll it. Describe the shape the compiled code actually takes — how the iteration space gets split up, what property the surviving hot loop's trip count must have, where SIMD (vector) instructions come from, and how safepoint polling is kept bounded inside such a loop. Then name the factors that cap how large an unroll factor the compiler will pick.

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

C2 splits the iteration space into pre-loop, main loop and post-loop. The main loop is the unrolled one: its trip count is a multiple of the unroll factor, its bounds checks are gone, and the superword pass fuses its copies into SIMD instructions. Strip mining wraps it so safepoints still get polled.

open as a page