Command Palette

Search for a command to run...

[Java Basics] Custom Exceptions in Java: Writing Your Own Exception Types

You already know try/catch/finally, throw, throws, and the difference between a checked and an unchecked exception. This article is the next step: defining the exception types your own code throws, so a failure arrives named after the thing that failed instead of after the library that happened to notice.

Writing the class takes four lines. Everything that makes it worth writing is the rest of this article — which supertype to pick, which constructors to provide, what to hang on the object, how to wrap the original failure without erasing it, and the cases where the JDK already has the type you were about to invent.

Your own exception class on the outside and the original JDK exception kept inside it as the cause

Every stack trace, program output and compiler message below was produced by compiling and running the code on OpenJDK 21.0.6. The javac errors are quoted verbatim, including their caret lines.

Why write your own exception type

A custom exception earns its place when it does at least one of three things that a JDK type cannot:

  • It names the failure in your domain's vocabulary. InsufficientStockException tells a reader what went wrong. IllegalStateException tells them only that something was in the wrong state.
  • It gives callers something specific to catch. One catch clause that means exactly one thing, and cannot accidentally swallow an unrelated failure that happens to share a JDK type.
  • It carries structured data. The SKU, the shortfall, the config key, the offending input — as fields you can read, not as a sentence buried inside a message string that the caller would have to parse back out.

If none of those apply, the last section of this article is for you: there is a good chance the JDK already ships the type you want.

The smallest custom exception class

An exception class is an ordinary class. The only requirement is that it descends from java.lang.Throwable, and in practice you extend Exception or RuntimeException — never Throwable or Error directly.

public class OrderException extends Exception {
    public OrderException(String message) {
        super(message);
    }
}

That is the entire class. There is no interface to implement, no annotation to add, no registry to update.

The constructor is the part people get wrong, because constructors are not inherited. Exception has four of them; your class has exactly the ones you declare. Write only the one above, and any call site that wants to pass a cause fails at compile time:

throw new OrderException("order #5 not found", new IllegalStateException("db down"));
NoCtor.java:9: error: constructor OrderException in class OrderException cannot be applied to given types;
        throw new OrderException("order #5 not found", new IllegalStateException("db down"));
              ^
  required: String
  found:    String,IllegalStateException
  reason: actual and formal argument lists differ in length
1 error

The same rule bites one level down. If you subclass your own exception and the superclass has no no-argument constructor, the compiler's implicit super() has nothing to call, and the error lands on the class declaration line rather than on any constructor you wrote:

class BaseEx extends Exception {
    BaseEx(String message) {
        super(message);
    }
}

class ChildEx extends BaseEx {
}
SubNoSuper.java:7: error: constructor BaseEx in class BaseEx cannot be applied to given types;
class ChildEx extends BaseEx {
^
  required: String
  found:    no arguments
  reason: actual and formal argument lists differ in length
1 error

One more detail worth knowing before you commit the file. Throwable implements Serializable, so every exception class you write is a serializable class, and javac will say so if you ask it to:

Lint.java:1: warning: [serial] serializable class OrderException has no definition of serialVersionUID
class OrderException extends Exception {
^
1 warning

It is a warning, not an error, and the default build is silent about it. Add private static final long serialVersionUID = 1L; if your project builds with -Xlint:serial or -Werror, or if the exception genuinely crosses a serialization boundary. Otherwise it is noise.

extends Exception or extends RuntimeException?

This is the only decision in the class that the caller can feel, and it is made by one word.

The same call site compiled twice, once extending Exception and once extending RuntimeException, and the four Throwable constructors with what each leaves in the object

Here is the same program twice. The only difference is the supertype of OrderException:

class OrderException extends Exception {
    OrderException(String message) {
        super(message);
    }
}

public class Orders {
    static void loadOrder(int id) throws OrderException {
        throw new OrderException("order #" + id + " not found");
    }

