Every method has two ways out. It can return a value, or it can throw — abandon the call and hand a Throwable object back up the chain instead of a result. Exception handling is the set of rules for that second exit: who creates the object, where control goes next, which lines still run on the way out, and where the program stops if nobody deals with it.
Java is unusual in making part of that a compile-time question. Some exceptions the compiler refuses to let you ignore, and the rest it says nothing about at all. That one split is where throws clauses come from, and it is what most of the confusion around exceptions actually is.
![]()
Every stack trace, compiler message and line of output below was produced by compiling and running the code on OpenJDK 21.0.6 (arm64). None of it is written from memory.
What happens when nothing catches it
Start with the case where you have written no exception handling at all.
public class Uncaught {
static int half(int n) {
return 100 / n;
}
static int compute(int n) {
return half(n) + 1;
}
public static void main(String[] args) {
System.out.println(compute(4));
System.out.println(compute(0));
}
}
26
Exception in thread "main" java.lang.ArithmeticException: / by zero
at Uncaught.half(Uncaught.java:3)
at Uncaught.compute(Uncaught.java:7)
at Uncaught.main(Uncaught.java:12)
Four things happened, and each of them matters later.
The first call finished normally and printed 26, so an exception is not a compile-time property of the code — the same line ran fine once and failed once, on data. Integer division by zero created an ArithmeticException object with the message / by zero. That object travelled from half out to compute and out to main, and because no method along the way was prepared to receive it, the JVM printed the trace and killed the thread. Finally, the trace went to standard error, not standard output, and the process exited with status 1 — redirect with 2>/dev/null and the 26 is all you see.
That last detail is the practical definition of "unhandled": the program stopped early and told the shell it failed.
The Throwable hierarchy: Error, Exception and RuntimeException
Everything you can throw or catch is a subclass of java.lang.Throwable. The interesting part is not the class list but where the branches sit, and you can print that with reflection rather than trust a diagram.
public class Hier {
static void chain(Class<?> c) {
StringBuilder sb = new StringBuilder(c.getSimpleName());
for (Class<?> s = c.getSuperclass(); s != null; s = s.getSuperclass()) {
sb.append(" -> ").append(s.getSimpleName());
}
System.out.println(sb);
}
public static void main(String[] args) throws Exception {
chain(NumberFormatException.class);
chain(Class.forName("java.io.FileNotFoundException"));
chain(StackOverflowError.class);
chain(ArrayIndexOutOfBoundsException.class);
}
}
NumberFormatException -> IllegalArgumentException -> RuntimeException -> Exception -> Throwable -> Object
FileNotFoundException -> IOException -> Exception -> Throwable -> Object
StackOverflowError -> VirtualMachineError -> Error -> Throwable -> Object
ArrayIndexOutOfBoundsException -> IndexOutOfBoundsException -> RuntimeException -> Exception -> Throwable -> Object

Throwable has exactly two direct subclasses. Error is for conditions the JVM itself is in trouble over — StackOverflowError, OutOfMemoryError — and your code is not expected to recover from them. Exception is everything else, and inside it RuntimeException marks the branch the compiler ignores.
| Class | Branch | Checked | Typically thrown when |
|---|---|---|---|
ArithmeticException | RuntimeException | no | integer division or % by zero |
NullPointerException | RuntimeException | no | a member is accessed through a null reference |
ArrayIndexOutOfBoundsException | RuntimeException | no | an index is negative or at least the length |
NumberFormatException | RuntimeException | no | Integer.parseInt gets something that is not a number |
ClassCastException | RuntimeException | no | a cast the runtime type does not permit |
IllegalArgumentException | RuntimeException | no | you rejected an argument yourself |
IllegalStateException | RuntimeException | no | the object is not in a state where the call makes sense |
ParseException | Exception | yes | text does not match the expected format |
InterruptedException | Exception | yes | a waiting thread is interrupted |
StackOverflowError | Error | no | recursion never reached a base case |
One consequence of the split is worth seeing directly, because catch (Exception e) reads like "catch everything" and is not:
public class ErrorCatch {
static int deep(int n) { return deep(n + 1); }
public static void main(String[] args) {
try {
deep(0);
} catch (Exception e) {
System.out.println("catch (Exception) saw it");
} catch (Throwable t) {
System.out.println("catch (Throwable) saw " + t.getClass().getName());
}
}
}
catch (Throwable) saw java.lang.StackOverflowError
Error is not under Exception, so the first block never runs. This is deliberate, and the right response is to leave both of those blocks out: a StackOverflowError means the recursion has no base case, and catching it hides the bug rather than fixing it.
try, catch and finally
The statement has three parts. try wraps the code that might throw, each catch names a type it is prepared to receive, and finally runs on the way out no matter which way you leave. Only one of catch and finally is required.
public class FinallyOrder {
static void run(int divisor) {
try {
System.out.println("try: before");
int q = 100 / divisor;
System.out.println("try: 100 / " + divisor + " = " + q);
} catch (ArithmeticException e) {
System.out.println(e);
} finally {
System.out.println("finally: always");
}
System.out.println("after the statement");
}
public static void main(String[] args) {
run(4);
System.out.println("---");
run(0);
}
}
try: before
try: 100 / 4 = 25
finally: always
after the statement
---
try: before
java.lang.ArithmeticException: / by zero
finally: always
after the statement

