Back to Blog Home

Exception Handling in Java (with Real Examples)

Anton Bjorkman image

Anton Bjorkman - Last Updated:

Exception Handling in Java (with Real Examples)

ON THIS PAGE

Java has been a mainstay for almost 30 years at this point. Teams rely on it for cross‑platform apps, enterprise backends, and the Android OS. It’s also a popular first language for new developers.

Bugs, however, are part of the territory. Strong typing and managed memory wipe out whole categories of problems, but runtime errors still slip through. Static analysis and code review catch many issues, yet the nastier ones wait until production: a lost network connection, an out‑of‑bounds array access, situations like that. If they aren’t handled, they crash apps and frustrate users.

In Java these issues surface as exceptions. The language offers try/catch blocks, finally clauses, and a clear hierarchy of checked and unchecked exceptions. Pair that with Sentry and you get the bigger picture: who was affected, how often it happens, and which commit probably introduced the fault. With that context you can move quickly from “it crashed” to “it’s fixed.”

The rest of this guide walks through Java exceptions, when to catch them, when to let them propagate, and how Sentry helps you stay ahead of them.

What exactly are exceptions in Java?

The Java documentation defines an exception as “an event, which occurs during the execution of a program, that disrupts the normal flow of the program’s instructions.”

Typical causes of exceptions include:

  • Loss of network connectivity

  • Provision of invalid input data

  • A request for a non-existent file

  • Out-of-bounds access to an array

  • Division by zero

Like most things in Java, exceptions are represented as objects — specifically, as subclasses of Exception, as shown in the class diagram below.

Java Throwable Hierarchy

The Exception class inherits from Throwable, which allows exceptions to break out of the standard program flow, appropriate to their nature as exceptional events. When an exception is encountered, it is thrown to a higher level of the call stack. From there, it can either be thrown again, or caught by an exception handler. The handler will usually attempt to return the program to normal execution, or at least provide the user with a friendly and actionable error message.

The sister class of Exception, Error, can also be thrown and caught, but this is usually discouraged in practice. Errors generally result from breaks in the Java Virtual Machine (JVM), such as StackOverflowError, typically caused by recursive structures or infinite loops, and breaks in the environment, or OutOfMemoryError, which appears when insufficient memory has been allocated to the JVM. Errors usually lead to application crashes, so it’s best practice not to catch them.

A Java developer should have the grace to accept errors, the courage to handle exceptions, and the wisdom to know the difference.

Checked vs unchecked exceptions in Java

An exception can be checked or unchecked. In Java, RuntimeException and its subclasses are unchecked, while all other subclasses of Exception are checked.

A checked exception must be handled by the code, either by catching it or throwing it on, or the code will not compile. Java core developers and developers of libraries will make certain common exceptions checked as a way to force developers to handle these exceptions. This feature is not present in many other mainstream programming languages and has been criticized as overused. However, it can be a powerful mechanism for ensuring code hygiene when used judiciously.

Unchecked exceptions do not have to be handled. This is how all exceptions function in most other mainstream languages, such as Python and C#. As a result, handling for unchecked exceptions is usually implemented later in a codebase’s lifecycle, often after these exceptions are discovered in testing.

Why prioritize exception handling in Java Code?

By default, unhandled exceptions bubble up and abruptly terminate an application thread, potentially leaving the program in an unrecoverable state. This is not a good user experience.

Exception handling allows us to anticipate specific errors and gracefully recover from them. For example, if an application asks the user for two integers to divide, and they provide 1 and 0, an ArithmeticException with the message / by zero will arise. Unhandled, this exception will cause the application to crash, requiring the user to restart it. Handled, we can give the user an error message telling them that division by zero is undefined, ask them to pick different numbers, and continue running.

Robust exception handling is the sign of a mature and well-tested codebase.

How to handle exceptions in Java

Now that we’ve covered the why of exception handling, let’s dive into the how.

try-catch: The foundation of handling