    public static void main(String[] args) {
        loadOrder(5);
    }
}

With extends Exception, that does not compile at all:

Orders.java:13: error: unreported exception OrderException; must be caught or declared to be thrown
        loadOrder(5);
                 ^
1 error

Change the supertype to RuntimeException, delete the now-unnecessary throws clause, and the same file compiles without a word and fails at run time instead:

Exception in thread "main" OrderException: order #5 not found
	at Orders.loadOrder(Orders.java:9)
	at Orders.main(Orders.java:13)

That is the whole difference, and it is bigger than it looks:

extends Exception (checked)extends RuntimeException (unchecked)
Compiler enforcementevery caller must catch it or declare itnone
Appears in your method signatureyes, via throwsno
Propagates through a caller who forgotimpossible — it will not compilesilently, all the way to the top
Cost of adding one laterrecompiles every caller in the chainnone
Works in a lambda passed to Stream.mapno, the functional interface does not declare ityes
Typical usea failure the caller has a real alternative fora bug, or a failure nobody can recover from

The rule that holds up in practice: make it checked when a reasonable caller has something better to do than crash. A missing order can be retried, reported, or answered with a 404; forcing the caller to acknowledge it is a service. A malformed internal identifier is a bug in your code, and making every caller write catch for a bug just produces empty catch blocks.

Two consequences people discover late. A checked exception cannot be thrown out of a lambda passed to map, forEach or any other standard functional interface, because those interfaces do not declare it — that alone pushes many library authors to unchecked. And widening a method from unchecked to checked is a source-breaking change for everyone who calls it, while the reverse is free.

What each constructor leaves in the object

Throwable defines four public-facing constructors, and the convention is to mirror all four in your class, each one delegating straight to super:

public class OrderException extends Exception {
    public OrderException() {
        super();
    }

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

    public OrderException(String message, Throwable cause) {
        super(message, cause);
    }

    public OrderException(Throwable cause) {
        super(cause);
    }
}

They are not interchangeable. Constructing one of each and printing what came out:

new OrderException()
  getMessage() = null
  getCause()   = null
  toString()   = OrderException
new OrderException(msg)
  getMessage() = order #5 not found
  getCause()   = null
  toString()   = OrderException: order #5 not found
new OrderException(msg, cause)
  getMessage() = order #5 not found
  getCause()   = java.lang.IllegalStateException: connection pool exhausted
  toString()   = OrderException: order #5 not found
new OrderException(cause)
  getMessage() = java.lang.IllegalStateException: connection pool exhausted
  getCause()   = java.lang.IllegalStateException: connection pool exhausted
  toString()   = OrderException: java.lang.IllegalStateException: connection pool exhausted
ConstructorgetMessage()getCause()
()nullnull
(String message)what you passednull
(String message, Throwable cause)what you passedwhat you passed
(Throwable cause)cause.toString()what you passed

The last row is the one to remember. The cause-only constructor does not leave the message empty — it derives it from cause.toString(), which is why that run printed the fully qualified java.lang.IllegalStateException: connection pool exhausted as the message of an OrderException. That is occasionally what you want and usually not: it hands the caller a message written in the vocabulary of the layer you were trying to hide.

Writing all four is a convention, not a rule. Write the ones your code actually throws — but write (String, Throwable) in every exception class that will ever wrap another, because it is the one that keeps the evidence, and adding it later means touching every throw site.

Carrying structured data instead of formatting a string

The moment an exception message contains a value the caller might want, that value should be a field. A message is for humans reading a log; a field is for code deciding what to do next.

class InsufficientStockException extends Exception {
    private final String sku;
    private final int requested;
    private final int available;

    InsufficientStockException(String sku, int requested, int available) {
        super("cannot ship " + requested + " x " + sku + ": only " + available + " in stock");
        this.sku = sku;
        this.requested = requested;
        this.available = available;
    }

    public String getSku() {
        return sku;
    }

    public int getAvailable() {
        return available;
    }

    public int getShortfall() {
        return requested - available;
    }
}

The caller then has something to work with instead of a sentence to regex:

try {
    ship("KB-100", 5, 2);
} catch (InsufficientStockException e) {
    System.out.println("caught  : " + e.getMessage());
    System.out.println("sku     : " + e.getSku());
    System.out.println("short by: " + e.getShortfall());
    System.out.println("action  : back-order " + e.getShortfall()
            + ", ship " + e.getAvailable() + " now");
}
caught  : cannot ship 5 x KB-100: only 2 in stock
sku     : KB-100
short by: 3
action  : back-order 3, ship 2 now

Four habits make this work:

  • Build the message in the constructor, from the same values you store. One place decides the wording, so every throw site is phrased identically and cannot drift.
  • Make the fields final. An exception is already in flight by the time anyone sees it; nothing should be able to change what it says happened.
  • Add derived accessors like getShortfall() when they are what callers actually want. It is a normal class.
  • Do not override getMessage() to splice fields in. getMessage() is called by toString(), by printStackTrace() and by every logging framework, sometimes on a partly constructed object during deserialization. Compose the string once, in the constructor.

One exception class with three fields beats three exception classes with none. The moment you find yourself writing OrderNotFoundException, OrderExpiredException and OrderLockedException that no caller distinguishes, collapse them into one class with a reason field or a small enum.

Chaining: the cause, getCause() and the Caused by section

Wrapping means throwing your type while keeping the original inside it. The cause is a plain reference stored on Throwable and read back with getCause() — nothing more exotic than that. What makes it valuable is that printStackTrace() walks the chain and prints every link.

Three chained exceptions over one shared frame grid, showing which frames each link prints and which are collapsed into the ... N more line

Here is a three-link chain: an array access fails, storage wraps it, the order layer wraps that.

class StorageException extends Exception {
    StorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

class OrderException extends Exception {
    OrderException(String message, Throwable cause) {
        super(message, cause);
    }
}

public class DeepChain {
    static final String[] ROWS = { "1,keyboard", "2,mouse" };

    static String readRow(int index) throws StorageException {
        try {
            return ROWS[index];
        } catch (ArrayIndexOutOfBoundsException e) {
            throw new StorageException("no row at index " + index, e);
        }
    }

    static String loadOrder(int id) throws OrderException {
        try {
            return readRow(id);
        } catch (StorageException e) {
            throw new OrderException("order #" + id + " could not be loaded", e);
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println(loadOrder(5));
    }
}

Running it prints all three links, outermost first:

Exception in thread "main" OrderException: order #5 could not be loaded
	at DeepChain.loadOrder(DeepChain.java:28)
	at DeepChain.main(DeepChain.java:33)
Caused by: StorageException: no row at index 5
	at DeepChain.readRow(DeepChain.java:20)
	at DeepChain.loadOrder(DeepChain.java:26)
	... 1 more
Caused by: java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 2
	at DeepChain.readRow(DeepChain.java:18)
	... 2 more

Read it from the bottom up and you get the story in causal order: index 5 was out of bounds on a length-2 array, so there was no row 5, so order 5 could not be loaded. Read it from the top down and you get it in layer order: the order layer failed, because storage failed, because the array access failed.

Where ... N more comes from. Each link prints only the frames it does not share with the link that wrapped it. StorageException and OrderException were both thrown while main was on the stack, so the identical trailing frame at DeepChain.main(DeepChain.java:33) is replaced by ... 1 more. The innermost link shares two trailing frames with the one above it, so it prints ... 2 more. Nothing is lost: the elided frames are exactly the ones already printed above. It is a de-duplication, not a truncation.

Line 26 versus line 28. Look closely at the two loadOrder frames. OrderException was created on line 28, at the throw inside the catch block; StorageException was already travelling through line 26, the call to readRow. A frame records where that exception was, so a wrapped chain gives you both the failure point and every layer that re-threw it.

initCause is the alternative, and it fires exactly once. If a constructor cannot take a cause — usually because you are wrapping an exception class you do not control — initCause sets it after construction. Four attempts, each run through a helper that prints -> ok or the getMessage() of whatever IllegalStateException came back:

Throwable root = new java.io.IOException("socket closed");

attempt("initCause once                        ", () -> {
    Exception e = new IllegalStateException("wrapped");
    e.initCause(root);
});
attempt("initCause twice                       ", () -> {
    Exception e = new IllegalStateException("wrapped");
    e.initCause(root);
    e.initCause(root);
});
attempt("initCause after super(message, cause) ", () -> {
    Exception e = new IllegalStateException("wrapped", root);
    e.initCause(root);
});
attempt("initCause(null), then initCause(root) ", () -> {
    Exception e = new IllegalStateException("wrapped");
    e.initCause(null);
    e.initCause(root);
});
initCause once                         -> ok
initCause twice                        -> Can't overwrite cause with java.io.IOException: socket closed
initCause after super(message, cause)  -> Can't overwrite cause with java.io.IOException: socket closed
initCause(null), then initCause(root)  -> Can't overwrite cause with java.io.IOException: socket closed

Only the first one works. The cause slot is one-shot: either the constructor fills it or initCause does, once. The second line is the obvious case; the third shows that a constructor which already took a cause counts as having filled it; and the fourth is the sharp edge — initCause(null) counts as filling the slot even though getCause() still returns null afterwards, so the exception can never be given a real cause again. Prefer the constructor whenever your class offers one; use initCause only where it does not.

Exception translation at an API boundary

Chaining is the mechanism. Translation is the reason you reach for it: at the edge of a module, an exception thrown by your implementation gets replaced by one your API declares, with the original kept as the cause.

A caller, your API and the JDK with a boundary line, and the same failure handled three ways with what each leaves the caller

A configuration reader that parses a port number can fail three ways. First, letting the low-level exception through:

static int readPort(String raw) {
    return Integer.parseInt(raw);
}
Exception in thread "main" java.lang.NumberFormatException: For input string: "8o80"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at Leak.readPort(Leak.java:3)
	at Leak.main(Leak.java:7)

Five frames, three of them inside java.base, and a message that mentions a string but not the setting it came from. The caller now has to catch NumberFormatException — a type that leaked out of your choice to use Integer.parseInt. Switch to a hand-rolled parser and every caller breaks.

Second, wrapping but dropping the cause:

} catch (NumberFormatException e) {
    throw new ConfigException("invalid value for 'server.port': " + raw);
}
Exception in thread "main" ConfigException: invalid value for 'server.port': 8o80
	at LostCause.readPort(LostCause.java:12)
	at LostCause.main(LostCause.java:17)

The type is right and the evidence is gone. Two frames, no Caused by: section, and no way to tell from the trace whether the parse failed, an index was out of range, or something else entirely. This is the version that turns a five-minute bug into a two-hour one.

Third, wrapping with the cause — and, because you are writing the class anyway, keeping the structured data too:

class ConfigException extends Exception {
    private final String key;
    private final String rawValue;

