You closed module 5 with an honest inventory: BiblioTech manages a catalogue, loans, reservations, notices and undo, but it is fragile in a way that can no longer be ignored. An Integer.parseInt("twenty") brings the application down. A returned false does not say why it failed. A System.out.println("WARNING: ...") cannot be filtered, archived or handled. And an operation that fails halfway leaves the system inconsistent.

This module solves that, and it starts with the most basic thing and at the same time the worst understood: what an exception really is. It is not "an error that breaks the program". It is an object —with its class, its state and its methods— that represents a failure and triggers a very particular transport mechanism: instead of being returned to the caller as a value, it travels backwards up the call stack looking for someone to take charge of it. Understanding that journey is understanding everything else.

In this lesson you are not going to write a single try or catch. That is the next lesson. Here it is about reading and understanding the failure: why return codes are a worse solution, what exactly happens when nobody catches an exception, how a stack trace is read from top to bottom —including the Caused by: section, which is where the truth usually lies—, how the Throwable hierarchy is organised and what each branch means, and what the difference is between checked and unchecked exceptions, which is the most argued-about design decision in the language. When you finish, an exception dump will stop being an intimidating wall of text and will become what it really is: a detailed report telling you exactly what failed and where.

Contents

  1. The starting point: BiblioTech's mute false
  2. What an exception is
  3. Why return codes are worse
  4. What happens when nobody catches: propagation up the stack
  5. Anatomy of a stack trace
  6. Caused by:: the chain of causes
  7. The Throwable hierarchy
  8. Error: what you must not try to handle
  9. RuntimeException: programming errors
  10. Checked exceptions
  11. Checked versus unchecked: the design criterion
  12. The real debate about checked exceptions
  13. The NullPointerException messages of Java 14+
  14. What you must never do
  15. Common Mistakes and Tips
  16. Exercises

  1. The starting point: BiblioTech's mute false

This is a real method from the BiblioTech you built in module 5. Look at it closely, because it contains the problem that gives the whole module its meaning:

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.nexussoftware.bibliotech.domain.Book;
import com.nexussoftware.bibliotech.domain.Material;

/** Module 5 version: reports failures with booleans and with println. */
public class Catalog {

    private final List<Material> materials = new ArrayList<>();
    private final Map<String, Material> indexByReference = new HashMap<>();
    private final Set<String> registeredIsbns = new HashSet<>();

    public boolean register(Material material) {
        if (material == null) {
            System.out.println("WARNING: null material");
            return false;                                   // (1)
        }
        if (indexByReference.containsKey(material.getReference())) {
            System.out.println("WARNING: duplicate reference");
            return false;                                   // (2)
        }
        if (material instanceof Book book && !registeredIsbns.add(book.getIsbn())) {
            System.out.println("WARNING: duplicate ISBN");
            return false;                                   // (3)
        }
        materials.add(material);
        indexByReference.put(material.getReference(), material);
        return true;
    }

    public Material findByReference(String reference) {
        return indexByReference.get(reference);             // returns null if it does not exist
    }
}

The method works. But look at what reaches the caller:

boolean ok = catalog.register(material);
if (!ok) {
    // And now what? Why did it fail?
    // Was it null? Duplicate reference? Duplicate ISBN?
    // The boolean does not say. All three cases are the same false.
}

That is the mute false: a value that reports that something went wrong, but which erases all the information about what went wrong. Three radically different causes —a programming failure (null), a data conflict (repeated reference) and a business rule violation (ISBN already catalogued)— collapse into the same bit. And the caller cannot react differently to each one, because it does not know which one happened.

Worse still: the real reason has been printed to the console, where the program cannot read it. A System.out.println is not a communication channel between methods; it is a text dump. If register is called from a web service, from a batch process or from an automated test, nobody sees that warning.

And findByReference is even more dangerous, because it returns null in the normal "does not exist" case. The null travels merrily through the program until somebody uses it:

Material m = catalog.findByReference("BK-999");     // does not exist: returns null
System.out.println(m.getTitle());                   // NullPointerException, far from the origin

The explosion happens somewhere other than where the problem was. The real error was on line 1 (looking for something that does not exist); the symptom appears on line 2. This displacement between cause and symptom is one of the biggest sources of wasted debugging time.

Exceptions solve exactly this: they let you signal a failure with a name, with data and at the exact moment it is detected.

  1. What an exception is

An exception is a Java object, an instance of a class descending from java.lang.Throwable, that represents an anomalous condition during the execution of the program.

The three important words in that definition:

  • Object: it is not a magic keyword nor a numeric code. It is an ordinary object, with its type, its fields and its methods. It can be created with new, stored in a variable, passed as a parameter and even put in a list. What makes it special is how it is transported.
  • Represents an anomalous condition: a file that is not there, a badly written number, an index out of range, a business rule violated. Something that prevents the method from fulfilling its contract.
  • During execution: it is not a compilation error. The code compiles perfectly; the failure appears when it runs with particular data.

Look at any exception for what it is —an object with state:

public class WhatIsAnException {
    public static void main(String[] args) {
        // An exception is created with new, like any other object.
        // Creating it does NOT throw it: here it simply sits in a variable.
        IllegalArgumentException failure =
                new IllegalArgumentException("The daily rate cannot be negative: -0.25");

        System.out.println("Class   : " + failure.getClass().getName());
        System.out.println("Message : " + failure.getMessage());
        System.out.println("toString: " + failure);
        System.out.println("Frames  : " + failure.getStackTrace().length);
        System.out.println("Cause   : " + failure.getCause());

        System.out.println("The program is still alive: creating an exception does not throw it.");
    }
}

Output:

Class   : java.lang.IllegalArgumentException
Message : The daily rate cannot be negative: -0.25
toString: java.lang.IllegalArgumentException: The daily rate cannot be negative: -0.25
Frames  : 1
Cause   : null
The program is still alive: creating an exception does not throw it.

Three observations worth engraving from the outset:

  1. Creating an exception does not throw it. The object exists, but the program flow is not altered. Throwing it requires the word throw, which you will see in 06-03.
  2. The object already contains the call stack at the moment of its construction (getStackTrace() returns an array of frames). That capture happens in the constructor, not when it is thrown. It is the most expensive part of creating an exception, and it will come back in 06-02 when we talk about performance.
  3. The message is free text, and its quality depends entirely on whoever writes it. "error" and "The daily rate cannot be negative: -0.25" cost the same; only one of the two is of any use.

When an exception is thrown, two things happen simultaneously:

  • The execution of the method is interrupted immediately at that point. The following lines of the method do not run.
  • The exception object starts to propagate backwards up the call stack, looking for a handler. That journey is section 4.

  1. Why return codes are worse

Before exceptions —and still today in languages like C or Go— failures are reported with the return value: -1, null, false, a numeric code. Java allows this, and sometimes it is even the right thing. But as a general mechanism it has four serious defects.

Problem Return code Exception
Can be ignored Yes: catalog.register(m); compiles and discards the result silently No: if it is not handled, the program stops noisily
Carries information A false does not say why; neither does a -1 Class + message + its own data + the full stack
Occupies the return value If the method already returns a Material, you have to invent an "impossible" value (null) The return value stays free for its real purpose
Pollutes the caller Every call needs its checking if, mixed in with the logic The normal path stays clean; handling is kept separate

The first defect is the decisive one. Compare:

// With a return code: ignoring the failure is TRIVIAL and also invisible
catalog.register(duplicateMaterial);        // returns false... and nobody cares
processAsIfRegistered();                    // the program carries on with incorrect data

// With an exception: ignoring the failure is IMPOSSIBLE in silence
catalog.register(duplicateMaterial);        // throws DuplicateReferenceException
processAsIfRegistered();                    // this line does NOT run

With the boolean, the program carries on on a false premise: it believes the material is registered and it is not. Errors that propagate silently, contaminating state, are far more expensive to diagnose than those that stop the program, because the symptom appears minutes or hours later, somewhere else, with no apparent connection to the cause.

The fourth defect, pollution of the caller, is best seen with a complete flow. This is how a loan looks with return codes:

// RETURN CODE STYLE: the real logic is buried among checks
public boolean lend(String reference, String employeeId, int day) {
    Material m = catalog.findByReference(reference);
    if (m == null) { return false; }                 // does not exist... or maybe it does and something else failed
    if (!m.isAvailable()) { return false; }          // not available
    Employee e = employeeRegistry.find(employeeId);
    if (e == null) { return false; }                 // unknown employee
    if (!e.canBorrow()) { return false; }            // limit exceeded
    boolean ok = registry.record(m, e, day);
    if (!ok) { return false; }                       // why?
    return true;
}

Five guard ifs, five indistinguishable return false, and whoever calls lend will receive a single boolean with five possible meanings. And on top of that, each of those false values returned by lend will end up turned into another false in the layer above, losing even more information at every hop.

At the end of the module, that same method will throw MaterialNotFoundException, MaterialNotAvailableException, UnknownEmployeeException or LoanLimitExceededException, each with the specific data of the failure, and the presentation layer will be able to decide which message to show in each case.

That said, return codes are not always bad. Map.get returning null and Set.add returning false are perfectly reasonable designs, because in those cases "not there" and "already there" are normal results, not failures. The informal rule is: if the condition is part of the expected behaviour and the caller is always going to check it, a return value is fine; if it is a failure that prevents the method from fulfilling its contract, it is an exception. In 06-07 you will come back to this criterion with a complete table.

  1. What happens when nobody catches: propagation up the stack

Here is the central mechanism of the module. Recall the call stack from 05-08: every time a method calls another, the JVM pushes a frame (stack frame) with the local variables and the return point; when the method finishes, its frame is popped.

When an exception is thrown, that popping happens all at once and without running the rest of the methods. That is what is called stack unwinding.

Look at a concrete BiblioTech case:

package com.nexussoftware.bibliotech.presentation;

public class PropagationDemo {

    public static void main(String[] args) {
        System.out.println("1. main starts");
        processRequest("twelve");                  // the user typed "twelve" instead of 12
        System.out.println("2. main ends");        // NEVER RUNS
    }

    static void processRequest(String input) {
        System.out.println("3. processRequest starts");
        int days = calculateDays(input);
        System.out.println("4. days = " + days);   // NEVER RUNS
    }

    static int calculateDays(String text) {
        System.out.println("5. calculateDays starts");
        return Integer.parseInt(text);             // HERE NumberFormatException is thrown
    }
}

Real output:

1. main starts
3. processRequest starts
5. calculateDays starts
Exception in thread "main" java.lang.NumberFormatException: For input string: "twelve"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:665)
	at java.base/java.lang.Integer.parseInt(Integer.java:781)
	at com.nexussoftware.bibliotech.presentation.PropagationDemo.calculateDays(PropagationDemo.java:17)
	at com.nexussoftware.bibliotech.presentation.PropagationDemo.processRequest(PropagationDemo.java:12)
	at com.nexussoftware.bibliotech.presentation.PropagationDemo.main(PropagationDemo.java:6)

Messages 2 and 4 do not appear. It is not that they were skipped: it is that their methods were abandoned mid-execution. No line after the throwing point ran in any of the methods in the chain.

This is the complete journey:

flowchart TB
    subgraph before["Stack just before the failure"]
        direction TB
        A3["parseInt(text) ← TOP"]
        A2["calculateDays('twelve')"]
        A1["processRequest('twelve')"]
        A0["main(args) ← BOTTOM"]
        A3 --> A2 --> A1 --> A0
    end
    before -->|"NumberFormatException is thrown"| B1
    subgraph unwinding["Unwinding: looks for a handler and finds none"]
        direction TB
        B1["parseInt: no catch, the frame is discarded"]
        B2["calculateDays: no catch, the frame is discarded"]
        B3["processRequest: no catch, the frame is discarded"]
        B4["main: no catch, the frame is discarded"]
        B1 --> B2 --> B3 --> B4
    end
    B4 --> C["The exception reaches the JVM:<br/>the thread's default handler"]
    C --> D["Prints the stack trace to System.err"]
    D --> E["The 'main' thread ENDS<br/>non-zero exit code"]

The steps, in detail:

  1. Integer.parseInt detects that "twelve" is not a number and throws a NumberFormatException object.
  2. The JVM looks in the current method for a handler (catch) able to deal with that type. There is none. It discards the frame of parseInt.
  3. It moves up to the caller, calculateDays. There is no catch there either. It discards its frame. The return never completes.
  4. It moves up to processRequest. Nothing there either. It discards its frame. The println("4. ...") line is left unexecuted.
  5. It moves up to main. Nothing. It discards its frame.
  6. There are no more frames: the exception reaches the thread's default uncaught exception handler, which prints Exception in thread "main" ... followed by the stack trace to System.err (not to System.out).
  7. The main thread ends. If no other non-daemon threads are alive, the JVM shuts down with a non-zero exit code (typically 1), which is what a script or a continuous integration system reads as "this process failed".

Three important clarifications that almost nobody explains:

  • The thread ends, not necessarily the JVM. In a single-threaded program they coincide. But if an exception escapes from a secondary thread (module 8), that thread dies and the rest of the program keeps running, often without anybody noticing. It is a classic source of silent failures.
  • The stack trace is printed to System.err. If you redirect standard output to a file but not error output, or the other way round, you will see half the story. And since System.out and System.err are flushed independently, it is common for them to appear in the console interleaved and out of order, which is very confusing when debugging.
  • That default handler can be replaced. Thread.setDefaultUncaughtExceptionHandler lets you decide what happens to exceptions that escape everything. It is one of the pieces of the "error boundary" you will build in 06-07.

  1. Anatomy of a stack trace

In 02-05 you learned to read a stack trace as a debugging tool. Now comes the complete and precise reading, because the stack trace is the most valuable document you have when something fails in production and you cannot reproduce it.

Go back to the dump from the previous section, now annotated:

Exception in thread "main" java.lang.NumberFormatException: For input string: "twelve"
└──── (A) ────┘ └─ (B) ─┘  └──────── (C) ───────────┘  └─────────── (D) ──────────┘
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:665)
	at java.base/java.lang.Integer.parseInt(Integer.java:781)
	at com.nexussoftware.bibliotech.presentation.PropagationDemo.calculateDays(PropagationDemo.java:17)
	at com.nexussoftware.bibliotech.presentation.PropagationDemo.processRequest(PropagationDemo.java:12)
	at com.nexussoftware.bibliotech.presentation.PropagationDemo.main(PropagationDemo.java:6)
	└─ (E) ─┘└──────────── (F) ─────────────┘└───── (G) ────┘└──────── (H) ───────┘
