Exception Handling in Java (with Real Examples)

Anton Bjorkman - Last Updated:

ON THIS PAGE
- What exactly are exceptions in Java?
- Why prioritize exception handling in Java Code?
- How to handle exceptions in Java
- Extracting details: How to get information from exceptions
- Tailoring errors: Creating user-defined exceptions
- In practice: Code examples for handling common java exceptions
- Java exception handling best practices for cleaner, safer code
- Proactive monitoring: Tracking and resolving Java exceptions with Sentry
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
try-catch
: The foundation of handlingThe 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:
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.
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
try-catch-catch-...-catch
: Handling multiple exception typesOne 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.
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
finally
: Code that always runsIn 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:
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
try-with-resources
: Cleaner Resource ManagementA 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:
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.
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
throw
: Raising exceptions explicitlyThe 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.
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
throws
: Declaring Potential ExceptionsMethods 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:
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
.
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.
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:
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:
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.
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:
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:
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
IOException
This exception occurs when an I/O
operation fails, such as reading from a file, writing to a stream, or working with sockets.
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
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
.
try {
Class<?> cls = Class.forName("com.example.NonExistentClass");
} catch (ClassNotFoundException e) {
System.err.println("Class not found: " + e.getMessage());
}
NoSuchMethodException
NoSuchMethodException
This exception occurs when your code attempts to access a method that doesn’t exist in the target class, typically via reflection.
try {
Class<?> cls = String.class;
cls.getMethod("nonExistentMethod");
} catch (NoSuchMethodException e) {
System.err.println("Method not found: " + e.getMessage());
}
InterruptedException
InterruptedException
This exception is thrown when a thread is waiting, sleeping, or otherwise blocked and another thread interrupts it.
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.err.println("Thread was interrupted!");
Thread.currentThread().interrupt(); // restore interrupt flag
}
InvocationTargetException
InvocationTargetException
This exception wraps an exception thrown by a method that was invoked via reflection. Use e.getCause()
to unwrap this exception.
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
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
:
try {
Thread.sleep(-100); // Negative sleep time
} catch (IllegalArgumentException e) {
System.err.println("Illegal argument: " + e.getMessage());
} catch (InterruptedException e) {
e.printStackTrace();
}
ArithmeticException
ArithmeticException
This exception is thrown when an impossible or undefined arithmetic condition occurs, such as integer division by zero.
try {
int result = 10 / 0;
} catch (ArithmeticException e) {
System.err.println("Arithmetic error: " + e.getMessage());
}
ArrayIndexOutOfBoundsException
ArrayIndexOutOfBoundsException
This exception occurs when you try to access an array element at an invalid index.
try {
int[] arr = {1, 2, 3};
int value = arr[5];
} catch (ArrayIndexOutOfBoundsException e) {
System.err.println("Array index out of bounds: " + e.getMessage());
}
NumberFormatException
NumberFormatException
This exception occurs when attempting to convert a string to a number, but the string contains no numeric characters.
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
overtry
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 intry
orcatch
. 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 specificcatch
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.