The try-catch control flow is the most fundamental building block for exception handling. In its simplest form, it consists of two blocks of code, one in which we attempt some operation that we know to be prone to exceptions (try), and another in which we handle any exception that arises (catch). It looks something like this:

Click to Copy
try {
    functionWhichMightThrowAnException();
}
catch (Exception ex) {
    handleThisException(ex);
}

Here’s a more concrete example, dealing with an attempt to read a file that does not exist.

Click to Copy
import java.io.*;

public class Example {
    public static void main(String[] args) {
        try {
            System.out.println("Reading file...");
            FileReader reader = new FileReader("nonexistentfile.txt");
            System.out.println("Successfully read file.");
        } catch (FileNotFoundException e) {
            System.out.println("The file was not found!");
        }
    }
}

Exception handling works by altering a program’s control flow. As soon as an operation in the try block throws an error – here, that will happen inside the FileReader constructor – execution jumps to the catch block, leaving the rest of the try block unexecuted. If no exception is encountered, the entire try block will be run, and the catch block will be skipped.

try-catch-catch-...-catch: Handling multiple exception types

One try block can have multiple catch blocks for different exceptions. This allows us to be very specific in our exception handling, which is useful for more complex try blocks where different exceptions might be thrown at different parts of the process.

Exceptions will always be caught by the first matching handler, so we need to structure the catch blocks according to the Exception class hierarchy, from deepest subclass to topmost class. For example, consider the following code that reads a number from a file, converts it to an integer, and uses it as a divisor.

Click to Copy
import java.io.*;

public class MultiCatchExample {

    public static void main(String[] args) {
        String fileName = "numbers.txt";

        try {
            BufferedReader reader = new BufferedReader(new FileReader(fileName));
            String line = reader.readLine();
            int number = Integer.parseInt(line);

            // Perform a division
            int result = 100 / number;
            System.out.println("Result: " + result);

            reader.close();
        } catch (FileNotFoundException e) {
            // The file didn't exist
            System.err.println("Error: File '" + fileName + "' not found.");
        } catch (IOException e) {
            // The file was empty, corrupt, or in a non-text format
            System.err.println("Error: Problem reading the file.");
        } catch (ArithmeticException e) {
            // The provided number was 0
            System.err.println("Error: Division by zero is not allowed.");
        } catch (NumberFormatException e) {
            // The file did not contain a number
            System.err.println("Error: Invalid number format in the file.");
        } catch (Exception e) {
            // Something else went wrong
            System.err.println("Unexpected error: " + e.getMessage());
        }
    }
}

Here we’ve used multiple catch blocks to handle different exceptions that may arise in our code, including a final catch-all block. If this final catch block executes during our testing, we can use the details it provides to implement additional catch blocks for specific errors not anticipated by this code.

finally: Code that always runs

In addition to try and catch blocks, exception-handling constructs can also include a finally block. The code in this block will run regardless of whether the try succeeds or is caught. We could use this for clean-up code, or critical operations, such as a web request that needs to be responded to. For example:

Click to Copy
public class FinallyExample {

    public static void main(String[] args) {
        int sampleInteger = 1;
        String sampleText = null;
        try {
            sampleText.length();
        } catch (Exception ex) {
            // Handles the exception caused by calling a method on a null reference
            System.out.println("Caught exception: " + ex);
        } finally {
            // Executes after the try or catch block, useful for cleanup or final steps
            sampleInteger++;
            System.out.println("Finally block reached. sampleInteger value : " + sampleInteger);
        }
        System.out.println("Sample text was: " + sampleText);
    }
}

try-with-resources: Cleaner Resource Management

A common use for finally blocks is to clean up resources, for example, closing open files. A finally block for our file-handling code above might look like this:

Click to Copy
finally {
    // Clean up resources
    if (reader != null) {
        try {
            reader.close();
            System.out.println("File closed successfully.");
        } catch (IOException e) {
            System.err.println("Error closing the file.");
        }
    } else {
        System.out.println("No file was opened, so nothing to close.");
    }
}