Mark Element What it tells you
(A) Exception in thread Fixed text from the default handler: nobody caught this
(B) "main" Name of the thread that died. Key in multithreaded programs
(C) java.lang.NumberFormatException Class of the exception: the what that failed
(D) For input string: "twelve" Message: the specific detail, with the offending data
(E) at Every at line is a stack frame
(F) com...PropagationDemo Class with its full package
(G) .calculateDays Method where execution was
(H) (PropagationDemo.java:17) Exact file and line number

And now the golden rule for reading it:

The first at line is where the failure happened. The last one is where everything started. Your code is usually in the middle.

The order is from the top of the stack to the bottom, that is, from the deepest point backwards. That is why main always appears at the end: it is the bottom of the stack.

A practical reading strategy, in this order:

  1. Read the class and the message (line 1). Very often that is enough: NumberFormatException: For input string: "twelve" is a complete diagnosis.
  2. Go down to the first line containing your package (com.nexussoftware.bibliotech). The java.base/... lines, or those of Spring, Hibernate or any library, are usually just the path; the error is almost always in how you called them. In the example, PropagationDemo.calculateDays(PropagationDemo.java:17) is the first "your" line: that is where the problem is.
  3. Keep going down to reconstruct the path: who called whom and with what data. This tells you how you got to that situation, which is what is usually missing.
  4. Look for Caused by:, if there is one. That is the next section, and it is where the truth lies in framework-based applications.

A detail that confuses a lot of people: in the example, Integer.parseInt appears twice with different lines (665 and 781). It is not a mistake in the dump: parseInt(String) is an overload that internally calls parseInt(String, int radix). Each frame is a different invocation, even though the method name repeats.

Another observation: java.base/ in front of the class name is the platform module (JPMS, Java 9+, the subject of 10-06). It indicates that the class comes from the JDK, not from your code or from a library. It is a very useful visual filter: everything starting with java.base/ belongs to the standard library.

Finally, two cases that come as a surprise:

  • Stack traces truncated with ... 23 more. Java does not repeat the frames shared between an exception and its cause. ... 23 more means "the next 23 frames are identical to those of the previous block". It is not lost information.
  • Empty traces or traces with no lines. With JIT optimisation, certain very repeated exceptions (like NullPointerException thrown in a hot loop) can end up being thrown without a stack trace because of an optimisation called fast throw. If you see an exception with no trace, start it with -XX:-OmitStackTraceInFastThrow and it will come back complete.

  1. Caused by:: the chain of causes

In a real layered application —and BiblioTech will be one by the end of this module—, a low-level exception is wrapped in a higher-level one before continuing upwards. The result is a chain of causes, and the stack trace shows all of them.

An example with the typical three-layer structure:

Exception in thread "main" com.nexussoftware.bibliotech.service.LoanFailedException: Could not register the loan of material BK-0001 for employee EMP-004
	at com.nexussoftware.bibliotech.service.LoanManager.lend(LoanManager.java:88)
	at com.nexussoftware.bibliotech.presentation.BiblioTechMenu.lendOption(BiblioTechMenu.java:142)
	at com.nexussoftware.bibliotech.presentation.BiblioTechApp.main(BiblioTechApp.java:31)
Caused by: com.nexussoftware.bibliotech.domain.MaterialNotAvailableException: Material BK-0001 has been on loan since day 12
	at com.nexussoftware.bibliotech.domain.Material.lend(Material.java:64)
	at com.nexussoftware.bibliotech.service.LoanManager.lend(LoanManager.java:84)
	... 2 more
Caused by: java.lang.IllegalStateException: The total loans counter is negative: -1
	at com.nexussoftware.bibliotech.domain.Employee.registerLoan(Employee.java:47)
	at com.nexussoftware.bibliotech.domain.Material.lend(Material.java:61)
	... 3 more

How this is read:

Block What it is Usefulness
The first The highest-level exception, the one that reached the JVM Says which business operation failed
Each Caused by: The exception that triggered the previous one Goes down one level of abstraction
The last Caused by: The root cause This is where the real problem is

Practical rule: read the first line to know which operation failed and go down to the last Caused by: to know why. The middle is the path between the two.

In the example, the operation that failed is "register a loan" (block 1), the immediate reason is that the material was not available (block 2), but the root cause is a loans counter that has gone negative (block 3). Fixing the top level would achieve nothing: the problem is in Employee.registerLoan.

And here an important preview of 06-03: this chain only exists if whoever wraps the exception preserves the cause. If in LoanManager.lend someone had written:

// BAD: loses all the information about what really happened
throw new LoanFailedException("Could not register the loan");

...the stack trace would have stopped at the first block, and the two Caused by: sections —the ones containing the useful information— would have disappeared for ever. This mistake, which in 06-03 you will call "losing the cause", is responsible for an enormous number of hours wasted in production.

  1. The Throwable hierarchy

Everything that can be thrown in Java descends from a single class: java.lang.Throwable. Its descendants are organised like this:

flowchart TB
    T["Throwable<br/>(root: everything throwable)"]
    T --> E["Error<br/>serious JVM failures<br/>UNCHECKED"]
    T --> X["Exception<br/>anomalous conditions<br/>CHECKED"]
    X --> R["RuntimeException<br/>programming errors<br/>UNCHECKED"]

    E --> E1["StackOverflowError"]
    E --> E2["OutOfMemoryError"]
    E --> E3["NoClassDefFoundError"]

    X --> X1["IOException"]
    X --> X2["SQLException"]
    X --> X3["ClassNotFoundException"]
    X1 --> X4["FileNotFoundException"]

    R --> R1["NullPointerException"]
    R --> R2["IllegalArgumentException"]
    R --> R3["IllegalStateException"]
    R --> R4["IndexOutOfBoundsException"]
    R --> R5["ClassCastException"]
    R --> R6["ArithmeticException"]
    R2 --> R7["NumberFormatException"]
    R4 --> R8["ArrayIndexOutOfBoundsException"]
    R4 --> R9["StringIndexOutOfBoundsException"]

This hierarchy is not decorative. It has three practical consequences that govern everything else:

  1. Only objects descending from Throwable can be thrown and caught. You cannot throw a String or an int.
  2. The branch determines whether the compiler forces you to do anything. Exception (excluding RuntimeException) is checked: the compiler requires you to catch it or declare it. Error and RuntimeException are unchecked: the compiler says nothing.
  3. Catching a superclass catches all its descendants. catch (Exception e) also catches NumberFormatException, because it descends from it. This is what gives meaning —and danger— to broad catches, and what makes the ordering of catch blocks you will see in 06-02 compulsory.

What Throwable gives all its descendants:

Method What it returns
getMessage() The detail message passed to the constructor
getLocalizedMessage() The same, but designed to be overridden with a translation
toString() fully.qualified.ClassName: message
printStackTrace() Prints the full trace to System.err
getStackTrace() The array of StackTraceElement with the frames
getCause() The exception that triggered it, or null
getSuppressed() Suppressed exceptions (06-06)

Notice that Throwable has two direct children with opposite fates: Error, which you must not handle, and Exception, which you should. That separation is the reason why Throwable is never caught: doing so lumps together things that are treated in completely different ways.

  1. Error: what you must not try to handle

