Controlling Program Flow
Introduction
This lesson covers the second objective of the Oracle Certified Professional Java SE 21 Developer exam (1Z0-830): Controlling Program Flow. Oracle does not publish a weighting per objective, but flow control is everywhere: nearly every code-analysis question contains an if, a loop or a switch, and Java 21 adds the features examiners love most: pattern matching for switch and record patterns, both final in Java 21.
The exam rarely asks "what is a loop". It shows a switch with a missing break, a pattern label placed in the wrong order, or a labeled continue, and asks whether the code compiles and what it prints. You will learn the exact rules of each construct so you can trace such code by hand and also spot compile-time errors (dominated labels, missing default, unreachable statements) in seconds.
Learning objectives
After this lesson you will be able to:
- Write and trace
if/elsechains, including the dangling-else rule andinstanceofpattern variables and their scope. - Use classic
switchstatements (with fall-through), arrow-form switch statements and switch expressions withyield. - Apply pattern matching for
switch: type patterns, guards withwhen,case null, dominance and exhaustiveness. - Deconstruct records with record patterns, including nested patterns and
var. - Use
for, enhancedfor,while,do/while,break,continueand labels, and detect unreachable code.
1. if/else and instanceof patterns
An if condition must be a boolean expression. if (x = 5) does not compile when x is an int. An else always pairs with the nearest unmatched if, whatever the indentation suggests:
int n = 5;
if (n > 0)
if (n > 10) System.out.println("big");
else System.out.println("negative"); // belongs to the inner if
This prints nothing: n > 0 is true, the inner if is false, and the else belongs to the inner if. Braces remove the ambiguity.
Pattern matching with instanceof
Since Java 16, instanceof can declare a pattern variable, so no cast is needed:
Object o = "Java 21";
if (o instanceof String s && s.length() > 4) {
System.out.println(s.toUpperCase()); // JAVA 21
}
The variable s is in scope only where the compiler can prove the match succeeded. In a instanceof String s && s.isEmpty() the right side sees s; in a instanceof String s || s.isEmpty() it does not compile. With a negation, the scope flows to the following statements:
if (!(o instanceof String s)) {
return; // must leave the block
}
System.out.println(s.length()); // s is in scope here
Since Java 21 a pattern whose type is the same as the static type, such as String t = "a"; t instanceof String s, is allowed. What is still an error is a test that can never succeed, such as Integer i = 1; i instanceof String s (incompatible types). Also, null instanceof Anything is always false.
The ternary operator cond ? a : b follows promotion rules: true ? 1 : 2.0 evaluates to 1.0 (a double), and mixing Integer and null can cause an unboxing NullPointerException when assigned to an int.
2. The classic switch statement
The selector of a classic switch can be byte, short, char, int, their wrappers, a String or an enum. It cannot be long, float, double or boolean. Case labels must be compile-time constants and unique. String switches use equals, so matching is case-sensitive, and a null selector throws NullPointerException.
The key behavior is fall-through: after a matching label the code continues into the next label until a break (or the end of the switch).
int n = 2;
switch (n) {
case 1: System.out.print("a");
case 2: System.out.print("b");
case 3: System.out.print("c"); break;
default: System.out.print("d");
}
// prints: bc
default may appear anywhere, and it is only used when no label matches, but the order of fall-through still follows the source order:
switch (5) {
default: System.out.print("d");
case 1: System.out.print("a");
}
// prints: da
Inside a loop, a break in a classic switch leaves only the switch, while continue applies to the loop. Variables declared in one case group are in scope for the whole switch block (a variable declared under case 1: can be assigned under case 2: but is not initialized there).
3. Arrow-form switch and switch expressions
With -> labels there is no fall-through and no break is needed. A label's right side is an expression, a block or a throw. You cannot mix : and -> in the same switch.
A switch expression produces a value and can be assigned or returned. It must be exhaustive: every possible input must be covered, so for an int or String selector a default is required; for an enum it is enough to list all constants (the compiler adds an implicit failing default); for a sealed hierarchy, all permitted subtypes.
enum Day { MON, TUE, SAT, SUN }
int hours = switch (day) {
case SAT, SUN -> 0; // several constants per label
case MON -> 6;
case TUE -> {
int base = 8;
yield base - 1; // yield returns from a block
}
};
Rules to remember:
- Inside a switch expression you cannot
break,continueorreturnout of the expression. Useyieldto supply the result. - With old-style colon labels in a switch expression,
yieldis also used and fall-through still exists. - A switch statement with arrow labels does not need to be exhaustive (unless it uses patterns or
null). - Case labels can list several constants separated by commas, and since Java 21 enum constants may be qualified (
case Day.MON) in pattern-matching switches.
| Feature | Statement, : form |
Statement, -> form |
Expression |
|---|---|---|---|
| Fall-through | Yes | No | Only with : labels |
Needs break/yield |
break |
No | yield for block bodies |
| Must be exhaustive | No | No (unless patterns) | Yes |
| Produces a value | No | No | Yes |
4. Pattern matching for switch (final in Java 21)
Java 21 lets the selector be any reference type and lets labels be patterns:
static String describe(Object o) {
return switch (o) {
case null -> "null value";
case Integer i when i > 100 -> "large int " + i;
case Integer i -> "int " + i;
case String s -> "string of " + s.length();
case int[] arr -> "int array of " + arr.length;
default -> "something else";
};
}
Important rules:
case null. Without it, anullselector throwsNullPointerException. You can writecase null, default ->to handle both.case nullcannot be combined with other patterns in one label.- Guards.
whenadds a boolean condition to a pattern label. Only pattern labels can have guards, not constants. - Dominance. A label that is dominated by an earlier label is a compile-time error.
case Integer ibeforecase Integer i when i > 100does not compile, because the first already matches everything the second matches. Put the specific (guarded) labels first. Likewise,case CharSequence csplaced beforecase String sis an error. The unguardeddefaultmust come last (or at least cannot dominate patterns that follow it in a pattern switch). - Exhaustiveness. A switch with patterns, whether statement or expression, must cover every possible value. With a
sealedinterface, covering all permitted subtypes is enough, with nodefault. - No fall-through into a pattern. You cannot fall into a label that declares a pattern variable, since the variable might be uninitialized.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
}; // no default needed: all permitted subtypes are covered
}
If a new class is later added to the permits clause and area is not recompiled, a MatchException is thrown at runtime for an unexpected subtype. This compile-time exhaustiveness check is the main benefit of sealed types (covered in detail in the object-oriented lesson).
5. Record patterns (final in Java 21)
A record pattern tests that a value is an instance of a record and deconstructs its components in one step:
record Point(int x, int y) {}
record Line(Point from, Point to) {}
Object o = new Line(new Point(2, 1), new Point(2, 9));
if (o instanceof Line(Point(var x1, var y1), Point(var x2, var y2)) && x1 == x2) {
System.out.println("vertical line at x=" + x1); // vertical line at x=2
}
Details:
- Component patterns can be type patterns (
Point p,int x), nested record patterns, orvar(the type of the component is inferred). - The pattern matches only if the value is non-null and an instance of the record type. A
nullcomponent does match a type pattern that is total for the component type (for examplePoint(var a, var b)whenaisnull), but a nested record pattern such asLine(Point(var a, var b), var t)does not match when thefromcomponent isnull. - Components are read through their accessor methods. The pattern's component count and types must match the record header exactly, or the code does not compile.
- In a
switch, record patterns can have guards and be checked for dominance and exhaustiveness exactly like type patterns.
| Pattern kind | Syntax | Matches |
|---|---|---|
| Type pattern | case String s |
Non-null instances of String |
| Guarded pattern | case String s when s.isEmpty() |
Non-null empty strings |
| Record pattern | case Point(int x, int y) |
Non-null Point instances |
| Nested record pattern | case Line(Point(var a, var b), var t) |
A Line whose from is non-null |
| Null label | case null |
Only null |
6. Loops, break, continue and labels
The classic for loop has three optional parts: initialization, condition, update. for (;;) is an infinite loop. A variable declared in the initialization is scoped to the loop.
for (int i = 0, j = 10; i < j; i += 3, j -= 3) {
System.out.print(i + ":" + j + " ");
}
// 0:10 3:7
Only declarations of the same type are allowed in the initializer (int i = 0, j = 10 is fine, int i = 0, long j = 1 is not).
The enhanced for works on arrays and any Iterable. The loop variable is a copy of each element, so assigning to it does not change the array. Modifying a collection structurally while iterating it with an enhanced for usually throws ConcurrentModificationException.
while checks its condition first and may never run; do { } while (cond); runs at least once and ends with a semicolon.
break ends the nearest enclosing loop or switch. continue skips to the next iteration of the nearest loop. A label lets them target an outer loop:
outer:
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (j == 1) continue outer;
if (i == 2) break outer;
System.out.print(i + "" + j + " ");
}
}
// prints: 00 10
Unreachable code is a compile-time error: a statement after break, continue, return, throw, or after while (true) { } with no break. A while (false) { ... } body is also an error, while if (false) { ... } is allowed. A final subtlety: for (;;) with no break followed by any statement does not compile.
Worked example: classifying messages with patterns
Problem. A logging service receives Object events: a String message, an Integer code, or a Line/Point from the earlier records. Write label(Object o) so that an Integer above 400 is "error", other integers are "code", null is "none", and everything else is "unknown". A colleague proposes this order:
return switch (o) {
case Integer i -> "code";
case Integer i when i > 400 -> "error";
case null -> "none";
default -> "unknown";
};
Does it compile? If not, how is it fixed, and what does label(500) return after the fix?
Step 1: check dominance. case Integer i matches every non-null Integer. The second label case Integer i when i > 400 can never be reached, which the compiler reports as "this case label is dominated by a preceding case label".
Step 2: reorder. Place the guarded label before the unguarded one. The case null label has no dominance relation with the others, so it can stay anywhere before default.
return switch (o) {
case null -> "none";
case Integer i when i > 400 -> "error";
case Integer i -> "code";
default -> "unknown";
};
Step 3: evaluate. For 500, the first label fails (not null), the second matches (500 > 400), and the result is "error".
Why not the alternatives? Replacing the guard with an if inside the arrow would also work, but it hides the logic and loses the compiler's dominance check. Removing case null would make label(null) throw NullPointerException. Using a classic switch with case Integer.class is not valid Java.
Common pitfalls and exam tips
- Exam tip: when a question has a
switch, first check the label form (:or->), then whether it is a statement or an expression, then whether it must be exhaustive. Three quick checks catch most compile errors. - A missing
breakin a colon-form switch silently falls through, anddefaultin the middle still falls through to the next label. - A
breakinside a switch inside a loop does not break the loop. - Dominance errors: general pattern first, guarded or more specific pattern later. Switching the order fixes it.
nullselector withoutcase nullthrowsNullPointerException, even in a pattern switch with adefault.yieldis a restricted identifier:yieldis only a statement inside a switch expression body.- Pattern variables are only in scope where the match is definitely true. Reusing the same name in an
if/else ifchain for different branches is legal. - A
switchonlong,boolean,floatordoubledoes not compile.
Mini case study: routing support tickets
A help-desk system models requests as sealed interface Ticket permits Bug, Question, Feature. Bug(String id, int severity), Question(String id) and Feature(String id, boolean paid) are records. Routing rules: a Bug with severity 4 or more goes to "oncall", other bugs to "backlog", a Question to "support", a paid Feature to "product", and an unpaid Feature to "backlog".
Task. Write the method and explain what the compiler does if a fourth ticket type is added later.
Solution.
static String route(Ticket t) {
return switch (t) {
case Bug(var id, var sev) when sev >= 4 -> "oncall";
case Bug b -> "backlog";
case Question q -> "support";
case Feature(var id, var paid) when paid -> "product";
case Feature f -> "backlog";
};
}
The guarded record patterns come before their unguarded counterparts, avoiding dominance errors. The unguarded Bug b, Question q and Feature f labels together make the switch exhaustive over the sealed interface, so no default is written. If a fourth permitted type is added, the switch stops being exhaustive and the compiler reports an error in route at the next compile. That is the intended safety net: a default would have hidden the new case and routed it silently to a wrong queue.
Key terms
- Fall-through: continuing into the next case group because no
breakended the current one. - Switch expression: a switch that yields a value and must be exhaustive.
yield: statement that provides the value of a switch expression block.- Type pattern / record pattern: tests of a type, optionally binding or deconstructing values.
- Guard: the
whenboolean condition attached to a pattern label. - Dominance: a preceding label that already matches everything a later label would match.
- Exhaustiveness: every possible input is covered by some label.
- Label (loop): an identifier marking a statement so
breakorcontinuecan target it.
Summary checklist
- I can trace fall-through in a colon-form switch, including a
defaultin the middle. - I know which selector types are allowed and that
nullthrows withoutcase null. - I can write switch expressions with
yield, multiple constants per label and exhaustiveness. - I can order pattern labels to avoid dominance errors and use
whenguards. - I can deconstruct nested records with
varand know when a record pattern does not matchnull. - I can predict the output of nested loops with labeled
breakandcontinue. - I can spot unreachable code and scope errors with pattern variables.
Official sources
- Oracle exam page for 1Z0-830 (objectives): https://education.oracle.com/java-se-21-developer-professional/pexam_1Z0-830 (opens in a new tab)
- JEP 441: Pattern Matching for switch (final in Java 21): https://openjdk.org/jeps/441 (opens in a new tab)
- JEP 440: Record Patterns (final in Java 21): https://openjdk.org/jeps/440 (opens in a new tab)
- Java Language Specification, Java SE 21, chapter 14 (Blocks, Statements and Patterns): https://docs.oracle.com/javase/specs/jls/se21/html/jls-14.html (opens in a new tab)