How it works
The explanation, with examples you can run.
Checked and unchecked
The JLS rule is simple: RuntimeException, Error and their subclasses are unchecked; every other Throwable is checked. Checked exceptions (IOException, SQLException) are for failures a caller can reasonably plan for. Unchecked ones signal bugs (NullPointerException, IllegalArgumentException) or conditions you can't usually recover from (OutOfMemoryError). The distinction exists only in the compiler: the JVM treats all throwables the same way, which is why Kotlin and Scala can ignore it.
How a catch works
javac turns each catch into a row in the method's exception table: a range of bytecode, a handler address and an exception class. You can see it with javap -c. Nothing runs on entry to a try. When something is thrown, the JVM looks for a matching row in the current method, and if there is none it pops the frame and tries the caller.
Where the time goes
Most of the cost is paid in the constructor. Throwable calls fillInStackTrace(), which walks every frame of the current thread and records it. A framework call stack 200 frames deep makes every new SomethingException() correspondingly slower. Unwinding through many frames costs a little more, and turning the trace into StackTraceElement objects (when you call getStackTrace() or print it) more again.
When you really do need an exception on a hot path, and you don't need its stack trace, the protected four-argument constructor can turn the trace off:
static final class NotFound extends RuntimeException { NotFound(String msg) { super(msg, null, false, false); // no suppression, no stack trace }}The better fix is usually not to use an exception at all. "No user with that id" is an expected outcome; return an Optional<User> or a small result type and keep exceptions for things that are actually exceptional.
Two HotSpot details, not language rules: if the JIT inlines both the throw and its catch into one compiled method, the throw can become a plain jump. And for exceptions the JVM throws itself in hot compiled code, such as a repeated NullPointerException, HotSpot may reuse a preallocated exception without a stack trace (-XX:+OmitStackTraceInFastThrow, on by default). If your logs show an NPE with no trace, that is why.
try-with-resources
try (var in = Files.newInputStream(path); var out = Files.newOutputStream(copy)) { in.transferTo(out);} // out is closed, then in, even if transferTo throwsResources are closed in reverse order. If the body throws and a close() throws too, the body's exception is the one that propagates and the other is attached to it with addSuppressed, so getSuppressed() still shows it. Since Java 15 a NullPointerException also says, by default, which expression was null, for example Cannot invoke "String.length()" because the return value of "User.name()" is null.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.