The Error branch represents failures of the virtual machine or the environment, not of your program's logic. They are situations from which, in general, an application cannot sensibly recover.

Error Typical cause
StackOverflowError Call stack exhausted: recursion with no base case or too deep (you saw this in 05-08)
OutOfMemoryError The heap is exhausted: a memory leak, a collection growing without limit, or an insufficient -Xmx
NoClassDefFoundError A class that was there at compile time does not appear on the classpath at run time
ExceptionInInitializerError An exception escaped from a static initialiser (static { ... })
AssertionError An assert assertion failed (or a test framework threw it)

StackOverflowError is the easiest to provoke, and you already know it from module 5:

public class RunawayRecursion {
    // No base case: every call pushes one more frame... until the stack runs out
    static int countLoans(int n) {
        return 1 + countLoans(n - 1);
    }

    public static void main(String[] args) {
        countLoans(10);   // StackOverflowError after ~10,000-50,000 frames
    }
}

Its stack trace is instantly recognisable: thousands of identical repeated lines.

Exception in thread "main" java.lang.StackOverflowError
	at RunawayRecursion.countLoans(RunawayRecursion.java:4)
	at RunawayRecursion.countLoans(RunawayRecursion.java:4)
	at RunawayRecursion.countLoans(RunawayRecursion.java:4)
	... (thousands of identical lines)

When you see that, do not look for the error in the line that repeats: look for it in the stopping condition that is missing or never met.

The rule with Errors is blunt: do not catch them. If your program runs out of memory, catching the OutOfMemoryError fixes nothing —most likely the next new will fail again, and on top of that the handler itself may need memory to run. The right answer is to let the process die, have a supervisor restart it, and analyse the cause: a heap dump, a leak, a badly sized limit. That is the subject of 10-07.

The only reasonable exception to this rule is an application server or a framework that catches Throwable in its main loop in order to log it before dying, never to carry on as if nothing had happened. You, writing business logic, do not have that case.

  1. RuntimeException: programming errors

RuntimeException and its descendants are unchecked exceptions: the compiler does not force you to catch them or declare them. The design criterion behind that decision is clear and worth remembering:

A RuntimeException normally signals a programming error: something that should not have happened if the code were correctly written. The solution is not to catch it, it is to fix the code.

These are the ones you will see most, with their typical cause and their real remedy:

Exception Typical cause How it is really fixed
NullPointerException Using a member of a null reference Check first, or do not allow the null at the source (Objects.requireNonNull, 06-03)
ArrayIndexOutOfBoundsException array[i] with i < 0 or i >= length Review the loop bound (< versus <=)
StringIndexOutOfBoundsException charAt/substring out of range Check the length beforehand
IndexOutOfBoundsException list.get(i) out of range Same, with size()
NumberFormatException Integer.parseInt("twelve") Validate the input or catch at the boundary (06-02)
ArithmeticException Integer division by zero: 5 / 0 Check the divisor. With double, 5.0/0 gives Infinity, not an exception
ClassCastException Casting to an incompatible type Use instanceof with a pattern, or generics (10-01)
IllegalArgumentException A method receives an invalid argument Validate in the caller; it is thrown by the method that detects it
IllegalStateException Calling a method at an inappropriate moment Review the object's life cycle
UnsupportedOperationException Modifying an immutable collection (List.of, Arrays.asList) Copy to a mutable list
ConcurrentModificationException Modifying a collection while iterating it (05-04) Use Iterator.remove or removeIf
NoSuchElementException next() with no more elements; remove() on an empty queue (05-07) Check hasNext(), or use poll()

A short program that provokes several of them on purpose, so you can recognise them by their message:

package com.nexussoftware.bibliotech.presentation;

import java.util.List;

/** Provokes typical exceptions one at a time. Run it with the scenario number. */
public class FailureShowcase {

    public static void main(String[] args) {
        int scenario = args.length > 0 ? Integer.parseInt(args[0]) : 1;

        switch (scenario) {
            case 1 -> {
                String title = null;
                System.out.println(title.length());
                // NullPointerException: Cannot invoke "String.length()" because "title" is null
            }
            case 2 -> {
                String[] isbns = { "978-0000000001", "978-0000000002" };
                System.out.println(isbns[2]);
                // ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2
            }
            case 3 -> {
                System.out.println(Integer.parseInt("fifteen"));
                // NumberFormatException: For input string: "fifteen"
            }
            case 4 -> {
                int totalDays = 30, materials = 0;
                System.out.println(totalDays / materials);
                // ArithmeticException: / by zero
            }
            case 5 -> {
                Object o = "Effective Java";
                Integer n = (Integer) o;
                System.out.println(n);
                // ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer
            }
            case 6 -> {
                List<String> fixed = List.of("Effective Java", "Refactoring");
                fixed.add("Design Patterns");
                // UnsupportedOperationException (no message)
            }
            default -> System.out.println("Unknown scenario: " + scenario);
        }
    }
}

Pay attention to one detail of scenario 4: integer division by zero throws an exception, but floating-point division does not. 30 / 0 blows up; 30.0 / 0.0 returns NaN and 30.0 / 0 returns Infinity, silently. It is an asymmetry of the IEEE 754 specification that comes as a surprise, and a real source of absurd fine calculations: if DAILY_RATE were accidentally divided by zero, BiblioTech would not fail, it would simply start showing €Infinity on the receipts.

  1. Checked exceptions

A checked exception is any descendant of Exception that does not descend from RuntimeException. On them the compiler applies the so-called catch or specify rule: if a method can throw them, the caller is obliged to do one of two things.

Check it for yourself. This code does not compile:

import java.io.FileReader;

public class DoesNotCompile {
    public static void main(String[] args) {
        FileReader reader = new FileReader("catalog.txt");
        // error: unreported exception java.io.FileNotFoundException;
        //        must be caught or declared to be thrown
    }
}

The compiler puts its foot down. The two legal ways out will be those of 06-02 and 06-03: catch it with try-catch, or declare it with throws in the signature so that the problem becomes the caller's.

These are the most frequent checked ones:

Checked exception When it appears Module where it is covered
IOException Any input/output operation that can fail Module 7
FileNotFoundException The file does not exist or cannot be opened (child of IOException) Module 7
InterruptedException A sleeping or waiting thread is interrupted Module 8
SQLException Database failure Module 11
ClassNotFoundException Class.forName cannot find the class Module 10 (reflection)
CloneNotSupportedException clone() on a class that does not implement Cloneable Module 3

The original design intent is this: a checked exception represents an anomalous but foreseeable and potentially recoverable condition, outside the programmer's control. A file not existing is not an error in your code: it is a fact of the world. That is why the language forces you to decide explicitly what to do about it, instead of letting the program fall over.

  1. Checked versus unchecked: the design criterion

This table summarises the complete difference, and it is worth coming back to it when in 06-04 you have to choose what your own exceptions inherit from:

Aspect Checked Unchecked
Base class Exception (not RuntimeException) RuntimeException and Error
Does the compiler force you? Yes: catch or declare No
Do they appear in throws? Compulsory if they propagate Optional (documentation only)
Intended meaning External and recoverable condition Programming error or serious failure
Expected reaction Handle it: retry, degrade, warn Fix the code
Examples IOException, SQLException, InterruptedException NullPointerException, IllegalArgumentException
BiblioTech case "The catalogue file cannot be read" "Somebody passed a null Material to register"
Cost Pollutes the signatures of the whole call chain Can go unnoticed until it blows up

