Control flow and the switch

if, loops and the jump statements, the classic switch — fall-through and what it compiles to — and the Java 14 switch expression, where a missing case stops the build.

15 min read Java Fundamentals

Control flow in Java is unremarkable until you reach switch, which has changed more in the last five years than in the previous twenty. The old statement form is a fall-through trap; the new expression form, with arrows and patterns, is one of the best features in the language. Knowing which one you are writing is half the lesson. The other half is what the compiler does with each form — jump tables, hash switches, invokedynamic pattern switches — because that is where the surprising behaviours come from.

if, loops, and the things worth knowing

if/else, while, do/while, the classic for and the enhanced for behave as in every C-family language. If you have not written one of those, here is each form once:

The forms, once eachjava
// if / else if / else: the first true condition wins, the rest are skipped
if (score >= 90) {
    grade = "A";
} else if (score >= 70) {
    grade = "B";
} else {
    grade = "C";
}
 
// while: tests first, so the body may run zero times
while (queue.hasWork()) {
    queue.processOne();
}
 
// do-while: tests last, so the body always runs at least once
String answer;
do {
    answer = prompt("Continue? (y/n)");
} while (!answer.equals("y") && !answer.equals("n"));
 
// for: start; condition; update
for (int i = 0; i < names.length; i++) {
    System.out.println(i + ": " + names[i]);
}
 
// enhanced for: every element, no index
for (String name : names) {
    System.out.println(name);
}

else if is not a separate keyword. It is an else whose body happens to be another if, which is also why an else belongs to the nearest if without one: in if (a) if (b) x(); else y(); the else goes with if (b), whatever the indentation says. Always writing braces makes the question go away.

do/while is the rarest of the loops, and it is right exactly when the body must run before there is anything to test: reading input until it is valid, or retrying until an attempt succeeds.

Three statements leave the normal flow:

StatementWhat it does
breakleaves the nearest enclosing loop or switch
continueskips the rest of this iteration and goes to the next; in a for, the update still runs
returnleaves the whole method, handing back a value if the method declares a return type

Nested ifs read worse than they run. Past two levels, handle the cases that stop the work first and return early, so the main path is not indented:

java
// nested
if (order != null) {
    if (order.isPaid()) {
        if (!order.isShipped()) {
            ship(order);
        }
    }
}
 
// guard clauses: the same logic, flat
if (order == null || !order.isPaid()) return;
if (order.isShipped()) return;
ship(order);

Beyond the forms, three details matter in practice:

The enhanced for loop calls iterator(). for (Item i : items) works on any Iterable, and on arrays (where it compiles to an index loop). Modifying the collection inside the loop throws ConcurrentModificationException on the next iteration, because the collection's iterator is fail-fast: ArrayList keeps a modCount that every structural change increments, and next() compares it against the count it saw at creation. It is a best-effort check, not a guarantee, and it is single-threaded: the "concurrent" in the name refers to modifying while iterating, not to threads. Use Iterator.remove(), removeIf, or collect what to remove and do it afterwards.

Labelled breaks exist and are occasionally right.

java
outer:
for (Row row : rows) {
    for (Cell cell : row) {
        if (cell.isPoison()) break outer;   // leaves both loops
    }
}

Reach for this rarely; extracting the inner search into a method that returns is usually clearer.

boolean conditions only. if (count) does not compile. There is no truthiness; that is a feature.

The old switch: fall-through

java
switch (day) {
    case MONDAY:
    case FRIDAY:
        rate = 1.0;
        break;
    case SATURDAY:
        rate = 1.5;
        // missing break: falls into SUNDAY
    case SUNDAY:
        rate = 2.0;
        break;
    default:
        rate = 1.0;
}

Every case label is a jump target; execution continues past it until a break. The missing break on SATURDAY sets rate to 2.0, silently. Compilers warn; reviewers miss it; production notices. This form still compiles and you will still meet it in every legacy codebase.

Under the hood: what a switch compiles to

The fall-through is not an accident of syntax; it is what the bytecode is. javac emits one of two instructions for a switch on an int, char, byte, short, or an enum's ordinal:

  • tableswitch when the case values are dense: a jump table indexed by value − low. Constant time, one bounds check and one indexed jump, regardless of how many cases.
  • lookupswitch when they are sparse: a sorted list of (value, target) pairs, searched by binary search. O(log n).

Each case body is just a label in a straight run of instructions, which is why control "falls" into the next one: there is nothing between them. break is a goto past the end.

hashCode()lookupswitchequals()tableswitch
knowan int hasharm chosennotext compares0

hashCode(). The switch calls hashCode() on the subject -- which is why a null string throws NullPointerException before any case is tried, and why default cannot catch it.

1 / 4

Three other switch subjects have their own compilation strategies:

Switch onCompiles to
Stringa lookupswitch on s.hashCode(), then equals inside the matching arm to confirm (hash collisions are real: "Aa" and "BB" share a hash), then a second switch on the case index. A null string throws before any of it.
enuma switch on ordinal(), through a synthetic $SwitchMap$ array so that reordering the enum's constants in another jar does not silently change which arm runs

The String case is the interesting one: two hash-equal strings reach the same arm and are separated by an equals chain, which is why a string switch is hashCode then equals and never hashCode alone. "Aa" and "BB" both hash to 2112, and both still work.

The switch expression (Java 14)

Every trap above — the missing break, the missing case, a switch that cannot produce a value — is fixed by the form Java 14 made standard. The same pricing rule:

Arrow labels, and a switch that is a valuejava
double rate = switch (day) {
    case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> 1.0;
    case SATURDAY -> 1.5;
    case SUNDAY -> 2.0;
};

Four things changed:

  • Arrow labels do not fall through. case X -> runs the expression, block or throw on its right, and nothing after it. There is no break to forget.
  • Several constants share one label, separated by commas, instead of empty cases stacked above each other.
  • The switch is an expression. It produces a value you can assign, return or pass, so rate is assigned once, and can be final, instead of being written in every arm.
  • A switch expression must be exhaustive. Over an int or a String, that means a default. Over an enum, listing every constant is enough, and leaving one out does not compile: the switch expression does not cover all possible input values. This is the check the colon form never had.

yield: a value out of a block

When an arm needs more than one expression, give it a block and hand the value back with yield:

java
String label = switch (code) {
    case 1 -> "one";
    case 2, 3 -> {
        String s = describe(code);
        yield s.toUpperCase();
    }
    default -> "other";
};

yield is to a switch expression what return is to a method, and one cannot stand in for the other. A return inside a switch expression fails with attempt to return out of a switch expression, and a block that ends without yield fails with switch rule completes without providing a value.

Four combinations, one worth writing

Colon or arrow labels, and statement or expression, are two separate choices. A single switch cannot mix label kinds (different case kinds used in the switch), so each switch is one of four:

colon labels, case X:arrow labels, case X ->
statementthe classic switch: falls through, nothing checks coverageno fall-through; still not required to cover every value
expressionfalls through between labels and leaves with yield; must be exhaustiveno fall-through, must be exhaustive, produces a value

Two more differences show up in real code:

  • Scope. A colon-form switch body is one block, so declaring int x under two different cases is variable x is already defined. Each arrow arm is its own block, and the same name can be reused.
  • null still throws. A switch expression on a null String or enum throws NullPointerException, exactly as the statement does. Handling null inside the switch, as case null, arrived with pattern matching in Java 21.

What exhaustive means at run time

The compiler checks an enum switch expression against the constants it can see when it compiles. If the enum lives in another jar and gains a constant later, the compiled switch will meet a value it has no arm for, and javac has planned for that: it adds a hidden default that throws. Compiled for Java 21 or later, that is a MatchException; compiled for an earlier release, an IncompatibleClassChangeError. Either way the new constant fails loudly on its first use instead of matching nothing, which is exactly the bug in the walkthrough below.

On Java 14 or later, write the arrow form. The colon form is for reading the code that was written before it, which is most of the code you will inherit. Switching on types and records rather than values is pattern matching, which builds on this syntax and has its own lesson in Modern Java.

Walkthrough: the rule that fell through

A pricing method computed rate with the colon-form switch above. A new tier, PREMIUM, was added to the enum and the switch, as case PREMIUM: rate = 0.8; directly above case SUNDAY:. No break. Trace what happened:

  1. javac compiled the enum switch to a tableswitch over $SwitchMap$ indices. The PREMIUM arm's last instruction was dstore rate; the next instruction in the run was SUNDAY's ldc 2.0.
  2. Every premium customer's rate became 2.0. Tests passed: the test for PREMIUM asserted rate <= 1.0... and had been written as assertTrue(rate > 0).
  3. The compiler had emitted a warning, possible fall-through into case, which the build printed among four hundred others.
  4. The fix was two changes, not one: a break in every arm, and a default that throws with the unhandled tier in its message. The first stops this bug; the second is what turns the next new tier into a loud failure on the first request instead of a silent discount.

-Xlint:fallthrough as an error in the build is the cheap version of that lesson, and before Java 14 it is the only version — the colon form has no compiler check for a missing case, only for a suspicious fall-through. On 14 or later the fix is the arrow form: a switch expression over the enum does not compile until PREMIUM has an arm, and there is no break to leave out.

Ternary and short-circuit

a ? b : c is fine for a single choice; nested ternaries are not — turn them into a switch or an early-return chain. A ternary has one type, computed from both branches, which is where Integer i = flag ? 1 : null throws: the branches are int and null, the expression type becomes int, and the null is unboxed. && and || short-circuit: the right side is not evaluated if the left decides the result, which is what makes s != null && s.isEmpty() safe. & and | on booleans do not short-circuit and are almost never what you mean.