Because this is such a common pattern, Java includes a variation of the try block that takes the file-opening code as an initial argument. This is similar to Python’s with statement.

Click to Copy
import java.io.*;

public class MultiCatchExample {

    public static void main(String[] args) {
        String fileName = "numbers.txt";

        // note that the file is now opened prior to the main try block
        try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
            String line = reader.readLine();
            int number = Integer.parseInt(line);

            // Perform a division
            int result = 100 / number;
            System.out.println("Result: " + result);

        } catch (FileNotFoundException e) {
            System.err.println("Error: File '" + fileName + "' not found.");
        } catch (IOException e) {
            System.err.println("Error: Problem reading the file.");
        } catch (ArithmeticException e) {
            System.err.println("Error: Division by zero is not allowed.");
        } catch (NumberFormatException e) {
            System.err.println("Error: Invalid number format in the file.");
        } catch (Exception e) {
            System.err.println("Unexpected error: " + e.getMessage());
        } finally {
            System.out.println("The file was closed automatically, so this block is unnecessary");
        }

    }
}

throw: Raising exceptions explicitly

The throw keyword allows us to raise an exception when a specific error condition is met. This is useful for implementing custom exceptions and deferring to exception handlers placed higher up in the code.

In this example, the function performDivision throws an ArithmeticException, which is caught in the main method.

Click to Copy
public class ThrowExample {

    public static void main(String[] args) {
        try {
            performDivision(10, 0); // divide by zero
        }
        catch (ArithmeticException ex) {
            // Catches the custom exception thrown from the method
            System.out.println("Caught exception: " + ex.getMessage());
        }
    }

    public static void performDivision(int numerator, int denominator) {
        if (denominator == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
        int result = numerator / denominator;
        System.out.println("Result: " + result);
    }

}

throws: Declaring Potential Exceptions

Methods that contain checked exceptions must either handle those exceptions internally with a try-catch block or include a throws declaration stating the class of exception thrown in their signatures. For example:

Click to Copy
import java.io.*;

public class SampleClass {

    public static void main(String[] args) {
        try {
            readFromFile("sampleFile.txt");
        }
        catch (IOException ex) {
            // Handles the IOException thrown by readFromFile
            System.out.println("Caught exception: " + ex.getMessage());
        }
    }

    public static void readFromFile(String fileName) throws IOException {
        // Declares that this method might throw an IOException
        BufferedReader reader = new BufferedReader(new FileReader(fileName));
        System.out.println(reader.readLine());
        reader.close();
    }
}

The throws declaration can also be included for unchecked exceptions, but is not strictly required. For example, we could rewrite the performDivision function in the section above to explicitly declare that it throws an ArithmeticException.

Click to Copy
    public static void performDivision(int numerator, int denominator) throws ArithmeticException {
        if (denominator == 0) {
            // Manually throws an exception to prevent division by zero
            throw new ArithmeticException("Cannot divide by zero");
        }
        int result = numerator / denominator;
        System.out.println("Result: " + result);
    }

Extracting details: How to get information from exceptions

So far, this article has focused on using exception handling to recover gracefully from errors and continue execution. However, we should not overlook the other key use of exceptions: debugging. While the solution to many exceptions is to implement handling code, some exceptions will reveal bugs in the code that require different fixes. In these cases, it is useful to get as much information as possible about a given exception.

In the code below, we use the catch block to print various information about the exception using its methods.

Click to Copy
public class SampleClass{

