skip to content

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%

answer

  1. one guard before the loop, zero checks inside
  2. counted loop: int IV, constant stride, invariant limit
  3. loop predication → uncommon trap → interpreter throws correctly
  4. pre-loop / main loop / post-loop split
  5. a[idx[i]] indirection keeps its check

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.

solid answer

~60 s

Range-check elimination (RCE) works by turning many per-iteration checks into one check before the loop. For a **counted loop** — an integer induction variable with a known start, a constant stride, and a loop-invariant limit — the compiler knows the exact set of index values. It emits a *loop predicate* in front of the loop asserting `start >= 0` and `lastIndex < array.length` (plus a null check on the array), then compiles the body with the bounds checks deleted. When the trip count cannot be reduced to one predicate, HotSpot instead **splits the iteration space**: a pre-loop runs checked iterations until alignment and range are provable, a main loop runs fully check-free (and is the part that gets unrolled and vectorised), and a post-loop finishes the remainder. Semantics are preserved because the predicate is a real check. If it fails, the code takes an uncommon trap — it deoptimizes back to the interpreter, which re-executes the loop with checks and throws `ArrayIndexOutOfBoundsException` at exactly the iteration the original program would have. Nothing is removed; the cost is just moved out of the loop.

go deeper

for a junior

Say that every array access must be checked, and that the JIT proves the range once before the loop instead of on each element.

for a middle

Name counted loops and loop predication, and explain that a failing guard falls back to interpreted execution that throws at the correct iteration.

for a senior

Add the pre/main/post loop split, why the main loop is the one that gets unrolled and vectorised, and which loop shapes defeat the analysis.

for a principal

Discuss it as a safety-versus-throughput design point: memory safety is preserved by moving checks to guards plus a correct deoptimization path, and argue when hand-tuning a loop shape is worth the readability cost versus leaving it to the compiler.

## The obligation Java is memory-safe: reading `a[i]` must throw `ArrayIndexOutOfBoundsException` when `i` is negative or not less than `a.length`, and must dereference nothing in that case. Naively that means, for each access, load the array length, compare, and branch. In a tight loop over a large array those instructions can rival the useful work — and the branch also constrains reordering and vectorisation. Range-check elimination is the pass that removes the cost while keeping the guarantee. ## Counted loops and induction variables The pass depends on the compiler recognising a **counted loop**: one induction variable of type `int`, a start value, a constant stride, and a limit that does not change inside the loop. From those the compiler derives the range of every index expression that is an affine function of the induction variable (`i`, `i + 1`, `2*i + 3`, and so on). Once the compiler can say "across the whole loop, this index is in `[lo, hi]`", one comparison against `0` and one against `array.length` cover every iteration. This is why the idiomatic `for (int i = 0; i < a.length; i++)` shape optimizes so well. The limit is the array's own length, loaded once and known invariant because array length is immutable, so the predicate is trivially satisfied and the checks vanish outright. ## Loop predication HotSpot's mechanism is **loop predication**. Before the loop, the compiler emits a guard containing all the invariant conditions it wants to assume: the array is non-null, the first index is not negative, the last index is below the length. That guard branches to an *uncommon trap* — a stub that discards the compiled frame and resumes in the interpreter. Inside the loop the checks are then gone entirely, because the loop cannot be entered unless the guard held. If the guard fails at run time, the trap fires and the interpreter re-runs the loop with real per-access checks. It therefore performs exactly the side effects the original loop would have performed up to the bad index, then throws. An observer cannot tell the difference: the exception type, the iteration at which it fires, and everything written before it are unchanged. This is the general JVM contract for speculative work — optimize for the expected case, keep a correct slow path. ## Splitting the iteration space Some loops cannot be covered by a single predicate: the limit may be a variable the compiler cannot bound, the stride may not divide the range evenly, or vector code may require a particular memory alignment. HotSpot then splits the loop into three: - a **pre-loop** that runs a few checked iterations, used to reach an aligned or provably-safe starting point; - a **main loop**, entered only when the remaining range is provably in bounds — this one is check-free and is the loop that unrolling and superword vectorisation actually target; - a **post-loop** that handles the leftover iterations, again with checks. The main loop dominates the run time for any large array, so almost all iterations execute without a bounds check even though the total code size grew. ## What defeats it RCE is a proof, and proofs fail on shapes the compiler cannot reason about: - an index that is not an affine function of the induction variable, for example one read from another array (`a[idx[i]]`) — an indirection like that must keep its check; - a loop bound that a call inside the loop might change, or an array reference reassigned in the body, so nothing is invariant; - indices whose arithmetic may overflow in a way the compiler cannot bound; - deeply irregular control flow that stops the loop from being recognised as counted at all. In those cases the check remains — which is fine. A predictable, correctly-predicted bounds branch is cheap; the reason to care about RCE is the loops where it also unlocks unrolling and SIMD. ## Why it matters for how you write code You do not ask for RCE with a flag; you write loops it can analyse. Keep the induction variable an `int` with a simple stride, compare directly against `array.length` rather than a copy that other code might change, avoid reassigning the array reference inside the loop, and prefer straightforward indexing over an extra layer of indirection when the loop is genuinely hot. Hand-"optimizations" such as caching the length in a local or counting downwards usually gain nothing and can make the loop harder for the compiler to analyse. ## Verifying it At the level of a real investigation you confirm RCE by disassembling the compiled method and looking for the absence of length loads and compare-branches in the loop body, with a single guard in the loop's entry block. What you should not do is assume it happened because a benchmark got faster.

  • If the hoisted predicate fails at run time, how does the program still throw the exception at the right iteration?
    The failing guard triggers an uncommon trap: the compiled frame is discarded and execution resumes in the interpreter at the loop entry, using the frame state the compiler recorded. The interpreter runs the loop with real per-access checks, so it reproduces every side effect up to the offending index and then throws ArrayIndexOutOfBoundsException there. The optimization is therefore speculative but not observable.
  • Why does copying array.length into a local variable before the loop rarely help?
    Array length is immutable once the array is allocated, so the compiler already treats the length load as loop-invariant and hoists it. The manual copy adds nothing, and if the array reference itself is reassigned or the copy drifts from the array actually indexed, you can defeat the very analysis you were trying to help. Idiomatic i < a.length is the shape the compiler recognises best.

saying these in an interview costs you the question

  • Saying the JIT removes bounds checks in a way that could let an out-of-range read through
  • Believing bounds checks disappear only if you use a special flag or a third-party library
  • Claiming counting down to zero or caching the length reliably speeds up array loops
  • Assuming a[idx[i]] gets its check eliminated the same way a[i] does
  • Thinking the check simply becomes free because the branch predictor learns it — that is a different, weaker effect

context