The question that will help you decide in practice:

Can the caller do something sensible other than "die" when this happens?

  • Yes → a checked one is defensible: ask for the file again, use default values, retry.
  • No, this should never happen if the code is correct → unchecked.

Applied to BiblioTech, which is the decision you will formally take in 06-04:

Situation Type chosen Reason
The material being looked for is not in the catalogue Unchecked Whoever searches should have checked first; and forcing a try on every search would be unbearable
The employee has reached the limit of 3 loans Unchecked It is a known business rule, checkable with canBorrow()
The catalogue file cannot be read at start-up Checked It is an external fact, and higher up you can decide to start with an empty catalogue
A null material is registered Unchecked (NullPointerException) Pure programming error

  1. The real debate about checked exceptions

Java is practically the only mainstream language with checked exceptions. C#, Kotlin, Scala, Python, JavaScript and Go deliberately dropped them. It is worth knowing the debate, because it shapes the style of the modern code you are going to come across.

In favour of checked exceptions:

  • They make visible in the signature what can fail. The API documents itself.
  • They stop you forgetting a foreseeable failure: the compiler will not let it compile.
  • They force you to think about the error path when you write the code, not when it is already in production.

Against:

  • They pollute signatures. If a deep method throws IOException, the whole chain up to the top has to declare it or catch it. An internal change becomes an API change.
  • They push people towards the anti-pattern. Faced with the obligation, many people write the empty catch or catch (Exception e) { e.printStackTrace(); }, which is worse than not having caught anything: the program carries on with broken state and nobody finds out.
  • They break with lambdas. The functional interfaces of java.util.function you met in 04-06 do not declare checked exceptions, so you cannot throw an IOException from inside a Function or a Consumer without wrapping it. This became very awkward from Java 8 onwards, and it is one of the weighty reasons for the modern shift towards unchecked ones.
// This does NOT compile: Consumer.accept does not declare IOException
List<String> paths = List.of("catalog.txt", "loans.txt");
paths.forEach(path -> {
    Files.readString(Path.of(path));    // error: unreported exception IOException
});

Where the consensus is today: modern frameworks have voted with their feet. Spring converts every SQLException (checked) into its DataAccessException hierarchy (unchecked). Hibernate does the same. Most new libraries use unchecked ones exclusively.

The pragmatic stance, which is the one you will follow in this module:

  • Use unchecked by default for your domain errors.
  • Reserve checked ones for external conditions where the caller really is going to do something other than propagate.
  • Never silence a checked one with an empty catch to get it off your back. If you really cannot do anything useful there, wrap it in an unchecked one preserving the cause (06-03) and let it go up.

  1. The NullPointerException messages of Java 14+

NullPointerException has historically been Java's most frustrating exception, for a very specific reason: it did not say which of the references on the line was null. Faced with this:

int year = catalog.findByReference("BK-0001").getCard().year();

...a NullPointerException in Java 8 gave you this:

Exception in thread "main" java.lang.NullPointerException
	at com.nexussoftware.bibliotech.presentation.Report.generate(Report.java:42)

Was catalog the null one? The result of findByReference? The result of getCard()? The only way out was to split the line into several or open the debugger.

Since Java 14, with helpful NullPointerExceptions (enabled by default since Java 15), the message is this:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke
"com.nexussoftware.bibliotech.domain.Card.year()" because the return value of
"com.nexussoftware.bibliotech.service.Catalog.findByReference(String)" is null
	at com.nexussoftware.bibliotech.presentation.Report.generate(Report.java:42)

The message points with surgical precision: the value returned by findByReference was null. Diagnosis solved with no debugger and without touching the code.

The JVM knows how to build these messages for every case in which a null causes the failure:

Situation Message fragment
Method call on null Cannot invoke "X.method()" because "variable" is null
Field read Cannot read field "field" because "variable" is null
Array length Cannot read the array length because "array" is null
Array element Cannot load from object array because "array" is null
Wrapper unboxing Cannot invoke "java.lang.Integer.intValue()" because "obj" is null

That last one explains the classic "impossible" NullPointerException a Map<String, Integer> can give you:

Map<String, Integer> loansByEmployee = new HashMap<>();
int total = loansByEmployee.get("EMP-999");   // the key does not exist: get returns null
// NullPointerException: Cannot invoke "java.lang.Integer.intValue()"
//                       because the return value of "java.util.Map.get(Object)" is null

The null returned by get is being unboxed to int (autounboxing, module 1), which means calling intValue() on null. The detailed message says exactly that. Without it, this failure is baffling for quite a while.

Since you are using Java 17 or later, you have this enabled by default and for free. Make the most of it: when you see a NullPointerException, read the whole message before touching anything. It is almost always handing you the answer on a plate.

  1. What you must never do

Before you write your first try in the next lesson, three rules that admit no nuance. You will see them demonstrated with code in 06-02; here are the principles.

1. Do not catch Throwable.

// FORBIDDEN
try {
    manager.lend("BK-0001", "EMP-001", 12);
} catch (Throwable t) {
    System.out.println("Something failed");
}

Catching Throwable lumps your business error together with an OutOfMemoryError or a StackOverflowError, from which you cannot recover. Worse still: it also traps ThreadDeath and class loader errors, interfering with which can leave the JVM in a state impossible to diagnose. Catch the most specific type you know how to handle.

2. Do not catch and stay quiet.

// FORBIDDEN: the "empty catch", the worst mistake in exception handling
try {
    catalog.register(material);
} catch (Exception e) {
    // we will look at it later
}

This does not handle the error: it destroys it. The exception carried inside it the class, the message, the cause and the complete stack; all of that is lost for ever and the program carries on pretending the operation succeeded. A failure like that can take weeks to show itself, and when it does there will not be a trace of its origin. If the catch is empty, catching it was wrong in the first place.

An empty catch is only admissible in one case, and with a compulsory comment explaining why the failure really is irrelevant there.

3. Do not use exceptions for normal flow.

// FORBIDDEN: walking a list by provoking the end-of-list exception
try {
    int i = 0;
    while (true) {
        System.out.println(materials.get(i++).getTitle());
    }
} catch (IndexOutOfBoundsException end) {
    // "we have finished the list"
}

Besides being unreadable, this is orders of magnitude slower than a normal for, because building the exception object involves capturing the complete call stack. Exceptions are for the exceptional. You will see the numbers in 06-02.

Common Mistakes and Tips

Believing that an exception "breaks the program" and that is that. No: it triggers a very well defined mechanism —stack unwinding in search of a handler— that you can control completely. It only ends the thread if nobody handles it along the whole journey.

Reading the stack trace from the bottom up. It is read from the top down. The first at line is the exact point of failure; the last one is main. And if there is a Caused by:, the truth is usually in the last one.

Ignoring the stack trace lines that are not yours. Correct as an initial filter: look for the first line with your package. But do not delete them from the error report: the path inside the library is sometimes exactly what reveals the incorrect usage.

Thinking that Exception includes everything throwable. It does not include Error. catch (Exception e) does not trap an OutOfMemoryError. In this particular case, that limitation is a virtue.

Confusing Error with "error". In Java, Error is a specific branch of the hierarchy —JVM failures—, not a generic synonym for "something went wrong". A NullPointerException is not an Error.