    public static void main(String[] args)
    {
        try
        {
            String sampleText = null;
            sampleText.length();
        }
        catch (Exception ex){
            System.out.println("toString");
            System.out.println(ex.toString());
            System.out.println("\ngetMessage");
            System.out.println(ex.getMessage());
            System.out.println("\nprintStackTrace");
            ex.printStackTrace();
        }
    }
}

The code above will produce the following:

Click to Copy
toString
java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null

getMessage
Cannot invoke "String.length()" because "<local1>" is null

printStacktrace
java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
    at SampleClass.main(SampleClass.java:7)
```

Notice that toString() prints out the exception class and message, while getMessage() prints just the message.

You can use printStackTrace() to produce a stack trace showing the flow of execution up to the point of the exception. While this is invaluable for debugging, you should avoid showing stack traces in production builds of applications, as they provide detailed information about the application’s internal structure, which could be exploited.

Tailoring errors: Creating user-defined exceptions

We can define custom exceptions in order to handle failure cases specific to an application. For example, we might want to use custom exceptions as part of input validation, to provide guidance to users.

A custom exception is a class that inherits from Exception or one of its subclasses. Exceptions that inherit from RuntimeException will be unchecked, while all others will be checked. Here are some simple examples:

Click to Copy
public class InvalidAgeException extends Exception {

    public InvalidAgeException(String message)
    {
        super(message);
    }
}

public class UncheckedInvalidAgeException extends RuntimeException {

    public UncheckedInvalidAgeException(String message)
    {
        super(message);
    }
}

We can use custom exceptions by throwing and catching them the same way we use built-in exceptions.

Click to Copy
public class CustomExceptionExample {

    public static void main(String[] args)
    {
        try
        {
            process(100);
        }
        catch (InvalidAgeException e){
                System.out.println("Caught an exception: " + e.getMessage());
        }
    }

    public static void process(int age) throws InvalidAgeException {
        if (age > 99) {
            throw new InvalidAgeException("You are too old to play with LEGO.");
        }
    }

    public static void processUnchecked(int age) { // throws not required for unchecked
        if (age > 99) {
            throw new UncheckedInvalidAgeException("You are too old to play with LEGO.");
        }
    }

}

Custom exceptions can include additional constructor arguments, allowing you to pass in specific data when throwing them. For example, we could define an InsufficientFundsException that returns the exact shortfall, as below:

Click to Copy
public class InsufficientFundsException extends Exception {
    private double deficit;

    public InsufficientFundsException(String message, double deficit) {
        super(message);
        this.deficit = deficit;
    }

    public double getDeficit() {
        return deficit;
    }
}

We could use this as follows:

Click to Copy
public class BankApp {

    public static void main(String[] args) {
        BankAccount account = new BankAccount(100.0);

        try {
            account.withdraw(150.0);  // Will throw InsufficientFundsException
        } catch (InsufficientFundsException e) {
            System.err.println("Withdrawal failed: " + e.getMessage());
            System.err.println("Deficit amount: $" + e.getDeficit());
        }
    }
}

// Custom exception class
class InsufficientFundsException extends Exception {
    private double deficit;

    public InsufficientFundsException(String message, double deficit) {
        super(message);
        this.deficit = deficit;
    }

    public double getDeficit() {
        return deficit;
    }
}

// Bank account class
class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) {
            double shortage = amount - balance;
            throw new InsufficientFundsException("Insufficient funds! Short by: $" + shortage, shortage); // second argument included
        }
        balance -= amount;
        System.out.println("Withdrawal successful. Remaining balance: $" + balance);
    }
}

In practice: Code examples for handling common java exceptions

Below, we’ve provided code examples for dealing with some of the most common exceptions you’ll encounter in Java. Feel free to use them in your own code.

Common checked exceptions

The following code snippets can be used to get your code to compile when using methods that throw checked exceptions. Many of them involve advanced programming concepts like reflection, so don’t worry if you don’t totally understand what they mean yet.

IOException

This exception occurs when an I/O operation fails, such as reading from a file, writing to a stream, or working with sockets.

Click to Copy
try {
    BufferedReader reader = new BufferedReader(new FileReader("nonexistentfile.txt"));
    String line = reader.readLine();
    reader.close();
} catch (IOException e) {
    System.err.println("I/O error: " + e.getMessage());
}

ClassNotFoundException

This exception occurs when your code tries to load a class by name (typically using reflection or class loading) that isn’t available in the classpath.

Click to Copy
try {
    Class<?> cls = Class.forName("com.example.NonExistentClass");
} catch (ClassNotFoundException e) {
    System.err.println("Class not found: " + e.getMessage());
}

NoSuchMethodException

This exception occurs when your code attempts to access a method that doesn’t exist in the target class, typically via reflection.

Click to Copy
try {
    Class<?> cls = String.class;
    cls.getMethod("nonExistentMethod");
} catch (NoSuchMethodException e) {
    System.err.println("Method not found: " + e.getMessage());
}

InterruptedException

This exception is thrown when a thread is waiting, sleeping, or otherwise blocked and another thread interrupts it.

Click to Copy
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    System.err.println("Thread was interrupted!");
    Thread.currentThread().interrupt(); // restore interrupt flag
}

InvocationTargetException

This exception wraps an exception thrown by a method that was invoked via reflection. Use e.getCause() to unwrap this exception.

Click to Copy
try {
    Method m = String.class.getMethod("length");
    Object result = m.invoke(null);
} catch (InvocationTargetException e) {
    System.err.println("Method threw an exception: " + e.getCause());
} catch (Exception e) {
    System.err.println("Reflection error: " + e.getMessage());
}

Common unchecked exceptions

Unlike checked exceptions, these exceptions don’t need to be handled for code to compile. Often, the appearance of unchecked exceptions can indicate bugs that require deeper fixes than adding exception handling. For cases when exception handling is the right approach, the snippets below may be helpful.

IllegalArgumentException

This exception is thrown when a method receives an argument value that it cannot use, for example, providing a negative number of milliseconds to Thread.sleep:

Click to Copy
try {
    Thread.sleep(-100); // Negative sleep time
} catch (IllegalArgumentException e) {
    System.err.println("Illegal argument: " + e.getMessage());
} catch (InterruptedException e) {
    e.printStackTrace();
}

ArithmeticException

This exception is thrown when an impossible or undefined arithmetic condition occurs, such as integer division by zero.

Click to Copy
try {
    int result = 10 / 0;
} catch (ArithmeticException e) {
    System.err.println("Arithmetic error: " + e.getMessage());
}

ArrayIndexOutOfBoundsException

This exception occurs when you try to access an array element at an invalid index.

Click to Copy
try {
    int[] arr = {1, 2, 3};
    int value = arr[5];
} catch (ArrayIndexOutOfBoundsException e) {
    System.err.println("Array index out of bounds: " + e.getMessage());
}

NumberFormatException

This exception occurs when attempting to convert a string to a number, but the string contains no numeric characters.

Click to Copy
try {
    int number = Integer.parseInt("abc");
} catch (NumberFormatException e) {
    System.err.println("Invalid number format: " + e.getMessage());
}

Java exception handling best practices for cleaner, safer code

  • Use the most specific exception class possible.

  • Avoid empty catch blocks.

  • Avoid complex logic in catch blocks.

  • Use try-with-resources over try where possible.

  • Don’t use an exception if an error case can be handled through ordinary program flow.

  • Use finally with care, as it always runs regardless of what happens in try or catch. Brittle logic in this block will cause additional exceptions.

  • Avoid displaying detailed exception information to the user in production builds of your applications.

  • Include a generic catch (Exception e) block after more specific catch blocks to ensure that exceptions you didn’t anticipate are caught.

  • Define custom exceptions, including custom checked exceptions, as they are invaluable for other developers using your code.

  • Document the exceptions your methods throw, especially unchecked exceptions.

Proactive monitoring: Tracking and resolving Java exceptions with Sentry

While your compiler and runtime environments provide basic information on exceptions, it’s not comprehensive and is generally limited to development environments. Performance monitoring products like Sentry provide greater visibility into your code and exceptions across different builds and environments, including in production, giving you a comprehensive overview of unhandled exceptions so that you can prioritize bug fixes appropriately, without relying on end-user bug reports or exposing sensitive information in your application’s frontend.

Sentry has several features that make it easier for you to understand what your exceptions are, how they are related, and how they tie to other components like APIs. It organizes similar exceptions into issues, minimizing the time needed to review and triage exceptions, and has built-in algorithms for grouping issues, which developers can also define using their own grouping criteria.

With its distributed tracing methods, Sentry also monitors application performance and highlights each issue’s impact on users, including how exceptions impact application latency. Using Trace View, Sentry provides more extensive information about each exception.

For each issue, Sentry shows you how frequently it occurs and how many users it impacts, making it easier for you to prioritize your remediation efforts.

You can hover over an issue to get more specifics about what parts of the code give rise to the exceptions.

For even greater detail, you can follow the dropdown next to each specific code item to see the code itself.

In addition, with the breadcrumbs feature, developers can visualize a detailed timeline of the events that occurred before a specific error.

As with other Sentry features, you can customize breadcrumbs to meet your own needs and debugging tactics.

Sentry knows that you may have sensitive data that you have to protect at all times, such as user credentials. Advanced data scrubbing algorithms are applied to redact sensitive information from your data sources. You can also define and implement your own data-scrubbing methods.

Sentry also monitors performance and highlights each issue’s impact on users, including how exceptions impact application latency. Sentry’s Performance Monitoring features give you insight into the success or failure of transactions and how they impact your users.

You can drill down through transactions to see the users involved and the individual transaction traces for each individual. And for each trace, you can drill down even further for more visibility.

However, even the most advanced tool will fail if it isn’t intuitive. Sentry provides a rich set of easily comprehensible, customizable overview dashboards so you can immediately assess the health of your application. With easily added dashboard widgets, you can build a view that focuses on your primary concerns.

Sentry provides powerful and intuitive tools for tracing and resolving exceptions and other issues at all stages of the development lifecycle, for developers of all skill levels. Sign up for free and try it out in your Java applications today.

FAQs

Java exceptions are broadly categorized into checked and unchecked exceptions. Checked exceptions are detected during compilation, while unchecked exceptions occur at runtime. Additionally, developers can create custom exceptions to handle specific business logic.

Checked exceptions include ClassNotFoundException, NoSuchMethodException, and InterruptedException, among others. These exceptions typically arise from issues like missing classes, non-existent methods, or interrupted thread operations. Handling them involves using try-catch blocks to catch and handle the exceptions gracefully.

Unchecked exceptions, such as IllegalArgumentException and ArithmeticException, occur at runtime and are often a result of improper input or arithmetic operations. Unlike checked exceptions, they are not required to be caught explicitly but should be handled appropriately for robust error management.

Sentry streamlines the debugging process by organizing exceptions into issues, providing detailed insights into their occurrence frequency and impact on users. Developers can visualize exception traces, analyze code segments, and scrub sensitive data for enhanced security. Additionally, Sentry offers comprehensive performance monitoring to track application latency and transaction success/failure.

Sentry offers a user-friendly interface with customizable overview dashboards, allowing developers to assess application health at a glance. Its intuitive navigation and drill-down features enable detailed analysis of exception traces, transactions, and performance metrics, empowering developers to resolve issues efficiently.

Sentry employs advanced data scrubbing algorithms to redact sensitive information from exception traces and performance metrics, ensuring data privacy and compliance with security standards. Developers can also define and implement custom data scrubbing methods to safeguard sensitive data effectively.

Developers can access Sentry’s documentation and signup to start leveraging its features for exception monitoring and debugging in Java applications. Sentry offers comprehensive guides and resources to help developers integrate and configure the platform effectively.

Share

Share on Twitter
Share on Bluesky
Share on HackerNews
Share on LinkedIn

Originally Published

Last Updated

Sentry Sign Up CTA

Code breaks, fix it faster

Sign up for Sentry and monitor your application in minutes.

Try Sentry Free

Topics

Debugging
How Anthropic solved scaling log volume with Sentry

How Anthropic solved scaling log volume with Sentry

Listen to the Syntax Podcast

Of course we sponsor a developer podcast. Check it out on your favorite listening platform.

Listen To Syntax

Company

© 2025 • Sentry is a registered Trademark of Functional Software, Inc.