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.
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:
// 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:
| Statement | What it does |
|---|---|
break | leaves the nearest enclosing loop or switch |
continue | skips the rest of this iteration and goes to the next; in a for, the update still runs |
return | leaves 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:
// 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.
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
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:
tableswitchwhen the case values are dense: a jump table indexed byvalue − low. Constant time, one bounds check and one indexed jump, regardless of how many cases.lookupswitchwhen 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(). 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.
lookupswitch. A sparse jump on the hash values of the case labels. This narrows many cases to one candidate without comparing any text at all.
equals(). The candidate is confirmed with equals, because hashes collide -- Aa and BB are both 2112 and would otherwise both jump to the first arm. This is the step people forget.
tableswitch. A SECOND switch, on the case index 0..n-1, which is dense -- so it is a real jump table. Only now does control reach your code.
Three other switch subjects have their own compilation strategies:
| Switch on | Compiles to |
|---|---|
String | a 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. |
| enum | a 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:
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 orthrowon its right, and nothing after it. There is nobreakto 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
rateis assigned once, and can befinal, instead of being written in every arm. - A switch expression must be exhaustive. Over an
intor aString, that means adefault. 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:
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 -> | |
|---|---|---|
| statement | the classic switch: falls through, nothing checks coverage | no fall-through; still not required to cover every value |
| expression | falls through between labels and leaves with yield; must be exhaustive | no 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 xunder two different cases isvariable x is already defined. Each arrow arm is its own block, and the same name can be reused. nullstill throws. A switch expression on anullStringor enum throwsNullPointerException, exactly as the statement does. Handlingnullinside the switch, ascase 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:
javaccompiled the enum switch to atableswitchover$SwitchMap$indices. ThePREMIUMarm's last instruction wasdstore rate; the next instruction in the run wasSUNDAY'sldc 2.0.- Every premium customer's rate became 2.0. Tests passed: the test for
PREMIUMassertedrate <= 1.0... and had been written asassertTrue(rate > 0). - The compiler had emitted a warning,
possible fall-through into case, which the build printed among four hundred others. - The fix was two changes, not one: a
breakin every arm, and adefaultthat 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
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?
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?
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
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
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
breakis agotoyou write yourself. - "A switch on strings is slow." It is a
lookupswitchon the hash plus oneequals, which beats anif/elsechain from the third case onward. - "A missing case is a compile error." Only in a switch expression. A
switchstatement, 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
switchcan take any type." A value switch takesintand its narrower kin,char,String, and enums. Notlong, notdouble— the jump table is indexed by anint, and switching on alongis 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.
- "
yieldandreturnare interchangeable."yieldleaves the switch with a value;returnwould leave the method, and inside a switch expression it does not compile. - "
ConcurrentModificationExceptionmeans 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
switchstatement — including the rule that acaselabel must be a compile-time constant, and what "falls through" means normatively. javap -con your own switches — the fastest way to seetableswitchbecomelookupswitchas 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 -con 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.