Program execution is not always smooth sailing. It is terrifying if a program exits directly upon encountering an error, or continues running with errors until we have no idea where the problem originated.
To solve this problem, people introduced Exceptions. They can isolate errors, gracefully exit programs, and make the system more robust.
What is an Exception?
An Exception is the objectification of an unexpected state.
Executing a program is essentially a process of pushing and popping the stack.
A calls B, B calls C, C finishes running and returns to B, and B then returns to A.
An unexpected state means that if an exception occurs during the execution of C, it cannot follow the normal return path.
After an exception occurs, the JVM will look for an exception handler, which is the catch block. If B does not have one at this time, it will directly pop B, then look for A, and finally return.
Objectification means that Java will wrap the error into an Object, which contains useful information:
- Type: What happened
e.getClass().getName() - State: Detailed description of the error
e.getMessage() - Context: Stack Trace
e.printStackTrace()
For example:
public class ExceptionDemo {
public static void main(String[] args) {
try {
calculate(10, 0)
} catch (ArithmeticException e) {
// e is the wrapped Object
System.out.println("1. Type (Class): " + e.getClass().getName())
System.out.println("2. State (Message): " + e.getMessage())
System.out.println("3. Context (Stack Trace):")
e.printStackTrace()
}
}
public static void calculate(int a, int b) {
int res = a / b // An exception object is generated here
}
}
Exceptions that occur in a try block all inherit from Throwable and are mainly divided into three categories:
- Checked Exception: Must be handled at compile time using try catch or throws, otherwise it will not compile. An example is
IOException. - Runtime Exception: Also called Unchecked Exception. These are usually code logic errors that the compiler does not force you to catch. Examples include
NullPointerExceptionorArithmeticException. - Error: These are usually severe errors at the JVM level, such as
OutOfMemoryError. The program generally cannot recover, and it is not recommended to catch them.
The ArithmeticException in the example belongs to Runtime Exception.
It will be thrown at runtime.
Throw and Throws
We can also manually throw an exception using the throw keyword. For example:
public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Invalid age: " + age + ", must be between 0 and 150")
}
this.age = age
}
There is another similar keyword throws used in the method signature.
It means that this method explicitly will not handle the exception, and it needs to be handled by the caller.
For example:
public void loadConfig() throws FileNotFoundException {
FileReader fr = new FileReader("config.txt")
}
Here FileNotFoundException must be declared because the compiler knows that reading a file might fail.
Custom Exception
Java native Exceptions are usually technical errors, such as null pointers, network disconnections, and so on, but they cannot describe business errors.
In most cases, we should inherit RuntimeException.
For example:
Java
public class InsufficientBalanceException extends RuntimeException {
// 1. Besides the message, it can carry specific business data
private final double balance
private final double amountRequested
// 2. Provide a constructor to pass information to the parent class
public InsufficientBalanceException(double balance, double amountRequested) {
// Call parent constructor to generate standard error description
super("Transfer failed: Current balance " + balance + " yuan, attempted to transfer " + amountRequested + " yuan")
this.balance = balance
this.amountRequested = amountRequested
}
// 3. Provide Getters to easily extract data in the catch block for compensation logic
public double getBalance() { return balance }
}
We can precisely catch this specific exception:
try {
// InsufficientBalanceException will occur here
bankService.withdraw(100)
} catch (InsufficientBalanceException e) {
// Only handle the case where there is not enough money
showDepositDialog()
}
In the super of the example, we passed the concatenated string to the parent class. When e.getMessage() is called later, this information will be printed.
In addition, we can also pass a Throwable:
public InsufficientBalanceException(double balance, double amountRequested, Throwable cause) {
super("Transfer failed: Current balance " + balance + " yuan, attempted to transfer " + amountRequested + " yuan", cause)
this.balance = balance
this.amountRequested = amountRequested
}
The cause represents the root cause. It can accept other exception information. For example:
try {
// 1. Simulate getting balance from database, which might throw SQLException
currentBalance = database.getBalance(userId)
if (currentBalance < amount) {
// 2. Scenario A: Logic error, proactively throw business exception
throw new InsufficientBalanceException(currentBalance, amount, null)
}
// do something
} catch (SQLException sqlEx) {
// 3. Scenario B: Technical error, database is down
// Wrap it as a business exception and pass the root cause sqlEx to record it
throw new InsufficientBalanceException(currentBalance, amount, sqlEx)
}
How the JVM Handles Exceptions
Let us look at this example.
try {
a / 0
} catch (ArithmeticException e) {
// Do something
}
When the JVM execution reaches a / 0, an error occurs, and it should immediately jump to the catch block. It does not do this by scanning the code line by line to find the catch block, as that would be too inefficient.
In fact, to skip the code that does not need to run between the error occurrence and the catch block, the Java compiler creates an Exception Table in the .class file during compilation.
It contains the following information:
- From: The bytecode instruction line where the try block starts.
- To: The bytecode instruction line where the try block ends.
- Target: The bytecode instruction line number where the catch block starts.
- Type: The type of exception that can be caught.
When a / 0 triggers an error, the JVM immediately looks up the table using the current line number.
If it is between From and To, and the type matches, it jumps directly to the Target to execute. This is very fast.
What if there is no try catch?
What if there is no try catch block? If an error occurs but there is no try catch at this time, the JVM has no way to find a matching entry in the exception table. What should it do?
It will perform a very heavy operation called Stack Frame Unwinding.
For example, if an exception occurs executing C:
- Force pop: The JVM will ignore the local variables in C and directly pop the Stack Frame from the virtual machine stack.
- Restore context: It restores the execution environment of the previous method B.
- Continue looking up the table: It takes the exception object from C and looks up the table in B.
- Loop until termination: It loops the above process until it retreats to
mainwith nowhere else to go, ultimately causing the current thread to terminate unexpectedly, or even causing the entire program to crash and exit.
Therefore, do not forget to write try catch blocks, otherwise the program will fall into an abyss.
Performance Issues of Exceptions
In high concurrency scenarios, throwing a large number of exceptions will cause the server CPU to spike.
The reason lies in e.printStackTrace() which we mentioned earlier, as it records the Stack Trace.
At the lowest level, when new InsufficientBalanceException() is called, the native method fillInStackTrace() in the constructor of the ancestor class Throwable is invoked.
The key point is that calling fillInStackTrace() pauses the Java execution flow or the current thread to capture every Stack Frame from the top of the stack down to the main method, including class names and method names, and finally records them.
This consumes a huge amount of resources.
Therefore, for custom business exceptions like InsufficientBalanceException, we only care about the status code and specific business message. We do not care much about the stack trace information. Thus we can directly return this:
// Add this inside InsufficientBalanceException
@Override
public synchronized Throwable fillInStackTrace() {
// Block the JVM from capturing the stack to improve performance
return this
}
Conclusion
A Java Exception wraps an interrupted unexpected execution path into an Object carrying context data and stack information.
In practice, we can trigger exceptions via throw, declare risks via throws, and use exception chaining to decouple business logic while ensuring underlying traceability.