Read the two runs side by side. They execute the same six lines in the same order and differ by exactly one line each way. In the good run the catch block is skipped. In the bad run the division throws, so the rest of the try block is abandoned — the third println never runs — and control jumps straight to the catch. Both runs then execute finally, and both continue past the statement, because a caught exception is a handled exception and the method carries on normally.
Also note what the second run prints. System.out.println(e) calls toString() on the throwable, which is the class name, then ": ", then the message. e.getMessage() alone would print just / by zero.
Catching more than one type with multi-catch
When two unrelated failures deserve the same response, list them in one catch separated by | instead of duplicating the block.
public class MultiCatch {
static void describe(String[] row, int index) {
try {
int value = Integer.parseInt(row[index]);
System.out.println("value = " + (100 / value));
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
System.out.println("input = " + e.getClass().getSimpleName() + ": " + e.getMessage());
} catch (ArithmeticException e) {
System.out.println("math = " + e.getMessage());
}
}
public static void main(String[] args) {
String[] row = { "4", "x", "0" };
describe(row, 0);
describe(row, 1);
describe(row, 2);
describe(row, 9);
}
}
value = 25
input = NumberFormatException: For input string: "x"
math = / by zero
input = ArrayIndexOutOfBoundsException: Index 9 out of bounds for length 3
Two rules apply to the alternatives. They must not be related by subclassing, because if one were a subclass of the other the wider one would already cover it; catch (ArithmeticException | RuntimeException e) is rejected with Alternatives in a multi-catch statement cannot be related by subclassing and a second line naming the pair. And the parameter is implicitly final — assigning to e inside the block fails with multi-catch parameter e may not be assigned.
The static type of e is the nearest common supertype of the alternatives, which is why the example above calls getClass() and getMessage() — methods that exist on Throwable — rather than anything specific to either type.
Ordering catch blocks from most specific to least
catch blocks are tried top to bottom and the first one whose type matches wins. A supertype placed above a subtype would therefore make the subtype unreachable, and the compiler treats that as an error rather than a warning.
try {
int q = 100 / Integer.parseInt(args[0]);
System.out.println(q);
} catch (Exception e) {
System.out.println("broad: " + e);
} catch (ArithmeticException e) {
System.out.println("narrow: " + e);
}
Ordering.java:8: error: exception ArithmeticException has already been caught
} catch (ArithmeticException e) {
^
1 error
Swap them and it compiles, with each block reachable:
public class CatchOrderOk {
static void handle(String raw) {
try {
System.out.println(100 / Integer.parseInt(raw));
} catch (ArithmeticException e) {
System.out.println("arithmetic: " + e.getMessage());
} catch (RuntimeException e) {
System.out.println("runtime: " + e);
}
}
public static void main(String[] args) {
handle("4");
handle("0");
handle("x");
}
}
25
arithmetic: / by zero
runtime: java.lang.NumberFormatException: For input string: "x"
The compiler polices the other direction too, but only for checked exceptions. Catching one that the try block cannot possibly raise is rejected with exception IOException is never thrown in body of corresponding try statement. Unchecked types are exempt, because almost any statement can raise a RuntimeException.
What finally is actually for
finally is a guarantee about cleanup, not about error handling. It runs when the try block completes normally, when an exception is caught, when an exception is not caught and is on its way out, and when the block is left by return, break or continue.
static int find(String[] rows) {
for (String r : rows) {
try {
return Integer.parseInt(r);
} catch (NumberFormatException e) {
continue;
} finally {
System.out.println("finally after " + r);
}
}
return -1;
}
finally after a
finally after b
finally after 7
found = 7
The finally block runs after the continue on the first two rows and after the return on the third — the return value is computed, then finally runs, then the method actually returns.
javap -c shows how that guarantee is implemented, and it is not magic. The compiler copies the finally body into every exit path: once at the normal end of the try, once at the end of the catch, and once into a synthetic handler registered for type any that runs the copy and then rethrows. Three println("finally: always") calls appear in the bytecode of a method that has one in the source, which is exactly why there is no way out of the statement that misses it.
There is one way out that skips it. System.exit does not unwind the stack, it stops the JVM:
try {
System.out.println("try");
System.exit(0);
} finally {
System.out.println("finally");
}
try
Historically finally was where you closed things — the resource was opened before the try, used inside it, and closed in the finally so that the close happened on both paths. That idiom is obsolete: try-with-resources does the same job with far less code and handles the awkward cases the hand-written version usually gets wrong. Article 35 covers it along with files.
Reading a stack trace
A stack trace is not a log of what your program did. It is a snapshot of the frames that were live at the moment of the throw, printed deepest first.
public class Unwind {
static int parseAge(String raw) {
return Integer.parseInt(raw.trim());
}
static int readRecord(String[] row) {
return parseAge(row[1]);
}
static int totalAge(String[][] rows) {
int total = 0;
for (String[] row : rows) {
total += readRecord(row);
}
return total;
}
public static void main(String[] args) {
String[][] rows = { { "ann", "34" }, { "bob", "4o" } };
System.out.println("total = " + totalAge(rows));
}
}
Exception in thread "main" java.lang.NumberFormatException: For input string: "4o"
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 Unwind.parseAge(Unwind.java:4)
at Unwind.readRecord(Unwind.java:8)
at Unwind.totalAge(Unwind.java:14)
at Unwind.main(Unwind.java:21)
Read it in this order. The first line gives the type and the message, and the message is usually the fastest diagnosis you will get — For input string: "4o" names the exact bad value, and a letter o sitting where a zero should be is the whole bug.
Then scan down for the first frame that belongs to you. The top three frames are inside java.base, because parseInt is where the object was constructed, but nothing in the JDK is wrong. The first line of your own code is Unwind.parseAge(Unwind.java:4), and line 4 is the parseInt call. That is where to put a breakpoint.
The frames below it are the path that got you there — readRecord at line 8, totalAge at line 14, main at line 21 — which is what tells you which row was being processed when the call was made.
The same text is available without crashing the program. e.printStackTrace() inside a catch prints exactly this format, minus the Exception in thread "main" prefix, and execution continues:
public class Pst {
static void inner() { throw new IllegalStateException("cache not initialised"); }
static void outer() { inner(); }
public static void main(String[] args) {
try {
outer();
} catch (IllegalStateException e) {
e.printStackTrace();
}
System.out.println("still running");
}
}
java.lang.IllegalStateException: cache not initialised
at Pst.inner(Pst.java:2)
at Pst.outer(Pst.java:3)
at Pst.main(Pst.java:5)
still running
One caveat on messages. NullPointerException in modern Java describes the exact dereference, but which variable it can name depends on how you compiled. Plain javac Npe.java gives Cannot invoke "String.length()" because "<local1>" is null; javac -g Npe.java, which keeps the local variable table, gives because "name" is null.
How an exception travels: stack unwinding
When a throw happens, the JVM looks at the current frame for a try whose catch matches the thrown type. If there is none, it discards that frame and looks at the caller, and it repeats until either a frame handles it or the stack runs out.

The word for discarding those frames is unwinding, and the cost is that everything they still had to do is abandoned. Put a catch partway up the same program and the difference is visible in the output:
public class UnwindCatch {
static int parseAge(String raw) {
System.out.println(" parseAge: about to parse " + raw);
int age = Integer.parseInt(raw.trim());
System.out.println(" parseAge: never reached for a bad row");
return age;
}
static int readRecord(String[] row) {
System.out.println(" readRecord: " + row[0]);
int age = parseAge(row[1]);
System.out.println(" readRecord: never reached for a bad row");
return age;
}
static int totalAge(String[][] rows) {
int total = 0;
for (String[] row : rows) {
try {
total += readRecord(row);
} catch (NumberFormatException e) {
System.out.println("totalAge: caught " + e.getMessage() + ", skipping row");
}
}
return total;
}
public static void main(String[] args) {
String[][] rows = { { "ann", "34" }, { "bob", "4o" }, { "cat", "29" } };
System.out.println("total = " + totalAge(rows));
}
}
readRecord: ann
parseAge: about to parse 34
parseAge: never reached for a bad row
readRecord: never reached for a bad row
readRecord: bob
parseAge: about to parse 4o
totalAge: caught For input string: "4o", skipping row
readRecord: cat
parseAge: about to parse 29
parseAge: never reached for a bad row
readRecord: never reached for a bad row
total = 63
For the bad row, two println calls that would have run never ran: the rest of parseAge and the rest of readRecord. Those frames were discarded on the way to the handler. The loop in totalAge then continued to the next row, and the program finished normally with total = 63 instead of dying with a trace.
That is the real design decision behind a catch: not "where is it convenient to write one" but "which frame is high enough to still have something sensible to do". parseAge cannot decide what a bad age means; totalAge, which knows it is looping over rows, can.
throw: raising an exception on purpose
throw is a statement. It takes one Throwable and transfers control immediately — nothing after it in the block runs. The usual reason to write one is that a method has been handed arguments it cannot work with.
public class ThrowDemo {
static int scoreFor(int correct, int total) {
if (total <= 0) {
throw new IllegalArgumentException("total must be positive, got " + total);
}
if (correct < 0 || correct > total) {
throw new IllegalArgumentException("correct must be 0.." + total + ", got " + correct);
}
return correct * 100 / total;
}
public static void main(String[] args) {
System.out.println("scoreFor(17, 20) = " + scoreFor(17, 20));
try {
scoreFor(3, 0);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
try {
scoreFor(25, 20);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
System.out.println("no message -> " + new IllegalStateException().getMessage());
}
}
scoreFor(17, 20) = 85
rejected: total must be positive, got 0
rejected: correct must be 0..20, got 25
no message -> null
Two habits are worth forming here. Throw the type that describes the problem — IllegalArgumentException for a bad argument, IllegalStateException for a call made at the wrong time — rather than a bare RuntimeException. And put the offending value in the message: got 25 is the difference between a five-second fix and a debugging session, and the last line of the output shows what you get when you skip it.
A catch block may also throw. Rethrowing the same object after logging it keeps the original stack trace, because the trace is captured when the object is constructed, not when it is thrown:
static int strict(String raw) {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException e) {
System.out.println("logging and rethrowing: " + e.getMessage());
throw e;
} finally {
System.out.println("finally runs even when the catch rethrows");
}
}
logging and rethrowing: For input string: "42x"
finally runs even when the catch rethrows
Exception in thread "main" java.lang.NumberFormatException: For input string: "42x"
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 Rethrow.strict(Rethrow.java:5)
at Rethrow.main(Rethrow.java:16)
The trace still points at line 5, the parseInt call, not at the throw e in the catch block.
throws: declaring what a method can raise
throws is a clause in a method signature, not a statement, and it does nothing at runtime. It declares which checked exceptions may escape this method, so that the compiler can force every caller to deal with them.
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;
public class Checked {
static long parseAmount(String raw) throws ParseException {
NumberFormat fmt = NumberFormat.getInstance(Locale.US);
return fmt.parse(raw).longValue();
}
static long totalOf(String[] raws) throws ParseException {
long total = 0;
for (String raw : raws) {
total += parseAmount(raw);
}
return total;
}
public static void main(String[] args) {
String[] good = { "1,200", "35", "9,999" };
String[] bad = { "1,200", "n/a" };
try {
System.out.println("good total = " + totalOf(good));
System.out.println("bad total = " + totalOf(bad));
} catch (ParseException e) {
System.out.println("caught = " + e);
System.out.println("offset = " + e.getErrorOffset());
}
}
}
good total = 11234
caught = java.text.ParseException: Unparseable number: "n/a"
offset = 0
NumberFormat.parse throws a checked ParseException, so parseAmount has two options: catch it, or declare it. It declares it. totalOf then faces the same two options at its own call site and also declares it. main is where the chain stops, because that is the first place with enough context to say what an unparseable amount means, so main catches instead of declaring.
Delete a throws from that chain and the build stops:
UnreportedParse.java:11: error: unreported exception ParseException; must be caught or declared to be thrown
long n = parseAmount("1,200");
^
1 error
The same message appears when a throw statement raises a checked exception in a method whose signature does not declare it. Unchecked exceptions never need declaring — scoreFor above throws IllegalArgumentException with no throws clause at all — although writing one is legal and is occasionally used as documentation.
"Catch it or declare it" is the entire rule, and declare it is very often the better answer. A method that cannot sensibly handle a failure should let it through rather than catch it and return a fake value.
Checked and unchecked, and why Java has both
The rule is a property of the class, not of the situation. Everything in the Error subtree and everything in the RuntimeException subtree is unchecked. Every other Throwable — including Exception itself — is checked.

The two lanes are the same shape of code, and the only difference is which stage rejects it.
| Checked | Unchecked | |
|---|---|---|
| Where they live | Exception minus the RuntimeException subtree | RuntimeException and Error subtrees |
| Compiler | forces catch or throws at every call site | says nothing |
| Enforced at | compile time, by javac | run time, by the JVM |
| Typical cause | something outside the program went wrong | a bug in the program |
| Examples | ParseException, InterruptedException | NullPointerException, IllegalArgumentException |
| Sensible response | handle it, or declare it and let a caller handle it | fix the code that produced it |
The distinction exists because the two failures are not alike. Unparseable number: "n/a" is not a bug — the input was bad, the program is correct, and some caller has to decide what to do, so the compiler makes sure that decision is at least written down somewhere. For input string: "4o" from a hard-coded array is a bug: nobody can handle it at runtime, and the fix is in the code that produced the string.
Java is the only mainstream language that made checked exceptions part of the type system, and the design is genuinely contested — its usual failure mode is a throws clause that grows until callers wrap everything in catch (Exception e) to shut it up, which throws away the benefit and adds the risk described below. Knowing which branch a type sits in is still not optional: it decides whether your code will compile.
Common mistakes
The empty catch block. catch (NumberFormatException e) { } compiles, runs, and destroys the evidence. In one run of a small parser, one row of { "3", "seven", "9" } silently became 0 and the total printed as 12 — a plausible number that is wrong. If you genuinely want to skip bad rows, print something or count them; a catch body with nothing in it is a decision to lose information.
Reaching for catch (Exception e). It catches every unchecked exception too, including the ones caused by your own bugs:
static int broadCatch(String[] row) {
try {
return Integer.parseInt(row[3]);
} catch (Exception e) {
System.out.println("bad number, using 0");
return 0;
}
}
bad number, using 0
The array has three elements, so row[3] throws ArrayIndexOutOfBoundsException — an off-by-one in this very method. The handler reports a parsing problem that did not happen and returns 0, and the bug survives to production. Catch NumberFormatException and the index bug reaches you as a stack trace instead.
return inside finally. It discards whatever the try block was doing, including an exception on its way out:
static int swallow(int n) {
try {
return 100 / n;
} finally {
return -1;
}
}
swallow(4) = -1
swallow(0) = -1
swallow(4) throws away the correct answer 25, and swallow(0) throws away an ArithmeticException entirely — the caller cannot tell the two runs apart. javac -Xlint:all warns with finally clause cannot complete normally, and that warning should be treated as an error. The same applies to break, continue and a throw inside finally:
static void lose() {
try {
throw new IllegalStateException("the real problem");
} finally {
throw new RuntimeException("thrown by finally");
}
}
caller sees: java.lang.RuntimeException: thrown by finally
The real problem is gone. Keep finally blocks to cleanup that cannot itself fail.
Assuming finally changes the returned value. It does not, for a primitive. return result; evaluates result first, then runs finally, then returns the value that was already computed — reassigning result to 999 in the finally block still returns 25. Only an explicit return in the finally block replaces it, which is the trap above.
Catching Throwable or Error. OutOfMemoryError and StackOverflowError mean the JVM is in a state your handler cannot improve. Catching them turns a clear crash into an unpredictable program.
Using exceptions for ordinary control flow. Entering a try block emits no instructions of its own — in the bytecode the handlers live in a separate exception table, so the protected code is compiled exactly as it would be without the try. Constructing a throwable is the expensive half: it walks the whole stack to fill in the trace. Using that as a loop exit is more work than an if and much harder to read. Exceptions are for the exceptional path.
FAQ
What is the difference between checked and unchecked exceptions in Java?
Checked exceptions are everything under Exception except the RuntimeException subtree, and the compiler will not let a call site ignore one: you must catch it or add throws to your own signature, or you get unreported exception X; must be caught or declared to be thrown. Unchecked exceptions are the RuntimeException and Error subtrees, and the compiler says nothing at all about them — they surface at run time. The intent is that a checked exception reports a condition outside the program that a caller should decide about, while an unchecked one reports a bug in the program itself.
Does finally always run in Java?
Almost always. It runs when the try block completes normally, when a catch handles an exception, when an exception is propagating out uncaught, and when the block is left by return, break or continue. It does not run if the JVM stops first — System.exit inside the try prints nothing from the finally block — or if the process is killed outright.
What is the difference between throw and throws in Java?
throw is a statement that raises an exception right now: throw new IllegalArgumentException("total must be positive");. throws is part of a method signature that declares which checked exceptions may escape the method: static long parseAmount(String raw) throws ParseException. throw does something at run time; throws does nothing at run time and exists purely so the compiler can enforce "catch it or declare it" on every caller.
How do I read a Java stack trace?
Start with the first line — it names the exception type and the message, and the message usually identifies the bad value directly. Then read down the at lines, which are the live frames deepest first. The top frames are often inside java.base and are not where the bug is; find the first frame in your own class and its line number, and start there. The frames below it are the call path that reached it, which tells you what data was being processed.
Should I catch Exception or a specific exception type?
A specific type, wherever you can name one. catch (Exception e) also catches every RuntimeException, so an ArrayIndexOutOfBoundsException caused by your own off-by-one gets reported as whatever the handler assumed and the bug is hidden. If two unrelated failures deserve the same response, use multi-catch — catch (NumberFormatException | ArrayIndexOutOfBoundsException e) — rather than widening to Exception.
Why does javac say exception ArithmeticException has already been caught?
Because an earlier catch block in the same statement names a supertype of it — usually Exception or RuntimeException — so the block for ArithmeticException can never be reached. catch blocks are tried in source order and the first match wins, so subtypes must come first. Move the specific block above the general one and the error goes away.
Can I return a value from a finally block?
You can, and you should not. A return in finally replaces whatever the try block produced, including an exception that was on its way to the caller, so a method that divided by zero returns a normal-looking value and nobody ever learns it failed. javac -Xlint:all flags it as finally clause cannot complete normally. Use finally for cleanup and let the value or the exception come from the try block.
Conclusion
Exception handling is one mechanism with two halves. The runtime half is unwinding: a throw abandons the current frame, the JVM discards frames until it finds a catch whose type matches, and finally blocks run on the way past. If no frame matches, the JVM prints the trace and exits with status 1. That single mechanism explains why lines after a throw never run, why a stack trace lists methods deepest first, and why the right place for a catch is the first frame that knows enough to decide.
The compile-time half is the checked and unchecked split, and it is only a question of where a class sits in the Throwable tree. Error and RuntimeException are unchecked and the compiler ignores them; everything else under Exception is checked and every call site must catch it or declare it with throws. Both halves reward being specific: name the narrowest type you can in a catch, put the offending value in the message when you throw, and keep finally for cleanup that cannot fail.
What is missing so far is a type of your own. Every exception here came from the JDK, and IllegalArgumentException says nothing about your domain.
Next in this series: custom exception classes — writing your own Exception and RuntimeException subclasses, choosing which branch to extend, and wrapping a low-level failure in a meaningful one without losing the original cause.