Believing that an unchecked exception is "less serious". It is exactly the other way round: it normally indicates a defect in your code, whereas a checked one usually indicates a circumstance of the environment. The word "checked" describes what the compiler does, not the severity.

Writing useless exception messages. "Error", "Failure" or "Could not" help nobody at three in the morning. A good message says what happened, with what specific data and what was expected: "Duplicate reference: BK-0001 already exists in the catalog". It is exactly the same amount of work.

Tip: faced with a NullPointerException, read the whole message. Since Java 14 it tells you literally which reference was null. It is the most profitable diagnostic improvement of Java's last decade.

Tip: when you report an error, copy the COMPLETE stack trace. Including every Caused by: and every ... N more. A trace cut down to the first line usually removes precisely the information that would have solved the case.

Tip: in production, redirect System.err to a file. Otherwise the stack traces are lost as soon as the terminal closes. In 06-07 you will do this properly, with a Logger and a FileHandler.

Tip: -XX:-OmitStackTraceInFastThrow. If exceptions with no stack trace appear in production, it is the JIT's fast throw optimisation. That parameter disables it and gives you back complete traces while you diagnose.

Exercises

Exercise 1: BiblioTech's exception zoo

Write the class ExceptionZoo in the package com.nexussoftware.bibliotech.presentation with a main that, without using try or catch (that is not for now), provokes in a controlled way the following exceptions, one per run according to a numeric argument, and that before each one prints what is going to happen and why:

  1. NullPointerException when asking for the title of a material that findByReference returned as null.
  2. ArrayIndexOutOfBoundsException when walking an array of ISBNs with <= instead of <.
  3. NumberFormatException when converting the input "fifteen days" with Integer.parseInt.
  4. ArithmeticException when calculating the average loan days with zero loans.
  5. ClassCastException when casting an Object containing a String to Integer.
  6. UnsupportedOperationException when adding a material to a list created with List.of.
  7. ConcurrentModificationException when removing from a list inside a for-each.

For each case, run the program and note in a comment at the end of the file the exact message each exception produces on your JDK.

Exercise 2: forensic reading of a stack trace

This dump arrives from Nexus Software's production environment:

Exception in thread "main" java.lang.IllegalStateException: Could not generate the receipt for loan LN-0007
	at com.nexussoftware.bibliotech.presentation.ConsoleReceipt.issue(ConsoleReceipt.java:54)
	at com.nexussoftware.bibliotech.service.LoanManager.returnItem(LoanManager.java:132)
	at com.nexussoftware.bibliotech.presentation.BiblioTechMenu.returnOption(BiblioTechMenu.java:188)
	at com.nexussoftware.bibliotech.presentation.BiblioTechApp.main(BiblioTechApp.java:29)
Caused by: java.lang.NullPointerException: Cannot invoke "com.nexussoftware.bibliotech.domain.Employee.getName()" because "this.borrower" is null
	at com.nexussoftware.bibliotech.domain.Loan.borrowerDescription(Loan.java:97)
	at com.nexussoftware.bibliotech.presentation.ConsoleReceipt.issue(ConsoleReceipt.java:51)
	... 3 more

Answer in writing, justifying each answer with the specific line of the dump:

  1. Which thread has died?
  2. Which business operation has failed, in plain language?
  3. What is the exact root cause?
  4. In which file and line would you start investigating? Why that one and not the first at line?
  5. What does ... 3 more mean?
  6. How many frames did the stack have in total at the moment of the original failure?
  7. Is this a programming error or an environmental condition? Which branch of the hierarchy confirms it?
  8. Propose two fixes: one that avoids the symptom and one that attacks the root cause. Which is the good one?

Exercise 3: exception classifier

Write ExceptionClassifier, a utility that takes an already constructed (not thrown) Throwable object and produces a report with:

  • The simple name and the full name of the class.
  • Whether it is an Error, a checked exception or an unchecked one. Hint: t instanceof Error, t instanceof RuntimeException, and otherwise it is checked.
  • The recommended reaction, according to a table you define yourself ("Do not handle: let the process die", "Fix the code", "Handle: it is an external condition").
  • The complete chain of causes, indented by levels, walking it with getCause() until reaching null.
  • The number of frames in its stack and the first frame belonging to the package com.nexussoftware, if there is one.

In main, build by hand a chain of three nested exceptions —an IllegalStateException caused by a RuntimeException caused by a java.io.IOException— and pass it to the classifier. Be careful with circular chains: protect the walk with a depth limit.

Solutions

Solution 1

package com.nexussoftware.bibliotech.presentation;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Deliberately provokes the most frequent exceptions, WITHOUT catching them,
 * so you can observe their message and their stack trace.
 *
 * Usage: java ExceptionZoo <1..7>
 */
public class ExceptionZoo {

    public static void main(String[] args) {
        int scenario = (args.length > 0) ? Integer.parseInt(args[0]) : 1;
        System.out.println("=== Scenario " + scenario + " ===");

        switch (scenario) {
            case 1 -> nullPointer();
            case 2 -> indexOutOfRange();
            case 3 -> numberFormat();
            case 4 -> divisionByZero();
            case 5 -> invalidCast();
            case 6 -> immutableList();
            case 7 -> concurrentModification();
            default -> System.out.println("Valid scenarios: 1 to 7");
        }

        // This line is ONLY printed in the 'default' case: in the others,
        // the exception propagates and ends the main thread before reaching here.
        System.out.println("Normal end of the program.");
    }

    /** 1. NullPointerException: the catalog returns null when it finds nothing. */
    private static void nullPointer() {
        System.out.println("We look for BK-9999, which does not exist. findByReference will return null");
        System.out.println("and when we ask it for the title we will get NullPointerException.");

        Map<String, String> index = new HashMap<>();
        index.put("BK-0001", "Effective Java");

        String title = index.get("BK-9999");        // null: the key does not exist
        System.out.println(title.toUpperCase());    // <-- NullPointerException
    }

    /** 2. ArrayIndexOutOfBoundsException: the classic <= mistake at the bound. */
    private static void indexOutOfRange() {
        System.out.println("We walk 3 ISBNs with i <= length instead of i < length.");

        String[] isbns = { "978-0000000001", "978-0000000002", "978-0000000003" };
        for (int i = 0; i <= isbns.length; i++) {   // <= : one iteration too many
            System.out.println("  ISBN[" + i + "] = " + isbns[i]);   // <-- blows up with i == 3
        }
    }

    /** 3. NumberFormatException: user input that is not a number. */
    private static void numberFormat() {
        System.out.println("The employee typed 'fifteen days' where an integer was expected.");

        String input = "fifteen days";
        int days = Integer.parseInt(input);         // <-- NumberFormatException
        System.out.println("Loan days: " + days);
    }

    /** 4. ArithmeticException: INTEGER division by zero. */
    private static void divisionByZero() {
        System.out.println("Average days with zero loans: integer division by zero.");

        int totalDays = 45;
        int numberOfLoans = 0;

        // Warning: with double there would be NO exception, it would give Infinity.
        System.out.println("  With double: " + (45.0 / 0));     // Infinity, no failure

        int average = totalDays / numberOfLoans;                 // <-- ArithmeticException
        System.out.println("Average: " + average);
    }

