Chapter 4: Control Flow
Programs rarely run in a straight line from top to bottom. Most of the time, they make decisions, repeat actions, and handle exceptions depending on conditions that arise during execution. This ability to branch, loop, and react dynamically is what makes programming expressive. In Java, these mechanisms are known collectively as control flow.
Control flow structures let you determine which parts of your code run and when. You can execute code only if certain conditions are met, repeat sections until a task is complete, or skip specific parts entirely. Java provides several categories of control flow statements, including if and else constructs, switch expressions, looping structures like for and while, and keywords that alter execution such as break and continue.
At a higher level, control flow is how logic takes shape in a program. It transforms raw instructions into behavior: testing data, reacting to input, and producing meaningful results. Even a simple decision, such as whether a number is positive or negative, is an example of control flow in action.
if (number > 0) {
System.out.println("Positive");
} else {
System.out.println("Non-positive");
}
This short code fragment demonstrates one of Java’s most common patterns; selective execution based on a condition. As programs grow, you will combine these patterns into loops, nested branches, and structured exception handling to manage complex operations predictably.
This chapter explores the main tools that shape program logic. You will learn how to make decisions with if and else, simplify them with ternary expressions, use pattern matching with switch, control repetition with loops, and handle exceptions gracefully. Each of these structures builds on the same foundation of truth values and expression evaluation introduced earlier, turning simple statements into structured, adaptable programs.
{…}) help prevent these mistakes and make your code easier to read and maintain.
if, else if, and else constructs
The if statement is the foundation of all decision making in Java. It allows your program to execute different code depending on whether a condition evaluates to true or false. Along with its optional else if and else branches, it lets you express multiple possible paths of execution clearly and predictably.
The basic if statement
An if statement tests a condition, which must evaluate to a boolean value (true or false). If the condition is true, the code inside the block ({…}) executes; if false, it is skipped.
int temperature = 30;
if (temperature > 25) {
System.out.println("It's warm today.");
}
Here, the message prints only if the condition temperature > 25 is true. If the condition is false, the statement inside the braces is ignored and execution continues after the if block.
Adding an else branch
The else clause provides an alternative path when the condition of an if statement is false. It ensures that one and only one of the two blocks executes.
if (temperature > 25) {
System.out.println("It's warm today.");
} else {
System.out.println("It's cooler today.");
}
Only one branch executes per evaluation. The first block runs if the condition is true; otherwise, the else block runs. Both blocks are mutually exclusive.
Using else if chains
When you need to test multiple related conditions, you can chain them together with else if. Each condition is checked in sequence until one evaluates to true; remaining branches are skipped once a match is found.
int score = 85;
if (score >= 90) {
System.out.println("Grade A");
} else if (score >= 80) {
System.out.println("Grade B");
} else if (score >= 70) {
System.out.println("Grade C");
} else {
System.out.println("Below average");
}
These conditional chains are read top to bottom. Only the first true condition’s block executes, even if later ones would also be true.
else as a clean “catch-all” for everything else.
Nested if statements
You can place one if statement inside another to test multiple layers of logic. This is called nesting. While sometimes necessary, too many nested conditions can make code difficult to read.
boolean loggedIn = true;
boolean admin = false;
if (loggedIn) {
if (admin) {
System.out.println("Welcome, administrator.");
} else {
System.out.println("Welcome, user.");
}
} else {
System.out.println("Please log in first.");
}
Here, the outer if checks whether a user is logged in, and the inner one checks whether that user is an administrator. Each level of logic narrows the possible outcomes.
if statements can quickly become confusing. When logic grows complex, consider using switch expressions or restructuring conditions into separate methods for better readability.
Boolean expressions and best practices
Any expression that returns a boolean value can be used as an if condition. This includes comparisons, method calls, and logical combinations.
if (ready && hasPermission()) {
executeTask();
}
Because Java’s if expects a boolean, there is no need to compare directly to true or false. For example, instead of writing if (ready == true), simply write if (ready).
isEligible() or hasAccess()) to express intent rather than logic.
Switch expressions and pattern matching
When a variable or expression can take on multiple possible values, a long chain of if and else if statements can become repetitive. Java’s switch construct provides a clearer way to select one of several actions based on a single value. Modern Java also extends this idea with switch expressions and pattern matching, which make branching more concise and expressive.
Traditional switch statements
The classic switch statement tests a single expression against a list of constant cases. When a match is found, execution continues from that point until a break statement is encountered, or until the switch block ends.
int day = 3;
switch (day) {
case 1:
System.out.println("Monday");
break;
case 2:
System.out.println("Tuesday");
break;
case 3:
System.out.println("Wednesday");
break;
default:
System.out.println("Unknown day");
}
Each case must be a constant expression and of the same type as the value being switched on. The default label is optional and executes when no other case matches.
break causes fall-through, meaning execution continues into the next case. This can be useful in rare situations but often leads to errors if done unintentionally.
Enhanced switch syntax (Java 14+)
Modern Java introduces a cleaner form of switch that avoids fall-through and can be used as an expression that returns a value. This version uses the -> symbol instead of :, and each branch ends automatically.
int day = 3;
String name = switch (day) {
case 1 -> "Monday";
case 2 -> "Tuesday";
case 3 -> "Wednesday";
default -> "Unknown";
};
System.out.println(name);
This form eliminates the need for break statements, prevents unintended fall-through, and can directly assign or return a value.
String category = switch (score / 10) {
case 10, 9 -> "Excellent";
case 8 -> "Good";
case 7 -> "Fair";
default -> "Needs improvement";
};
switch expressions are safer and clearer than the old form. They encourage a more functional style, where the result of a choice is directly assigned rather than printed or executed inline.
Using yield for multi-statement cases
Sometimes a case requires more than one line of code to produce a result. In a switch expression, you can use yield to return a value from a block enclosed in braces.
int month = 2;
int days = switch (month) {
case 1, 3, 5, 7, 8, 10, 12 -> 31;
case 4, 6, 9, 11 -> 30;
case 2 -> {
boolean leap = true;
yield leap ? 29 : 28;
}
default -> 0;
};
System.out.println(days);
The yield keyword signals the value that the switch expression should produce for that case. It replaces older workarounds that required temporary variables.
break statement no longer returns values in switch expressions. Use yield instead when you need to compute a result within a block.
Pattern matching in switch (Java 17+)
From Java 17 onward, switch supports pattern matching, a powerful feature that tests both type and value simultaneously. This allows more expressive branching when dealing with objects of different types.
Object obj = "Hello";
String result = switch (obj) {
case String s -> "String of length " + s.length();
case Integer i -> "Integer value " + i;
case null -> "Null reference";
default -> "Unknown type";
};
System.out.println(result);
Each case can specify both a type and an identifier, letting you immediately use the casted variable within the case block. This avoids the need for separate instanceof checks and manual casts.
switch combines readability with type safety. It is especially useful when processing data that might come from different classes or when implementing polymorphic behavior without long if-else chains.
When to use switch
Use switch when you need to choose between several fixed, distinct options based on a single expression. It is ideal for enumerations, menu systems, or data categories, and modern syntax makes it both concise and expressive.
Day today = Day.FRIDAY;
String mood = switch (today) {
case MONDAY -> "Focused";
case FRIDAY -> "Excited";
case SATURDAY, SUNDAY -> "Relaxed";
default -> "Steady";
};
default branch in switch constructs, even when all cases seem covered. This ensures your code remains correct if new values or enum constants are introduced later.
Loops: for, while, do while
Loops let your program repeat sections of code until a condition changes. They are essential for tasks like processing lists, reading files, performing calculations, or waiting for user input. Java provides three primary looping structures: for, while, and do-while. Each achieves repetition in a slightly different way but follows the same core idea, which is to execute a block of code multiple times while a condition remains true.
The while loop
The while loop evaluates its condition before each iteration. If the condition is true, the block executes. If false, the loop stops immediately.
int count = 0;
while (count < 3) {
System.out.println("Count: " + count);
count++;
}
Here, the code prints numbers from 0 to 2. The condition count < 3 is checked before each iteration, so if it is initially false, the loop body will never run.
while when you don’t know in advance how many times a loop should run, such as waiting for input or checking a process until it completes.
The do while loop
The do-while loop is similar to while, except that the condition is tested after the body executes. This guarantees that the loop runs at least once, regardless of whether the condition is initially true or false.
int number = 0;
do {
System.out.println("Number: " + number);
number++;
} while (number < 3);
In this example, the loop prints Number: 0, Number: 1, and Number: 2. Even if the condition had been false from the start, the body would still execute once before stopping.
do-while loop can eventually become false. Otherwise, the program could enter an infinite loop that never exits.
The for loop
The for loop is ideal when you know exactly how many times you need to repeat an action. It includes an initialization step, a loop condition, and an update expression, all written in one concise line.
for (int i = 0; i < 5; i++) {
System.out.println("i = " + i);
}
The for statement performs these steps in order:
- Initialize a counter variable (
int i = 0). - Check the condition (
i < 5). - If true, execute the loop body.
- Update the counter (
i++). - Repeat until the condition becomes false.
When the condition fails, execution continues after the loop block.
for loop can contain any valid expressions. You can even leave them empty if they are managed elsewhere, but doing so often reduces clarity.
The enhanced for loop
Introduced in Java 5, the enhanced for loop (also known as the “for-each” loop) provides a simpler way to iterate over arrays and collections without using an index variable.
String[] names = { "Java", "Python", "C++" };
for (String name : names) {
System.out.println(name);
}
This loop automatically retrieves each element in sequence. It is read-only. While it can access elements, it cannot modify the underlying collection structure directly.
for loop is ideal for reading data, but if you need to remove or insert elements while looping, use an Iterator or a traditional for loop instead.
Nested loops
Loops can be nested inside one another to perform repeated operations over multiple dimensions, such as iterating through a matrix or comparing every pair of items in a list.
for (int i = 1; i <= 3; i++) {
for (int j = 1; j <= 2; j++) {
System.out.println("i=" + i + ", j=" + j);
}
}
This example prints each combination of i and j values. The outer loop controls the number of rows, while the inner loop controls the columns.
Infinite loops
A loop that never ends is called an infinite loop. While often a mistake, it can be useful in certain cases, such as event-driven programs or continuously running background tasks. To create one intentionally, use a condition that is always true.
while (true) {
System.out.println("Running...");
break; // use break or another condition to exit
}
Every infinite loop must have a controlled exit path, such as a break statement or external event trigger. Otherwise, the program will continue indefinitely and consume system resources.
Loop control: break, continue, return
In addition to defining how loops begin and repeat, Java provides several keywords that let you control when and how they end. These are break, continue, and return. Each alters the normal flow of a loop in a specific way; stopping it, skipping an iteration, or exiting a method entirely. Used thoughtfully, they make loops more flexible and expressive.
The break statement
The break statement immediately terminates the nearest enclosing loop (or switch block). Program execution continues with the first statement after the loop.
for (int i = 0; i < 10; i++) {
if (i == 5) {
break;
}
System.out.println("i = " + i);
}
System.out.println("Loop ended");
This example prints values from 0 to 4, then exits the loop once i equals 5. The message “Loop ended” appears immediately after the break.
break is useful when you need to stop looping early after finding a result or meeting a specific condition, such as locating an item in a list or handling an error condition.
Labeled break statements
Java also allows labeled breaks, which can exit an outer loop directly from within a nested loop. To use one, place a label before the outer loop, followed by a colon, and refer to that label in the break statement.
outer:
for (int i = 1; i <= 3; i++) {
for (int j = 1; j <= 3; j++) {
if (i * j == 4) {
break outer; // exit both loops
}
System.out.println(i + " × " + j + " = " + (i * j));
}
}
System.out.println("Exited outer loop");
Here, when the product of i and j equals 4, control jumps out of both loops. Without the label, only the inner loop would terminate.
The continue statement
The continue statement skips the rest of the current iteration and proceeds directly to the next one. It is useful when you want to ignore certain cases but keep the loop running.
for (int i = 1; i <= 5; i++) {
if (i == 3) {
continue;
}
System.out.println("i = " + i);
}
This prints 1, 2, 4, and 5. When i equals 3, the loop jumps immediately back to its condition check, skipping the print statement for that iteration.
continue is often cleaner than wrapping the rest of a loop body in an else block. It lets you express “skip this case” directly and concisely.
Labeled continue statements
Like break, continue can also use labels to skip directly to the next iteration of an outer loop from within a nested one.
outer:
for (int i = 1; i <= 3; i++) {
for (int j = 1; j <= 3; j++) {
if (j == 2) {
continue outer; // skip to next outer iteration
}
System.out.println("i=" + i + ", j=" + j);
}
}
Here, when j equals 2, control jumps to the next outer iteration of i, skipping the rest of both inner and outer loop bodies for that pass.
continue can simplify complex loop logic, but if you find yourself needing multiple nested labels, it’s a sign the code might be clearer as separate methods.
The return statement in loops
The return statement exits not just the loop but the entire method. It is often used when a specific result has been found and no further work is needed.
public static boolean containsZero(int[] numbers) {
for (int n : numbers) {
if (n == 0) {
return true; // exit method immediately
}
}
return false;
}
In this example, the method stops as soon as a zero is found, avoiding unnecessary iterations. Once return executes, no further statements in the method are run.
return can make loops more efficient by exiting early when a condition is met. However, avoid scattering multiple returns in long methods as too many exit points can obscure the flow of logic.
Choosing between break, continue, and return
Although all three affect control flow, they serve distinct purposes:
breakends the current loop orswitchblock.continueskips the current iteration and moves to the next.returnexits the entire method immediately.
Used together, they allow fine-grained control over program execution. The key is clarity. Loops should read naturally, with interruptions only where they simplify logic or improve efficiency.
Basic exception handling
Even the most carefully written programs encounter unexpected situations—invalid input, missing files, network errors, or arithmetic problems such as division by zero. Java handles these events through a structured system called exception handling. Rather than letting a program crash when something goes wrong, you can detect and respond to problems gracefully using the try, catch, and finally blocks.
What exceptions are
An exception is a signal that something has interrupted normal program flow. When an exceptional condition occurs, Java creates an Exception object and throws it. If the exception is not handled, the program terminates and prints an error stack trace. Exception handling gives you a controlled way to catch and respond to these events instead of letting them propagate unchecked.
int result = 10 / 0; // throws ArithmeticException
System.out.println("This line never runs");
Without handling, this division by zero stops the program immediately. With a proper try and catch, you can prevent termination and handle the issue safely.
Using try and catch
A try block wraps code that may generate an exception. One or more catch blocks follow it, each specifying the type of exception to handle. If a matching exception is thrown, the corresponding catch block executes; otherwise, the program ends with an error.
try {
int result = 10 / 0;
System.out.println("This line is skipped");
} catch (ArithmeticException e) {
System.out.println("Cannot divide by zero.");
}
System.out.println("Program continues normally.");
Here, the ArithmeticException is caught, and the program continues without crashing. You can have multiple catch blocks to handle different exception types.
try {
int[] data = {1, 2, 3};
System.out.println(data[5]);
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("Index out of range.");
} catch (Exception e) {
System.out.println("General error: " + e.getMessage());
}
catch blocks from top to bottom, so broader types like Exception should appear last.
The finally block
The finally block contains code that always runs, whether or not an exception occurred. It is typically used to release resources such as files, network connections, or database handles.
try {
System.out.println("Opening resource...");
int value = 5 / 0;
} catch (ArithmeticException e) {
System.out.println("Error: " + e.getMessage());
} finally {
System.out.println("Closing resource.");
}
Even though the exception occurs, the finally block executes, ensuring the resource is closed properly. This helps prevent leaks and guarantees cleanup operations are always performed.
return statement appears inside a try or catch, the finally block still runs before the method exits. This behavior ensures predictable cleanup in every situation.
Nested and rethrown exceptions
Exception handling blocks can be nested, allowing more specific handling inside broader contexts. In some cases, you may want to rethrow an exception to signal that the error should be handled elsewhere.
try {
try {
int n = Integer.parseInt("abc");
} catch (NumberFormatException e) {
System.out.println("Invalid number format.");
throw e; // rethrow to outer handler
}
} catch (Exception e) {
System.out.println("Outer handler caught: " + e);
}
Here, the inner catch detects the specific error, while the outer one performs general handling or logging. This pattern keeps code modular and separates responsibilities.
Checked and unchecked exceptions
Java divides exceptions into two broad categories:
- Checked exceptions are verified at compile time. You must either handle them with
try/catchor declare them with athrowsclause. Examples includeIOExceptionandSQLException. - Unchecked exceptions (also called runtime exceptions) occur during execution and do not need to be declared or caught. Examples include
NullPointerExceptionandArithmeticException.
Unchecked exceptions usually represent programming errors, while checked ones often represent recoverable conditions that your program should anticipate.
The role of exception handling
As well as being about preventing crashes, exception handling is also about building predictable, resilient software. By isolating risky operations in try blocks and handling problems clearly, you ensure your program behaves consistently, even when something goes wrong.
try {
readFile("data.txt");
processData();
} catch (IOException e) {
System.out.println("File error: " + e.getMessage());
} finally {
System.out.println("Operation complete.");
}
This structure defines a clear beginning, middle, and end for potentially fragile operations. Later chapters will revisit exceptions in detail, exploring custom exception classes, propagation, and advanced handling patterns.