skip to content

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%

answer

  1. invariant = all operands defined outside the loop
  2. pre-header block holds the hoisted value
  3. opaque call or possible alias → load must stay
  4. volatile read cannot be hoisted (acquire, re-read required)
  5. may-throw needs a guard; hoisting also costs registers

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.

solid answer

~1 min

**Loop-invariant code motion (LICM)** identifies expressions inside a loop whose operands are all defined outside it, and evaluates them once in a pre-header block before the loop. Typical candidates: a pure arithmetic expression on loop-invariant values, an array-length load (array length is immutable), and a field load the compiler can prove nothing in the loop writes. It is blocked when the compiler cannot prove invariance or cannot prove hoisting is safe: - something in the loop **writes** the field or memory location, or a **call** the compiler could not inline might write it — the compiler must assume the worst; - **aliasing**: two references might point at the same object, so a store through one may change what the other loads; - the value is **volatile**, whose acquire semantics forbid collapsing repeated reads — the JVM must re-read it; - the operation could **throw or has a side effect**, so hoisting it would make it happen when the loop body would not have run, or would change where it happens; the compiler must first prove the loop executes at least once, or guard the hoisted copy. In HotSpot, hoisting a check-like condition into a loop predicate is the same machinery that drives bounds-check elimination.

go deeper

for a junior

Say the compiler computes an unchanging expression once before the loop instead of every iteration, and give the array-length example.

for a middle

Explain what makes a load invariant, and name the blockers: a store in the loop, an un-inlined call, and volatile reads.

for a senior

Bring in alias analysis, the zero-trip-count hazard for operations that can throw, guarded hoisting via loop predicates, and register pressure as a profitability limit.

for a principal

Frame it as the payoff chain from inlining to alias precision to hoisting, and discuss where you accept a compiler-opaque boundary deliberately (ordering-sensitive reads) versus where an opaque call is an accident worth removing.

## The idea If a computation inside a loop produces the same value on every iteration, computing it once is strictly better. Loop-invariant code motion is the pass that finds those computations and moves them into a **pre-header** — a block the compiler inserts on the single path into the loop — leaving the loop body to reuse the already-computed value. What counts as invariant is defined recursively: an operation is invariant if all its operands are defined outside the loop, or are themselves invariant operations. So `base * scale` where neither is written in the loop is invariant; so, by induction, is `(base * scale) + offset`. ## Why memory makes this hard Pure arithmetic is easy. Memory is where the analysis actually lives, because a load is invariant only if nothing in the loop can write the location it reads. The compiler builds a model of memory effects and asks, for each load, whether any store in the loop — or any call it could not analyse — might target the same location. Several things force a conservative "yes": - **A visible store in the loop** to the same field or array element. Obvious, and correct to respect. - **A call that was not inlined.** An opaque call could write any reachable field, so every load of a possibly-reachable location must be repeated after it. This is one of the underrated benefits of inlining: it converts opaque calls into analysable code and unlocks LICM. - **Aliasing.** If the loop stores through reference `p` and loads through reference `q`, and the compiler cannot prove `p != q`, the load must stay. Type information helps — a store to a `long[]` cannot alias a load from an `Object[]` field — but references of the same type frequently defeat it. Array length is the happy exception: it is immutable for the life of the array, so the length load is invariant as long as the array *reference* is not reassigned in the loop. That is why `i < a.length` costs nothing per iteration in compiled code. ## What else blocks hoisting **Ordering constraints.** A `volatile` read carries acquire semantics: it must not be reordered with the accesses that follow it, and the program is entitled to see a new value on each execution of the read. The compiler therefore cannot collapse repeated volatile reads into one hoisted load. The same holds for reads inside a synchronized region relative to the monitor operations, and for the ordering-sensitive accessors in `VarHandle`. This is the concrete reason a status flag intended to stop a loop must be declared appropriately — an ordinary field read is exactly the kind of load LICM is designed to hoist. **Speculative execution of work that might not run.** Hoisting moves a computation to a point that executes even if the loop body executes zero times. For pure arithmetic, that is harmless (worst case, wasted work). For anything that can throw — a division, a null dereference, an array access — or that has a side effect, it is not: the program could observe an exception the original never reached. The compiler therefore either proves the loop runs at least once, or moves the operation under a guard, or leaves it alone. HotSpot's loop predication is precisely the guarded form: the condition is hoisted into a predicate whose failure path deoptimizes to the interpreter, so the exception, if any, still happens in the right place. **Register pressure.** Even when hoisting is legal it is not always profitable. Every hoisted value occupies a register across the whole loop; hoist too many and the allocator spills to the stack, and the spill reloads cost more than the recomputation would have. Compilers apply heuristics here, and an aggressive-looking transformation can be declined for this reason alone. ## Its relatives LICM rarely acts alone. It is usually run alongside common-subexpression elimination (which merges identical computations wherever they appear), induction-variable simplification (which rewrites `i * stride + base` into a pointer advanced by a constant each iteration — strength reduction), and the loop predication that underpins bounds-check elimination. Together they turn a source-level loop full of repeated address arithmetic and checks into a body containing only the essential loads, arithmetic, and stores. ## What to take away If you are reasoning about why a loop is slower than expected, ask what stops the compiler from hoisting: an opaque call in the body, a store that might alias, a field read that is ordering-sensitive by design. And when you are reasoning about *correctness* rather than speed, remember the flip side — hoisting a repeated read of an ordinary field is a legal, expected transformation, so any loop whose termination depends on another party changing a field must use a mechanism that carries the required ordering rather than hoping the load is repeated.

  • Why does inlining a small method often unlock loop-invariant code motion?
    An un-inlined call is opaque: the compiler must assume it can write any reachable memory, so every field load in the loop has to be repeated after it. Inlining replaces the call with analysable code, letting the compiler see exactly which locations are written. Loads it can now prove untouched become invariant and get hoisted, which is why inlining is described as the enabling optimization rather than just a call-overhead saving.
  • Why is a repeated read of a volatile field never collapsed into one hoisted load?
    A volatile read has acquire semantics and is a synchronization action: the specification requires each execution of the read to be a real read of the current value, and forbids reordering subsequent accesses above it. Collapsing repeated reads into a single hoisted load would let the loop spin forever on a stale value and would break the ordering edges other code depends on. The compiler therefore keeps the load in the loop and emits whatever barrier the target architecture requires.

saying these in an interview costs you the question

  • Believing the compiler must re-read an ordinary field each iteration because another thread might change it
  • Assuming any expression not written in the loop can be hoisted, ignoring aliasing and opaque calls
  • Missing that hoisting a possibly-throwing operation changes observable behaviour when the loop runs zero times
  • Thinking manual hoisting into a local always helps — ignoring that it can also defeat other analyses and add register pressure
  • Confusing loop-invariant code motion with loop unrolling or with common-subexpression elimination

context