Try it yourself

Loops that do not do what they look like

java
int n = 10;
do {
    System.out.println("do: " + n);
} while (n < 5);
 
int i;
for (i = 0; i < 5; i++) {
    if (i % 2 == 0) continue;
    System.out.println(i);
}
System.out.println("done at " + i);
Answer

do: 10, then 1, 3, and done at 5. The do/while runs its body once before it ever tests n < 5, so it prints even though the condition was false from the start. continue skips the println for the even numbers, but in a for it still runs the update, i++, so the loop ends normally and i is 5 afterwards.

What does it print?

java
int grade = 2;
switch (grade) {
    case 1: System.out.println("A");
    case 2: System.out.println("B");
    case 3: System.out.println("C"); break;
    case 4: System.out.println("D");
}
System.out.println("done");
Answer

B, then C, then done. Control enters at case 2 and keeps going into case 3, because a case is a label and nothing separates one body from the next. The break in case 3 is what finally stops it; case 4 is never reached. Remove that break and D prints too.

This is the whole argument for the default-that-throws and for keeping one break per arm: the compiler will not tell you a break is missing, and the output is plausible enough to survive review.

Why does this compile, and what does it print?

java
String s = "BB";
switch (s) {
    case "Aa": System.out.println("Aa"); break;
    case "BB": System.out.println("BB"); break;
    default:   System.out.println("?");
}
Answer

It prints BB. "Aa" and "BB" have the same hashCode (2112), so the compiled lookupswitch sends both to the same arm, where javac emitted an equals chain to tell them apart before jumping to the right body. The collision costs one extra string comparison and nothing else. It is the reason a string switch is hashCode then equals, never hashCode alone.

A switch expression that falls through

java
int n = 1;
int v = switch (n) {
    case 1:
        System.out.println("one");
    case 2:
        System.out.println("two");
        yield 20;
    default:
        yield 0;
};
System.out.println(v);
Answer

one, two, then 20. Being an expression does not stop a switch falling through: with colon labels, control enters at case 1, runs on into case 2, and leaves only at its yield. The exhaustiveness check still applied, which is why default is there. Rewrite it with arrow labels and case 1 can no longer reach the code under case 2.

Remove during iteration

java
for (Order o : orders) if (o.isCancelled()) orders.remove(o);

What goes wrong, and give two correct versions.

Answer

The remove bumps modCount; the iterator's next next() sees the mismatch and throws ConcurrentModificationException. (If the removed element happens to be the second-to-last, the loop ends before the check and the bug hides.) Correct: orders.removeIf(Order::isCancelled), which iterates once and compacts once; or an explicit Iterator with it.remove(), which updates the expected count. Building a list of cancelled orders and calling removeAll afterwards also works and is O(n²) on an ArrayList.

Misconceptions

  • "Fall-through is a syntax quirk." It is the bytecode: case bodies are labels in one straight run, and break is a goto you write yourself.
  • "A switch on strings is slow." It is a lookupswitch on the hash plus one equals, which beats an if/else chain from the third case onward.
  • "A missing case is a compile error." Only in a switch expression. A switch statement, arrow or colon, over values or over an enum, has no exhaustiveness check and no lint category for one. A constant added to the enum next year silently matches nothing.
  • "A switch can take any type." A value switch takes int and its narrower kin, char, String, and enums. Not long, not double — the jump table is indexed by an int, and switching on a long is still a preview feature in Java 25. Pattern matching (Java 21) lets a switch test the type of any object, which is a different mechanism.
  • "Arrow labels make a switch an expression." They are independent. An arrow-form statement is still a statement with no coverage check, and a colon-form expression still falls through.
  • "yield and return are interchangeable." yield leaves the switch with a value; return would leave the method, and inside a switch expression it does not compile.
  • "ConcurrentModificationException means another thread modified the list." Almost always it is the same thread, in the loop body. The check is single-threaded and best-effort.

Going deeper

  • JLS §14.11, the switch statement — including the rule that a case label must be a compile-time constant, and what "falls through" means normatively.
  • javap -c on your own switches — the fastest way to see tableswitch become lookupswitch as you spread the case values out.
  • JEP 361, which made switch expressions standard in Java 14, and JLS §15.28 for the expression form.
  • Where this goes next, when you reach the releases that added it: JEP 441 (pattern matching for switch), JEP 440 (record patterns).
  • javap -c on a string switch and an enum switch: the two strategies, in twenty lines each.
  • java.lang.runtime.SwitchBootstraps, the bootstrap behind pattern switches.
  • ArrayList.Itr.checkForComodification() in the JDK source, for the fail-fast mechanism.
Progress is saved on this device and to your account when signed in.