skip to content

How does the Java compiler implement a traditional switch in bytecode, and what scoping subtlety exists for variables declared inside a switch block?

level: seniorimportance: nice to knowfreq 25%

answer

  1. tableswitch = dense, O(1) jump table
  2. lookupswitch = sparse, O(log n) binary search
  3. Both key on 32-bit int (=> no long)
  4. String: hashCode then equals; enum: ordinal
  5. One shared scope => wrap a case in { }

basics

~20 s

The compiler turns a switch into one of two jump instructions: tableswitch (a dense jump table) or lookupswitch (a sorted list of key/offset pairs). All cases share one scope, so a variable declared in one case is visible (but maybe uninitialized) in the others.

solid answer

~50 s

A traditional switch compiles to one of two JVM instructions. When the case label values are dense (close together), the compiler emits tableswitch — a contiguous jump table indexed directly by the value, giving O(1) dispatch. When they are sparse, it emits lookupswitch — a sorted table of (key, branch-offset) pairs that the JVM binary-searches, giving O(log n). Both key on 32-bit int, which is why long is unsupported; String switches first switch on hashCode (an int) then verify with equals, and enum switches switch on ordinal. The scoping subtlety: the entire body between the braces is a single block with one shared scope. A local variable declared in one case is in scope for all later cases, so you can reference it there — but if that case was not actually executed it may be unassigned, which the compiler rejects as 'variable might not have been initialized.' The fix is to wrap a case body in its own { } block.

code

java · 28 lines
java
// Shared-scope pitfall and its fix
int x = 2;

// BROKEN: one shared scope across all cases
/*
switch (x) {
    case 1:
        int total = 100;     // assigned only on case 1
        break;
    case 2:
        total += 5;          // ERROR: 'total' might not have been initialized
        break;
}
*/

// FIXED: per-case block scopes
switch (x) {
    case 1: {
        int total = 100;
        System.out.println(total);
        break;
    }
    case 2: {
        int total = 5;
        System.out.println(total);
        break;
    }
}

go deeper

for a junior

Not expected to know bytecode; should at least know each case isn't a fresh scope and wrapping in braces helps.

for a middle

Aware that switch is faster than an if-chain and that variables declared in a case leak into the shared switch scope.

for a senior

Explains tableswitch vs lookupswitch, the 32-bit-int keying (hashCode for String, ordinal for enum), and diagnoses the shared-scope/definite-assignment error.

for a principal

Uses the bytecode model to reason about hot-path dispatch performance and sets idioms (block-scoped cases, enum switches) to avoid the scope pitfalls across a codebase.

## Two bytecode instructions When `javac` compiles a switch over integral/enum/String values it does **not** generate a chain of comparisons. Instead it picks one of two specialized JVM instructions, based on how the case label *values* are distributed: ### `tableswitch` — for dense values If the case values are **contiguous or nearly so** (e.g. 1,2,3,4,5), the compiler builds a **jump table**: an array of branch targets indexed directly by `value - low`. The JVM computes the index in constant time and jumps. This is **O(1)** dispatch and is the faster, preferred form. To keep tables from getting huge, the compiler may pad small gaps with entries pointing at `default`. ### `lookupswitch` — for sparse values If the values are **scattered** (e.g. 1, 100, 5000, 99999), a dense table would waste enormous space, so the compiler emits a **sorted list of `(key, offset)` pairs**. The JVM **binary-searches** the keys, giving **O(log n)** dispatch. The compiler chooses between them by weighing table density against size; you do not control it directly, but understanding it explains performance and the type restrictions. ### Why this restricts the types Both instructions key on **32-bit `int`**. That single fact explains: - **`long` is forbidden** (64-bit won't fit the key), - **`String` switches** are compiled in two steps: first a `lookupswitch` on `String.hashCode()` (an `int`), then an `.equals` check to disambiguate hash collisions, - **`enum` switches** compile to a switch on the constant's `ordinal()` (an `int`), typically via a small generated lookup array. ## The scoping subtlety The body of a switch — everything between `{` and `}` — is **one single block with one shared scope**, not a separate scope per case. The `case` labels are just jump targets *into* that one block. This has a surprising consequence for local variables: ```java switch (x) { case 1: int y = 10; // declared here System.out.println(y); break; case 2: // y is IN SCOPE here (same block) ... System.out.println(y); // COMPILE ERROR: y might not have been initialized break; } ``` Because `case 1` and `case 2` share the same scope, the **declaration** of `y` is visible in `case 2`. But if the switch jumped straight to `case 2`, the assignment `int y = 10` never ran, so `y` would be **unassigned**. Java's definite-assignment analysis catches this and refuses to compile — "variable `y` might not have been initialized." A related gotcha: you also cannot have two `case` labels each declaring a variable with the **same name**, because they live in one scope (name clash). ### The fix: give each case its own block Wrap the case body in braces to create a nested scope: ```java switch (x) { case 1: { int y = 10; // scoped to this block only System.out.println(y); break; } case 2: { int y = 20; // independent y, no clash System.out.println(y); break; } } ``` Now each `{ }` is its own scope, so the names don't collide and there is no "might not be initialized" problem. ## Why a senior should know this - It explains the **type restrictions** (the 32-bit-int key) from first principles rather than as a rule to memorize. - It explains **performance**: dense switches are essentially free dispatch; converting a long `if/else if` ladder over an int to a switch can be a real win. - The **scoping rule** is a genuine source of confusing compile errors that a senior should be able to diagnose instantly and fix with a block.

  • When does the compiler choose lookupswitch over tableswitch?
    When the case values are sparse, so a dense indexed table would waste too much space; lookupswitch stores sorted (key,offset) pairs and binary-searches them.
  • Why can't you reference a variable declared in an earlier case from a later case?
    All cases share one scope, so the name is visible, but if the switch jumped past the declaration the variable is unassigned — definite-assignment analysis rejects it. Wrap the case in its own { } block.

saying these in an interview costs you the question

  • Saying switch compiles to a sequential if/else chain
  • Thinking each case gets its own scope automatically
  • Claiming you can redeclare the same variable name in two bare cases
  • Assuming the developer chooses tableswitch vs lookupswitch directly

context