    /** 5. ClassCastException: incompatible cast at run time. */
    private static void invalidCast() {
        System.out.println("An Object containing a String is cast to Integer.");

        Object data = "Effective Java";             // it is really a String
        Integer year = (Integer) data;              // <-- ClassCastException
        System.out.println("Year: " + year);
    }

    /** 6. UnsupportedOperationException: List.of returns an IMMUTABLE list. */
    private static void immutableList() {
        System.out.println("List.of creates an immutable list; add throws an exception.");

        List<String> fixedCatalog = List.of("Effective Java", "Design Patterns");
        fixedCatalog.add("Refactoring");            // <-- UnsupportedOperationException

        // Note: Arrays.asList has the same problem for add/remove,
        // although it does allow set(i, x). It is a fixed-size view over the array.
        List<String> view = Arrays.asList("a", "b");
        view.set(0, "z");                           // this DOES work
    }

    /** 7. ConcurrentModificationException: modifying while iterating (seen in 05-04). */
    private static void concurrentModification() {
        System.out.println("We remove inside a for-each: the iterator detects the change.");

        List<String> materials = new ArrayList<>(
                List.of("Effective Java", "Design Patterns", "Refactoring"));

        for (String title : materials) {
            if (title.startsWith("Design")) {
                materials.remove(title);            // <-- ConcurrentModificationException
            }
        }
        // The correct way was: materials.removeIf(t -> t.startsWith("Design"));
    }
}

/*
 * OBSERVED MESSAGES (JDK 17):
 *
 * 1. java.lang.NullPointerException: Cannot invoke "String.toUpperCase()"
 *      because the return value of "java.util.Map.get(Object)" is null
 * 2. java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
 * 3. java.lang.NumberFormatException: For input string: "fifteen days"
 * 4. java.lang.ArithmeticException: / by zero
 * 5. java.lang.ClassCastException: class java.lang.String cannot be cast to
 *      class java.lang.Integer (java.lang.String and java.lang.Integer are in
 *      module java.base of loader 'bootstrap')
 * 6. java.lang.UnsupportedOperationException      (no message)
 * 7. java.util.ConcurrentModificationException    (no message)
 *
 * Observation: exceptions 6 and 7 carry NO message. Their class alone is the
 * diagnosis. It is a reminder that the name of the exception is the most
 * important part of the information, and of why in 06-04 we create our own
 * classes with domain names instead of reusing generic types.
 */

Solution 2

1. Which thread has died? The "main" thread, according to the first line: Exception in thread "main". Being the program's only thread, the JVM terminates with a non-zero exit code.

2. Which business operation has failed? Issuing the receipt for the return of loan LN-0007. This is said jointly by the message of the highest-level exception (Could not generate the receipt for loan LN-0007) and by the call path: main → returnOption → returnItem → issue. The user chose the "return" option in the menu.

3. What is the exact root cause? The borrower field of the Loan object is null. The last Caused by: says so, and with the precision of the Java 14+ messages: Cannot invoke "...Employee.getName()" because "this.borrower" is null. The this.borrower indicates that it is not a local variable nor a parameter, but a field of the Loan itself: the object was built without a borrower or somebody set it to null afterwards.

4. Where to start investigating? In Loan.java:97, inside borrowerDescription, which is the first at line of the last Caused by:. That is where the null shows itself. Not in the first line of the dump (ConsoleReceipt.java:54), because that is only the layer that wrapped the failure, not the one that produced it.

That said, the place where it shows itself is not necessarily where the defect is: what is really suspicious is how a Loan came to exist with borrower set to null. The real investigation is in the constructor of Loan and in LoanManager, which is what creates them.

5. What does ... 3 more mean? That the next 3 frames of the cause are identical to those of the previous block and Java does not repeat them. Reconstructed, they are LoanManager.returnItem, BiblioTechMenu.returnOption and BiblioTechApp.main. No information is lost.

6. How many frames did the stack have? Five. The two explicit ones of the cause (Loan.borrowerDescription and ConsoleReceipt.issue) plus the 3 compressed into the ... 3 more. The complete stack was:

Loan.borrowerDescription     (Loan.java:97)           ← TOP, it blows up here
ConsoleReceipt.issue         (ConsoleReceipt.java:51)
LoanManager.returnItem       (LoanManager.java:132)
BiblioTechMenu.returnOption  (BiblioTechMenu.java:188)
BiblioTechApp.main           (BiblioTechApp.java:29)  ← BOTTOM

A fine detail: ConsoleReceipt.issue appears on line 51 in the cause and on line 54 in the wrapper. Consistent: on 51 borrowerDescription was called and failed; on 54 is the catch that wrapped it in the IllegalStateException.

7. Programming error or environmental condition? A programming error. The branch of the hierarchy confirms it: NullPointerException descends from RuntimeException, that is, it is unchecked. Nothing external has failed —no disk, no network, no user input—: there is simply a domain object in an invalid state that should never have been allowed.

8. Two fixes.

Fixing the symptom (bad as the only solution):

// In Loan.borrowerDescription
public String borrowerDescription() {
    if (borrower == null) { return "(no borrower)"; }   // covers up the symptom
    return borrower.getName();
}

The receipt is issued, but with no borrower, and the loan is still corrupt in memory and in any later report. The warning has been silenced, not the problem fixed.

Fixing the cause (the good one): stop a Loan from being able to exist without a borrower, by validating in the constructor —exactly what you will do in 06-03:

public Loan(String reference, Material material, Employee borrower, int startDay) {
    this.reference = Objects.requireNonNull(reference, "The reference cannot be null");
    this.material  = Objects.requireNonNull(material,  "The material cannot be null");
    this.borrower  = Objects.requireNonNull(borrower,  "The borrower cannot be null");
    // ...
}

This way the failure is detected at the moment and place where the invalid object is created, with a clear message, instead of twenty minutes later when printing a receipt. It is the fail-fast principle you will develop in 06-03.

Solution 3

package com.nexussoftware.bibliotech.presentation;

import java.io.IOException;

/**
 * Analyses an already constructed Throwable: branch of the hierarchy, recommended
 * reaction, chain of causes and location of the first own frame.
 *
 * It neither throws nor catches anything: it only INSPECTS the exception object,
 * demonstrating that an exception is an ordinary object with state.
 */
public class ExceptionClassifier {

    /** Safety limit: protects against circular chains of causes. */
    private static final int MAX_DEPTH = 10;

    private static final String OWN_PACKAGE = "com.nexussoftware";

    public static String report(Throwable t) {
        if (t == null) {
            return "There is no exception to analyse.";
        }

        StringBuilder sb = new StringBuilder();
        sb.append("========================================\n");
        sb.append("EXCEPTION REPORT\n");
        sb.append("========================================\n");
        sb.append("Simple class  : ").append(t.getClass().getSimpleName()).append('\n');
        sb.append("Full class    : ").append(t.getClass().getName()).append('\n');
        sb.append("Message       : ").append(t.getMessage()).append('\n');
        sb.append("Category      : ").append(category(t)).append('\n');
        sb.append("Reaction      : ").append(reaction(t)).append('\n');
        sb.append("Stack frames  : ").append(t.getStackTrace().length).append('\n');
        sb.append("First own frame: ").append(firstOwnFrame(t)).append('\n');
        sb.append("----------------------------------------\n");
        sb.append("CHAIN OF CAUSES:\n");
        sb.append(chainOfCauses(t));
        sb.append("========================================");
        return sb.toString();
    }

