mattone
dopo mattoneTHE BOOK SERIES
IT/EN
← Java guide

Java 25 · 12/39

12. Exceptions and error handling

Java 25 · Complete guide · Draft under review

This guide retains the book’s draft status. Editorial review and comprehension checks with independent readers remain to be completed.

Search the whole guide →

When the normal path is interrupted

Until now we have read examples following instructions from top to bottom. A real program also encounters situations where an operation cannot complete as expected: a file does not exist, data violate a constraint, a reference is null. Java represents many of these events with an exception object. The throw statement starts an exit from the current path; control travels back through calls until it finds a suitable catch, or leaves the program and the JVM displays a stack trace.

A stack trace lists the calls active at the time of the error. To begin diagnosis, first read the exception's type and message, then find the first line belonging to your code. The line number is a clue referring to the compiled source version, not a complete explanation of the cause. In chapter 7 we caused a NullPointerException by calling a method on null: the exception name describes the event, but we still need to understand where that reference came from.

The empty stack and the special-value problem

In an initial version of IntStack, pop() returned 0 when there were no elements. This device seems to solve the problem of a method required to return an int, but introduces ambiguity: 0 is also a valid number a caller may have pushed. If pop() returns 0, we do not know whether we popped that very value or the stack was empty. A push() that ignores an insertion attempt would have the complementary defect: when the stack was full, it did nothing and data were lost without warning.

The chapter 8 version rejects both impossible operations with an exception. The normal path can therefore return any integer, including zero; the abnormal path carries a type and message separately from the value. It is not the only possible design: we could also offer a method checking whether the stack is empty before popping. With concurrent callers, however, checking and then popping as two separate operations would require further guarantees. Here the lesson is more general: a special value works only if the domain truly reserves it and the contract declares it.

Exceptions do not make an error harmless. They interrupt ordinary flow and leave the level possessing enough information to decide how to react. When a constructor discovers necessary data are invalid, it can throw an exception instead of delivering an already-inconsistent object. When reading a file fails, the program can communicate which file and operation were involved. In both cases the solution's value lies in the quality of the contract and handling, not in having written throw.

A family of types, two different obligations

Throwable is the root of throwable types. Two main branches are Error and Exception. Under Exception, RuntimeException groups many exceptions reporting problems such as invalid arguments or null references. Figure 12.1 shows this essential structure.

Essential Throwable hierarchy
Figure 12.1 – Position in the hierarchy determines compiler checking. An exception extending Exception without being a RuntimeException is generally checked; RuntimeException and Error are unchecked.

A checked exception, such as IOException, must be handled with catch or declared with throws by a method that can let it propagate. An unchecked exception has no such compilation obligation. The distinction does not say one is always more serious than the other: it describes what the language imposes on the caller. Error types represent situations an ordinary program usually does not try to recover from by broadly catching them. The Java 25 specification defines classification and checking rules.

To read the hierarchy without memorizing a name list, follow four concrete cases. ClassNotFoundException extends Exception, not RuntimeException: it is checked in an API searching for a class by name. IndexOutOfBoundsException extends RuntimeException: an out-of-bounds index is reported at runtime without a throws obligation. NullPointerException belongs to the same unchecked branch. OutOfMemoryError is instead an Error; it does not become a checked exception merely because it interrupts a program. The spelling IndexOutOfBoundException lacks the final s and is not the standard type name.

Example type Hierarchy branch Compiler obligation
ClassNotFoundException checked Exception handle or declare if the API can throw it
IndexOutOfBoundsException RuntimeException no catch or throws obligation
NullPointerException RuntimeException no catch or throws obligation
OutOfMemoryError Error no catch or throws obligation

The table does not suggest systematically catching the last three. A wrong index often requires correcting loop bounds or checking input data. A null reference requires understanding why the contract permitted or did not permit that absence. A process that exhausted memory might not be able to complete even a recovery operation; broadly catching Error to continue would be a promise the program usually cannot keep. Conversely, a checked exception alone does not guarantee good handling: an empty catch satisfies the compiler-required form but loses the problem.

A class we define can also represent a specific error. In the grade program, InvalidGradeException extends Exception: the method readGrade declares it with throws, while the caller handles it with catch. For "27" we obtain a grade; for "31" an out-of-range value; for "xx" the conversion error is preserved as the cause. The type name tells the caller which operation failed better than a generic Exception.

static int readGrade(String text) throws InvalidGradeException {
    try {
        int grade = Integer.parseInt(text);
        if (grade < 0 || grade > 30) {
            throw new InvalidGradeException("out of range: " + grade);
        }
        return grade;
    } catch (NumberFormatException cause) {
        throw new InvalidGradeException("not a number: " + text, cause);
    }
}

The call to Integer.parseInt can throw NumberFormatException, a RuntimeException and therefore unchecked. Our catch turns it into a grade-domain error while retaining the original cause. The throw in the out-of-range branch instead directly throws our checked exception. The compiler requires the method to declare this possibility and callers to handle or in turn declare it.

throw and throws look similar but occupy different places. throw new InvalidGradeException(...) is a statement inside the method: the abnormal exit starts at that point. throws InvalidGradeException is part of the method declaration and warns the caller of possible checked propagation. The declaration throws nothing by itself. A method can declare several checked types, but an ever-longer list may suggest its responsibility boundary is unclear.

Let us see what happens to throws when a method is overridden. If the base class promises a method that can throw a certain checked exception, the subclass can implement it without throwing that exception or declare a compatible more specific one. It cannot surprise users of the base type by adding a new broader checked exception that the base contract did not anticipate: the caller compiled against the base would not have been required to handle it. Unchecked exceptions follow different rules and do not require that declaration. This is another example of substitutability: the subclass signature must respect what the caller was authorized to expect.

Even inside a catch we can decide to propagate. If we have only added a log line and write throw error;, we let the caught object travel upward. In some cases the compiler can determine checked types more precisely than the catch variable apparently indicates: this is called precise rethrow. We do not need it for the first exception program, but it avoids a false conclusion: catching with a general-type variable does not always force us to declare throws Exception when rethrowing without reassigning that variable. The exception-checking specification defines the exact limits; in everyday code we still prefer signatures exposing types useful to the caller.

In the handler catch (InvalidGradeException error), the type in parentheses determines which objects it can intercept. The name error is a local variable with which to read message, cause and other captured-exception data. The catch does not resume execution from the line that failed: control enters the handling block and, if it completes normally, continues after the try–catch construct. Remaining statements in the try block after the error point are skipped. This is why putting many independent operations inside a large try can make it hard to understand which completed.

Definition – Cause and suppression. The cause is the previous error a new exception preserves to explain its origin. A suppressed exception is instead another error occurring while closing a resource when a main error was already present. getCause() and getSuppressed() therefore tell two different relationships; they are not two names for the same list.

Key concept – Choosing the response. A catch should perform a sensible action where the problem is understandable: show a useful message, retry if appropriate, use an alternative or add context and propagate. Catching Exception to print a line and continue can leave the program in an incorrect state.

What an exception object contains

Read Throwable constructors starting from the information we want to keep. new Exception() creates an exception without a message chosen by us; new Exception("read failed") adds a message. The form with another exception as cause, new Exception("catalog unavailable", error), keeps both the new context and our starting event. A form receiving only the cause also exists. Subclasses do not necessarily expose every constructor with identical signatures: when defining our own exception, we explicitly choose which to offer and call the appropriate superclass constructor.

A message is a sentence for understanding the problem, not a structured field to parse for decisions. A program wanting to distinguish “grade out of range” from “nonnumeric text” can use specific types or exception-specific data instead of looking for words in getMessage(). The message can be null; it can also change to improve diagnosis without changing the error meaning. The cause, when present, retains the original trace and is obtained with getCause(). If we discard it while translating a low-level exception into a domain one, the person diagnosing the problem loses an important part of the story.

Important note – Type, message, cause and trace. The type lets catch select what it knows how to handle. The message explains the event to a person. The cause links an error to what caused it. The stack trace shows the active call path where the exception was produced. None of these four elements replaces the others; printing only one can make diagnosis difficult.

An IOException is checked: if a method lets it escape, its contract must declare this with throws and the caller must in turn handle or declare it. NumberFormatException, although foreseeable with external data, is unchecked: the compiler does not require mentioning it in the signature. Classification depends on the type's position in the hierarchy, not whether the error seems “frequent”. A method receiving human input can still choose to intercept conversion and return a result more suitable for the interface. Conversely, declaring throws Exception to avoid choosing types loses useful caller information.

Where to handle a problem: three levels of one operation

Suppose readCatalog opens a file, showCatalog calls that method and main starts the program. If the file does not exist, readCatalog knows the attempted path and can add it to the message. showCatalog knows the operation's purpose and can decide whether an alternative catalog exists. main knows the user's context and can show a warning, terminate with a clear outcome or request a new path. There is no rule requiring the method nearest the file to catch the exception: handling belongs at the level that truly has a sensible response.

If readCatalog catches IOException and returns an empty list, the caller might show “no books” when it actually failed to read anything. We have turned a failure into a valid result and lost information needed to react. Returning an empty list can be correct when the file was read and contains no books; that is a different event. This distinction helps decide whether to use a return value, checked exception or unchecked exception. The choice does not arise from wanting to write fewer catch blocks, but from the meaning the caller must be able to attribute to the outcome.

A method declaring throws IOException does not assume the error will always happen: it declares a contract possibility. A caller can catch it and choose an alternative; another method can in turn declare it and leave the decision to its own caller. Each propagation preserves the same exception until it is intercepted or a new object with a cause is thrown. When adding context, a message such as "Cannot read catalog.txt" helps more than "Error", but must not contain credentials or data that should not end up in logs.

To understand – throw is not return. return normally completes a method by delivering its declared value; throw interrupts the normal path and searches for a handler. After throw, subsequent statements in the same block are not executed along that path. A compatible catch can handle the event higher in the chain; if none exists, the error escapes the entry point and the runtime reports it. This movement between levels, not merely the object's name, is what we call propagation.

Following the call stack

Return to chapter 8's IntStack class, without creating a new version. pop() throws IllegalStateException with message empty stack when there is no element to return. In the propagation program, main calls show, which calls read, which finally calls pop. Neither intermediate method has a catch: the exception travels up to main's handler.

static int read(IntStack stack) {
    return stack.pop();
}

static int show(IntStack stack) {
    return read(stack);
}

Figure 12.2 follows the exception from its origin to the handler. It does not show a stack of numbers like chapter 8: it shows the call stack, the methods still active at the error's time. These are two uses of the word stack, connected by the order in which calls enter and leave, but they are not the same program object.

Exception propagation through calls
Figure 12.2 – The exception starts in IntStack.pop() and is intercepted in main. read and show let the error travel up because they have no suitable handler.

The program prints empty stack, then the names pop, read, show and main. To obtain these names it reads the exception's StackTraceElement objects, which describe individual calls. An ordinary stack trace print would also show files and line numbers; our example shows only names, so output stays stable if we move code within the file. To reproduce it, compile IntStack.java and DemoPropagation.java together with javac --release 25 -Xlint:all -d build IntStack.java DemoPropagation.java from a folder where you copied both sources, then launch java -cp build DemoPropagation.

Important note – Propagation does not mean solution. Removing a catch from read does not fix the empty stack: it merely lets a caller level decide what to do. In our main we catch it to observe the trace; in an application the contract should explain when popping is legal and what response is appropriate if the stack is empty.

Reading a trace from the right point

If an exception originates in IntStack.pop(), the first application line in the stack trace indicates that very method; subsequent lines show read, show and main in reverse order of the calls made to get there. Do not read the trace as a list of statements executed from top to bottom: it is a snapshot of calls still active at the problem's time. The top line identifies where the exception was constructed or thrown; the logical cause may be further back, for example in code calling pop without having pushed anything.

Some recurring errors deserve precise names. NullPointerException can arise when dereferencing null; ArrayIndexOutOfBoundsException when accessing an out-of-array index; ArithmeticException for integer division by zero. Floating-point division by zero follows different rules and can produce infinity or NaN. ClassNotFoundException is checked in APIs searching for a class by name; OutOfMemoryError belongs to the Error branch, and intercepting it as an ordinary recovery case does not make a process that exhausted memory reliable. The type narrows diagnosis, but none of these names alone explains why the program reached that situation.

For a quick check you can ask an exception for getMessage(), getCause() and getStackTrace(). The message can be absent; the cause can be null; trace elements can include library and runtime code. In a real application it is often better to record the complete exception with a logging tool, preserving causes, rather than printing only error.getMessage() and losing the path leading to failure.

Returning to the null-array case

Follow main, method1 and method2: the first passes a null array to the second, which passes it to the third, where accessing element zero causes NullPointerException. The same chain appears in DemoNullTrace.java, but we print only type and method names, so output does not depend on file line numbers.

public class DemoNullTrace {
    static void method1(int[] a) {
        method2(a);
    }

    static void method2(int[] b) {
        System.out.println(b[0]);
    }

    public static void main(String[] args) {
        try {
            method1(null);
        } catch (NullPointerException error) {
            System.out.println(error.getClass().getSimpleName());
            for (StackTraceElement element : error.getStackTrace()) {
                System.out.println(element.getMethodName());
            }
        }
    }
}

The lines are NullPointerException, method2, method1, main. Calls happened in order main → method1 → method2, while the trace lists the problem point first and then callers. This reversal is not a graphical detail: it explains why the top trace method is often the best place to observe the failed operation, but the null value may originate further down in call history. Here we deliberately passed it from main; in a real program it could come from a file, parameter or unchecked result.

Modern JDKs can offer more precise NullPointerException messages, indicating the operation attempted on a null reference. Exact message text is not an API to parse for decisions and can depend on context. Our program avoids it in expected output, but during diagnosis it should be read: it often helps recognize which reference was null. The solution is not catching every NullPointerException and continuing; it is understanding whether the contract permitted null and correcting the value's source or the missing check.

Three outcomes of one calculation

Before resource closing, observe a program where the normal path and two different errors start from the same operation. attempt receives two strings, converts them into integers and divides the first number by the second. The complete example is in DemoDivision.java.

public class DemoDivision {
    static void attempt(String first, String second) {
        try {
            int a = Integer.parseInt(first);
            int b = Integer.parseInt(second);
            System.out.println("result: " + (a / b));
        } catch (NumberFormatException error) {
            System.out.println("an integer is required");
        } catch (ArithmeticException error) {
            System.out.println("cannot divide by zero");
        } finally {
            System.out.println("attempt finished");
        }
    }

    public static void main(String[] args) {
        attempt("8", "2");
        attempt("eight", "2");
        attempt("8", "0");
    }
}

The first call prints result: 4 and then attempt finished. In the second, Integer.parseInt("eight") throws NumberFormatException: division is not reached, an integer is required is printed and then attempt finished. In the third, conversion succeeds but integer division by zero throws ArithmeticException: cannot divide by zero is printed and finally attempt finished. We see six lines in all, two for each call. The finally block also executes after a catch; it does not replace the handler or make invalid data valid.

The two catch blocks are separate because the program wants different messages. If the response were truly identical, we could write catch (NumberFormatException | ArithmeticException error) and one body. But we could not place two types, one a subtype of the other, in the same multi-catch: the more specific branch would already be covered by the other. Nor would broadly catching Throwable to hide every problem make sense. The handler is chosen according to the exception type arriving from the try; if several separate handlers apply, their order must put the more specific first.

We see the complete form in DemoMultiCatch. This time the two abnormalities share a response: the requested operation cannot produce a quotient, so the method reports the attempt was rejected. The type name stays in the print to understand which check stopped calculation; we do not use that name as an end-user message.

public class DemoMultiCatch {
    static void calculate(String dividend, String divisor) {
        try {
            int first = Integer.parseInt(dividend);
            int second = Integer.parseInt(divisor);
            System.out.println("quotient: " + first / second);
        } catch (NumberFormatException | ArithmeticException error) {
            System.out.println("rejected: " + error.getClass().getSimpleName());
        }
    }

    public static void main(String[] args) {
        calculate("12", "3");
        calculate("twelve", "3");
        calculate("12", "0");
    }
}

Before running it, follow the three calls. The first passes both conversions and prints quotient: 4. The second stops at Integer.parseInt("twelve") and prints rejected: NumberFormatException; division is not attempted. The third converts both values, but zero divisor causes ArithmeticException, so it prints rejected: ArithmeticException. The common response does not erase the distinction between events. If we wanted to ask the user to correct a number or explicitly forbid zero, we would return to the previous program's two separate handlers, because responses would differ.

Multi-catch does not mean “catch every exception”: it names only listed types. For example, calculation could receive text "8" and "0": ArithmeticException is handled; it could also contain a programming error on another line producing NullPointerException, and the shown multi-catch would not intercept it. This is desirable when the response “invalid input” would be misleading for a different defect. Handler precision protects error meaning. If several types truly share a response, the common block avoids duplication; if they need different messages or recovery, two separate catch blocks describe the program better.

Consider the order of two hypothetical handlers: catch (RuntimeException e) followed by catch (NumberFormatException e). The second could never receive a NumberFormatException, because the first would already catch it: it is a RuntimeException subclass. The compiler reports an unreachable handler. Reversing the order lets the specific case be treated first and leaves the general case for other runtime errors. Even when code compiles, however, a broad handler can hide unexpected defects. The question is not “how many types can I put in catch?” but “for which of these events can I really choose a response?”.

The program's finally shows flow. Imagine adding a line System.out.println("after") after the entire try–catch–finally: in these three cases it would execute after attempt finished, because each error was handled. If we removed the suitable catch, finally would still be traversed, but the exception would continue propagating and the line after would not be reached along that path. The distinction between “perform a final action” and “resolve the error” is the construct's heart. A return in finally would make the path harder to follow and could hide an exception already traveling.

To understand the order. Imagine declaring int result; before the try, assigning a / b inside it and printing it after the try–catch. The compiler will report that result may not have been initialized: one of the two error paths skips the assignment. Drawing the three paths before moving statements helps distinguish normal from exceptional flow.

Closing a resource even when something fails

A resource such as a file or connection must be released when its use ends. Try-with-resources automatically calls close() on the objects declared in the try parentheses that implement AutoCloseable. It does not replace understanding errors: it makes closing reliable even when the body throws an exception.

In the complete program we use a teaching resource printing closing and then throwing an IOException. The try body also throws an IOException. This situation is constructed specifically to see which error stays primary.

try (Resource resource = new Resource()) {
    resource.use();
    throw new IOException("error during work");
} catch (IOException error) {
    System.out.println("primary: " + error.getMessage());
    for (Throwable secondary : error.getSuppressed()) {
        System.out.println("secondary: " + secondary.getMessage());
    }
}

The output, in order, is work, closing, primary: error during work and secondary: error during closing. The body error stays primary; the closing error is preserved among suppressed exceptions, accessible with getSuppressed(). It is not lost. If the body completed normally and only close() failed, the closing error would instead propagate. The AutoCloseable documentation explains the closing contract.

The variable resource makes clear which object will be closed. If we declare several in the parentheses, Java closes them in reverse creation order. In a real application close() must do what it can to release what it owns even when reporting a problem; our example always throws only to make the suppressed-exception rule observable.

With two resources the path can be followed like a small stack. If we write try (Resource first = ...; Resource second = ...), second closes before first. If the body and both closings fail, the body error stays primary and closing errors are preserved as suppressed in the order they arise. If the body completes normally, a closing error can become the primary exception. This rule prevents a secondary cleanup problem from erasing the problem that interrupted work. Exception structure, however, is no excuse for ignoring resource release: application code must still decide which data and files are valid after failure.

A resource declared in the parentheses must be compatible with AutoCloseable, meaning it offers the interface's close() operation. The construct governs closing; it does not know whether the file we wrote contains correct data or undo changes already performed by the try body. If the program must maintain an atomic operation, a further strategy is needed, for example writing a temporary file and replacing the final one only after success. The boundary between resource handling and data consistency deserves naming: otherwise “automatically closed” seems to promise more than it does.

The old style with a finally manually calling close() forces consideration of several situations: the resource might not have opened, work might fail, and closing itself might throw another exception. A naively written finally can replace the work error with the closing error, losing the cause that first interested us. Try-with-resources makes resource ownership explicit and preserves the closing error among suppressed ones when a primary error already exists. It does not prevent using finally for other completion activities; it simply distinguishes what the language can manage structurally from application-specific logic.

To verify – Two failures, two relationships. In our DemoResources, the closing exception appears in the primary error's getSuppressed(). If instead a method caught a read error and threw a new “catalog unavailable” exception, the initial error should appear in the new exception's getCause(). Draw two different arrows: primary error → suppressed closing error and new explanation → original cause. Calling both “previous error” prevents correctly reading a real diagnosis.

The distinction also matters when printing a complete trace: causes and suppressed exceptions can appear with different headings and common stack parts abbreviated. This is not random line loss; presentation tries to avoid repeating the same calls. Before concluding that a resource was not closed or a cause is missing, consult the exception object and its explicit relationships. The primary message alone does not necessarily tell the whole failure sequence.

A finally block executes when leaving its corresponding try, whether the body completes normally or throws an exception, except for events interrupting JVM execution. It is useful for completion operations that are not AutoCloseable resources; for those, try-with-resources also clarifies closing order and preservation of suppressed exceptions. Avoid introducing a new return or throw in finally without understanding its effect: it can replace the previous result or exception.

When two independent error types need the same response, Java allows multi-catch, for example catch (IOException | NumberFormatException error). The handler must make sense for both. Separate catch order matters: a more specific type must be intercepted before its superclass, otherwise the specific branch would be unreachable. These choices apply to real method errors; wrapping every line in a try is unnecessary.

Propagating without losing the cause

A method can add context to an error before letting it travel upward. For example, when reading a file fails, the level knowing the document name can throw a new exception with a message including it, preserving the original exception as cause. Thus the caller reads the meaning of the failed operation and can still trace the initial error with getCause().

throw new IllegalStateException("Cannot load the catalog", error);

This line is a fragment to place in a context where error was caught. But we do not turn every error into a RuntimeException just to avoid throws: if the caller can react to the event, choosing handling or propagation is part of the method contract. For an argument violating an immediate constraint, such as chapter 8's grade 31, IllegalArgumentException instead communicates that the caller passed a forbidden value.

Important note – Do not use exceptions for the ordinary path. A search that may not find an element should express that result in its contract rather than throwing an exception for every missing match. Exceptions report paths requiring attention and handling. The concrete choice depends on the API and domain, not just the availability of try and catch.

Before writing a catch, formulate the response

If a user enters an invalid textual grade, we can ask them to correct it. If the program reads a nonexistent file, we can allow another path to be chosen. If a stack is full, we can reject the new element and show the reached capacity. These three responses belong to different contexts and need different information. Writing catch (Exception error) { System.out.println("Error"); } for every case erases precisely the information the exception mechanism was designed to carry.

A good exercise is reading every handler and completing the sentence: “After this block, the program can continue because…”. In DemoDivision, attempt ends after explaining the result or incorrect input; it does not deliver an invented number to the rest of the application. In DemoResources, the handler shows both primary and closing errors, then the example ends. In the checked-stack case, the handler knows which value was rejected and can decide whether to communicate it or suggest a larger stack. If we cannot complete the sentence without pretending an operation succeeded, that level probably must add context and propagate, or let the exception travel upward.

Exceptions can also originate in a constructor. Chapter 8's Customer rejects empty first and last names; if the constructor throws IllegalArgumentException, the caller does not obtain a completed customer. In a lambda passed to a functional API or a concurrent activity, the error also follows the contract of the interface or mechanism executing the work: it is incorrect to assume it always reaches the catch around the line that started the task. When we encounter lambdas and concurrency we will revisit those boundaries with complete examples. Here we establish the general principle: every control transfer must be read in the context of who actually executes the potentially failing operation.

Designing our own exception without losing the domain

InvalidGradeException describes which operation failed better than a generic Exception. Its message constructor serves when the numeric grade is out of range; the constructor also receiving a cause serves when text conversion fails. The caller can treat both as failure to acquire a grade, while whoever diagnoses the program retains the distinction between paths. If the caller needs to automatically distinguish cases, we can add structured class data, for example a code or category, rather than forcing interpretation of the message sentence.

Choosing to extend Exception or RuntimeException is not a moral ranking of seriousness. With Exception we ask callers to explicitly consider the error path at compile time. With RuntimeException we impose no such syntax constraint, useful for precondition violations indicating a defect in caller code. In our example textual input can be wrong without the program being badly written; the checked version makes this possibility visible in the signature. Another API might choose a result representing success or failure. Whatever choice we make, we document behavior and test valid inputs, out-of-range values and unreadable text.

A stack declaring the error in its signature

Chapter 8's IntStack reports a full condition with unchecked IllegalStateException. We can build a version with a custom checked exception able to carry maximum capacity. Both choices are possible, but their caller contracts differ. The DemoCheckedStack program compares the two APIs of the same concept and makes the difference from the already-used IntStack explicit. This second stack has capacity two, attempts to push three numbers and keeps the correct size after rejecting the last.

public class DemoCheckedStack {
    static final class FullStackException extends Exception {
        private static final long serialVersionUID = 1L;
        private final int capacity;
        private final int rejectedValue;

        FullStackException(int capacity, int rejectedValue) {
            super("full stack");
            this.capacity = capacity;
            this.rejectedValue = rejectedValue;
        }

        int capacity() {
            return capacity;
        }

        int rejectedValue() {
            return rejectedValue;
        }
    }

    static final class CheckedStack {
        private final int[] elements;
        private int size;

        CheckedStack(int capacity) {
            if (capacity <= 0) {
                throw new IllegalArgumentException("nonpositive capacity");
            }
            elements = new int[capacity];
        }

        void push(int value) throws FullStackException {
            if (size == elements.length) {
                throw new FullStackException(elements.length, value);
            }
            elements[size++] = value;
        }

        int size() {
            return size;
        }
    }

    public static void main(String[] args) {
        CheckedStack stack = new CheckedStack(2);
        try {
            stack.push(7);
            stack.push(12);
            stack.push(99);
        } catch (FullStackException error) {
            System.out.println(error.getMessage() + ": " + error.capacity());
            System.out.println("rejected: " + error.rejectedValue());
        }
        System.out.println("size: " + stack.size());
    }
}

The output is full stack: 2, rejected: 99, size: 2. Exception fields are structured data: the caller can read capacity and rejected value without parsing full stack. The method push declares throws FullStackException and callers must handle or propagate this possibility. The check size == elements.length occurs before writing: thus the third value is not lost behind a false success print and does not bring size to three. The historical example aimed to teach precisely this transition from silence to an observable contract; here we show it without suggesting that every stack must necessarily choose a checked exception.

The file contains the exception and stack as nested classes to make the test self-contained. The word static on both nested classes says their instances need no instance of the enclosing DemoCheckedStack. serialVersionUID avoids a warning about serialization inherited from Exception; it is not what makes the exception checked. The checked property comes from extending Exception without extending RuntimeException. If you remove the catch from main without adding a suitable throws, the compiler rejects the program. This is a useful negative test, distinct from ordinary example execution.

To verify

Compile the complete source with javac --release 25 -Xlint:all -d build DemoResources.java and execute java -cp build DemoResources. Before testing, write the order of the four lines. Then remove only the throw inside the try body: predict which message reaches the catch and how many suppressed exceptions you will find. Activity C12 includes this diagnosis and a guided stack-trace reading.

Try the examples

Running the programs requires JDK 25. Download the individual Java files linked in the chapter or the complete example project, which includes instructions and a launcher script. The explanations also compare expected output: predict it before running the program.

Massimiliano Tarquini · CC BY-NC 4.0

Back to the top ↑