    ConfigException(String key, String rawValue, Throwable cause) {
        super("invalid value for '" + key + "': " + rawValue, cause);
        this.key = key;
        this.rawValue = rawValue;
    }

    public String getKey() {
        return key;
    }

    public String getRawValue() {
        return rawValue;
    }
}

public class Translate {
    static int readPort(String raw) throws ConfigException {
        try {
            return Integer.parseInt(raw);
        } catch (NumberFormatException e) {
            throw new ConfigException("server.port", raw, e);
        }
    }
}
message : invalid value for 'server.port': 8o80
key     : server.port
raw     : 8o80
cause   : java.lang.NumberFormatException: For input string: "8o80"
--- full trace ---
ConfigException: invalid value for 'server.port': 8o80
	at Translate.readPort(Translate.java:25)
	at Translate.main(Translate.java:31)
Caused by: java.lang.NumberFormatException: For input string: "8o80"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at Translate.readPort(Translate.java:23)
	... 1 more

The caller catches one type it can reason about, gets getKey() and getRawValue() to build an error page with, and the full parsing failure is still there under Caused by: for whoever reads the log. Two things at once, and it costs one extra argument at the throw site.

Where to draw the line: wherever a caller outside your module would otherwise have to know what your implementation is made of. Inside a module, let exceptions travel — wrapping every call in a new type produces chains ten links deep that say nothing. At the boundary, translate. And when you translate, always pass the cause.

When not to write a custom exception

Most of the exception types people write already exist. The JDK's unchecked exceptions cover the common failure shapes, and using them means every reader already knows what they mean.

SituationUse thisWhy not a custom type
A caller passed a value the method cannot acceptIllegalArgumentExceptionuniversally understood; a subclass adds a name and nothing else
The object is not in a state where this call makes senseIllegalStateExceptionthe same, and callers rarely catch these separately
A required argument was nullNullPointerException via Objects.requireNonNull(x, "...")the JDK already throws it with your message attached
There is nothing left to returnNoSuchElementExceptionIterator, Optional and Scanner all throw it — reuse the vocabulary
This implementation does not support the operationUnsupportedOperationExceptionimmutable collections use it; readers recognise it instantly
Text could not be parsed into a numberNumberFormatExceptiona subclass of IllegalArgumentException, already precise

Two of those carry no message at all by default. An empty ArrayList iterator throws NoSuchElementException with getMessage() returning null, and List.of("a").add("b") throws UnsupportedOperationException with getMessage() returning null — so when you throw them, supply a sentence, because the type alone will not.

The test is one question: would any caller catch this separately, or read data off it? If every caller would treat it the same as any other failure — log it and give up — a JDK type is enough and a custom class is a file nobody benefits from. If a caller wants to retry only this failure, or needs the SKU off it to build a back-order, write the class.

There is a middle path worth knowing: subclass a JDK exception rather than Exception. class InvalidSkuException extends IllegalArgumentException gives callers a specific type to catch while still being caught by anyone who catches IllegalArgumentException. It works well for validation, where a generic handler at the top of the stack is the normal case and one specific handler is the exception.

What a custom exception costs

Constructing a Throwable captures the stack trace. That is not free, and the cost is structural: it walks the frames that are on the stack at that moment, so it scales with how deep you are, not with a fixed charge per throw.

Measuring it structurally rather than in time. deep recurses 60 times and throws; report catches and counts what getStackTrace() returned:

static void deep(int n, int kind) {
    if (n == 0) {
        if (kind == 0) throw new Plain("boom");
        if (kind == 1) throw new NoTrace("boom");
        throw new NoFill("boom");
    }
    deep(n - 1, kind);
}

static void report(String label, int kind) {
    try {
        deep(60, kind);
    } catch (RuntimeException e) {
        System.out.printf("%-38s: frames = %d, message = %s%n",
                          label, e.getStackTrace().length, e.getMessage());
    }
}
plain RuntimeException subclass       : frames = 63, message = boom
writableStackTrace = false            : frames = 0, message = boom
fillInStackTrace() overridden to no-op: frames = 0, message = boom

The 63 frames are the 61 deep calls plus report and main — the exact total depends on how deep you were when the throw happened, which is the whole point.

Two supported ways to opt out. The four-argument Throwable constructor, available since Java 7, is the modern one:

class Lite extends RuntimeException {
    Lite(String message) {
        super(message, null, false, false);
    }
}

The third argument disables suppression, the fourth disables the writable stack trace. The older approach overrides fillInStackTrace() to return this without doing any work:

class NoFill extends RuntimeException {
    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }
}

Both produce a zero-frame trace. Both should be rare. This is a tool for an exception that is genuinely used as control flow on a hot path — a parser signalling end-of-input thousands of times a second — and the price is that when it does escape somewhere unexpected, the log tells you nothing about where it came from. For an exception that means "this request failed", the trace is the whole point. Leave it on.

Common mistakes

Writing one class per message. OrderNotFoundException, OrderExpiredException and OrderLockedException are three files, three imports and three catch clauses that all do the same thing. One class with a reason field says the same and lets a caller switch on it.

Extending Exception for a programming bug. If the only correct response is to fix the code, forcing every caller to write catch produces empty catch blocks, which are strictly worse than the crash they replace.

Extending Error or Throwable directly. Error means the JVM is in trouble — OutOfMemoryError, StackOverflowError — and catching it is almost always wrong. Extending Throwable directly gives you a checked exception that no catch (Exception e) will catch, which surprises everyone.

Catching Exception above your own type. The general catch-ordering rule bites the moment you introduce a custom class, because the clause naming it becomes unreachable and the compiler refuses to compile the file:

CatchOrder.java:11: error: exception OrderException has already been caught
        } catch (OrderException e) {
          ^
1 error

The narrowest type first — which, once you have written one, is usually yours.

Dropping the cause. throw new ConfigException(msg) inside catch (NumberFormatException e) compiles, runs, and quietly deletes the only record of what actually failed. If the variable e is in scope and you are not passing it, that is a bug.

Building the message outside the constructor. Two throw sites, two slightly different wordings, and a log nobody can grep. Pass the values in and let the constructor phrase it.

Reflexively adding serialVersionUID — or reflexively ignoring the warning. Add it if the build treats warnings as errors or the exception really is serialized. Otherwise it is a line of ceremony per class.

FAQ

Should a custom exception extend Exception or RuntimeException?

Extend Exception when a reasonable caller has something better to do than crash — retry, fall back, return a 404 — and you want the compiler to make sure they consider it. Extend RuntimeException when the exception signals a bug, or when no caller in the chain can do anything useful about it. Two practical tie-breakers: a checked exception cannot escape a lambda passed to map or forEach, and changing a method from unchecked to checked breaks every caller that already compiles.

Do I have to write all four Throwable constructors?

No. Constructors are not inherited, so you get exactly the ones you declare, and you should declare the ones your code throws. The one always worth writing is (String message, Throwable cause), because it is the one that preserves the original failure — adding it later means editing every throw site. The cause-only (Throwable) constructor is the one to think twice about: it derives the message from cause.toString(), which pushes the lower layer's vocabulary straight into your exception's message.

What is the difference between the cause constructor and initCause?

They set the same field. super(message, cause) sets it during construction; initCause(cause) sets it afterwards, and only once — a second call throws IllegalStateException: Can't overwrite cause with ..., and so does calling it on an object that already got a cause from its constructor. Use initCause only when you are wrapping an exception class you do not control and whose constructors take no cause.

Why does my stack trace end with "... 2 more"?

Because those frames were already printed. When printStackTrace() walks a chain, each link prints only the frames it does not share with the link that wrapped it, and the identical trailing frames are collapsed into a single ... N more line. Nothing is hidden — the elided frames are exactly the ones listed above in the enclosing link. Read the Caused by: sections from the bottom up to get the failure in causal order.

Does a custom exception need serialVersionUID?

Only if it is actually serialized or your build fails on warnings. Throwable implements Serializable, so every exception class inherits that, and javac -Xlint:serial reports serializable class OrderException has no definition of serialVersionUID. The default compile is silent. Add private static final long serialVersionUID = 1L; when the warning matters to your project.

When is a custom exception overkill?

When no caller would catch it separately and no caller would read data off it. If the honest answer is "everyone just logs it", IllegalArgumentException, IllegalStateException, NoSuchElementException or UnsupportedOperationException says it with a name every Java developer already knows. Note that the last two carry a null message by default, so pass one when you throw them yourself.

Conclusion

A custom exception class is four lines of code and two real decisions. The supertype decides who is forced to deal with it: extends Exception will not let loadOrder(5); compile without a catch or a throws, while extends RuntimeException compiles silently and fails at run time. The constructors decide what the object carries, and (String, Throwable) is the one that matters, because it is the difference between a trace that ends at your wrapper and one that shows Caused by: java.lang.NumberFormatException: For input string: "8o80" four frames further down.

Everything else follows from treating it as an ordinary class. Store the values that caused the failure as final fields and build the message from them in the constructor, so callers get getShortfall() instead of a sentence to parse. Translate at module boundaries and pass the cause every time, so the chain stays intact and ... N more collapses only what has already been printed. And before writing the class at all, check whether IllegalArgumentException already says exactly what you meant — the best custom exception is often the one you did not need.

Next in this series: ArrayList and LinkedList. Arrays have a fixed length decided at creation; collections do not. The next article covers the List interface and its two workhorse implementations, how each one is actually laid out in memory, and why the choice between them changes the cost of insertion, removal and random access.

Related Posts

[Java Basics] Methods in Java: Declaring and Calling Them

How to declare and call a method in Java: the parts of a declaration, static versus instance methods and the non-static method cannot be referenced from a static context error, the return statement, the call stack and StackOverflowError, reading a stack trace, Javadoc, and the real compiler errors beginners hit.

[Java Basics] Classes and Objects in Java: The Foundation of OOP

Classes and objects in Java, proved by running the code: what a class defines, what new really does step by step, zero-initialised fields, references and aliasing, null and the helpful NullPointerException, instance methods, toString, equals versus ==, and the one-class-per-file rule, all on OpenJDK 21.

[Java Basics] Polymorphism in Java: Overriding vs Overloading

Polymorphism in Java explained on OpenJDK 21: method overriding rules, @Override, dynamic dispatch proved with javap, overriding versus overloading, field hiding, static hiding, upcasting and downcasting, and the constructor trap.

[Java Basics] Recursion in Java: How It Works and When to Use It

How recursion works in Java: the base case and the recursive case, factorial traced frame by frame, a real StackOverflowError from a missing base case, recursion depth and -Xss, why naive Fibonacci needs 2692537 calls for fib(30) while memoisation needs 59, recursion versus iteration, and why the JVM does not optimise tail calls.