    /**
     * Determines the branch of the hierarchy.
     * Watch the ORDER of the checks: RuntimeException is a child of Exception,
     * so you have to ask about the most specific one first. It is exactly
     * the same ordering rule that governs multiple catches (06-02).
     */
    private static String category(Throwable t) {
        if (t instanceof Error) {
            return "JVM ERROR (unchecked)";
        }
        if (t instanceof RuntimeException) {
            return "UNCHECKED (RuntimeException)";
        }
        if (t instanceof Exception) {
            return "CHECKED (Exception, not RuntimeException)";
        }
        return "Direct Throwable (very rare case)";
    }

    private static String reaction(Throwable t) {
        if (t instanceof Error) {
            return "Do not handle. Let the process die, log it and analyse the cause.";
        }
        if (t instanceof RuntimeException) {
            return "Usually signals a defect: fix the code, do not catch it.";
        }
        if (t instanceof Exception) {
            return "External condition: handle it (retry, degrade or report).";
        }
        return "No recommendation.";
    }

    /** Walks getCause() with increasing indentation and anti-circular protection. */
    private static String chainOfCauses(Throwable t) {
        StringBuilder sb = new StringBuilder();
        Throwable current = t;
        int level = 0;

        while (current != null && level < MAX_DEPTH) {
            String indent = "  ".repeat(level);
            String prefix = (level == 0) ? "" : "Caused by: ";
            sb.append(indent)
              .append(prefix)
              .append(current.getClass().getSimpleName())
              .append(": ")
              .append(current.getMessage())
              .append('\n');

            Throwable next = current.getCause();

            // An exception can be its own cause (Throwable allows it if it is
            // built badly): without this check, the loop would be infinite.
            if (next == current) {
                sb.append(indent).append("  [circular cause: stopping here]\n");
                break;
            }
            current = next;
            level++;
        }

        if (level >= MAX_DEPTH) {
            sb.append("  [chain truncated at ").append(MAX_DEPTH).append(" levels]\n");
        }
        if (level == 0) {
            sb.append("  (this exception has no cause: it is the root)\n");
        }
        return sb.toString();
    }

    /**
     * Looks for the first frame belonging to our code.
     * It is what you do mentally when reading a stack trace: skipping the lines
     * from java.base and from libraries until you find the first one of your own.
     */
    private static String firstOwnFrame(Throwable t) {
        for (StackTraceElement frame : t.getStackTrace()) {
            if (frame.getClassName().startsWith(OWN_PACKAGE)) {
                return frame.getClassName() + "." + frame.getMethodName()
                        + " (" + frame.getFileName() + ":" + frame.getLineNumber() + ")";
            }
        }
        return "(none: the exception originated entirely outside " + OWN_PACKAGE + ")";
    }

    // ------------------------------------------------------------------

    public static void main(String[] args) {
        // We build a three-level chain by hand, from the deepest one
        // to the shallowest. The constructor's second argument is the CAUSE.
        IOException root = new IOException("Could not read catalog.txt: permission denied");

        RuntimeException middle =
                new RuntimeException("Loading the catalog from disk failed", root);

        IllegalStateException outer =
                new IllegalStateException("BiblioTech cannot start without a catalog", middle);

        System.out.println(report(outer));

        System.out.println();
        System.out.println(report(root));

        System.out.println();
        System.out.println(report(new StackOverflowError()));
    }
}

Output (abbreviated):

========================================
EXCEPTION REPORT
========================================
Simple class  : IllegalStateException
Full class    : java.lang.IllegalStateException
Message       : BiblioTech cannot start without a catalog
Category      : UNCHECKED (RuntimeException)
Reaction      : Usually signals a defect: fix the code, do not catch it.
Stack frames  : 1
First own frame: com.nexussoftware.bibliotech.presentation.ExceptionClassifier.main (ExceptionClassifier.java:141)
----------------------------------------
CHAIN OF CAUSES:
IllegalStateException: BiblioTech cannot start without a catalog
  Caused by: RuntimeException: Loading the catalog from disk failed
    Caused by: IOException: Could not read catalog.txt: permission denied
========================================

Two lessons from the exercise:

  1. The order of the instanceof checks in category is compulsory. If you asked t instanceof Exception first, every RuntimeException would fall there and be misclassified, because a RuntimeException is an Exception. That same rule —from the specific to the general— will be the ordering rule for catch blocks in 06-02, only there the compiler will force you to respect it.
  2. The chain of causes is walked with getCause() until null, and the last one in the chain is the root cause. It is exactly what the JVM does when printing the Caused by: blocks. By implementing it yourself, you understand that the stack trace format is not magic: it is a trivial walk over a linked list of exceptions.

Conclusion

You now know what an exception really is: an object that represents a failure, that is built with new like any other, that carries inside it its class, its message, its cause and a complete photograph of the call stack, and that —when thrown— interrupts the method dead and travels backwards up the stack looking for someone to take charge.

You understand why this is better than return codes: a false can be ignored silently, does not say why it failed, occupies the return value and forces a check at every call. The mute false of Catalog.register and the null of findByReference are, precisely, the first fragility this module is going to eliminate.

You know the complete mechanism of stack unwinding: the JVM looks for a handler frame by frame, discarding each one without running the rest of its code, and if it reaches the bottom without finding one, it hands the exception to the thread's default handler, which prints the trace to System.err and ends the thread —not necessarily the JVM, a nuance that will return in module 8—, leaving a non-zero exit code.

You know how to read a stack trace from top to bottom, identifying the thread, the exception class, the message and each frame with its file and its line; you know that the first at line is the point of failure, that the last one is main, that it is worth jumping to the first line of your own package, and that faced with a chain of Caused by: the truth is in the last one. You also know that ... N more hides no information, and that a missing trace may be down to the fast throw optimisation.

You have the map of the hierarchy: Throwable at the root, with Error for the JVM failures you must not handle —StackOverflowError, OutOfMemoryError—, and Exception for anomalous conditions, with its RuntimeException branch of programming errors. You recognise the most frequent exceptions of each branch by their name and by their message, you know that integer division by zero blows up while floating-point division returns Infinity silently, and you know that the detailed NullPointerException messages of Java 14+ tell you literally which reference was null.

And you are clear about the distinction between checked and unchecked: what the compiler forces in each case, the design criterion —recoverable external condition versus defect in the code—, the real debate about their value, and the pragmatic stance you will follow in BiblioTech: unchecked by default for the domain, checked only when the caller is going to do something other than propagate. Together with the three prohibitions that admit no nuance: do not catch Throwable, do not catch and do nothing, and do not use exceptions for normal flow.

So far you have learned to read the failure. In the next lesson, The Try-Catch Block, you will learn to handle it: the exact syntax and semantics of try and catch, what is protected and what is skipped when an exception fires, the methods of the caught object, the compulsory ordering rule when there are several catch blocks —from subclass to superclass, with the compilation error you get if you invert it—, the multi-catch with | from Java 7 and its rules, the scope of variables declared inside the try, and the four sensible things that can be done inside a catch: recover with a default value, retry, translate to another exception or log and rethrow. With a demonstration of the anti-patterns and of the real cost of throwing an exception versus a simple if. And with the module's first tangible victory: BiblioTech's interactive menu will stop dying when somebody types "twelve" where a number was expected.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved