12 Exception Handling Don't Let Yourself Become Confused When Problems Arise

12 Exception Handling Don’t Let Yourself Become Confused When Problems Arise #

Today, I want to talk to you about the pitfalls of exception handling.

It’s inevitable for applications to encounter exceptions, and capturing and handling exceptions is a delicate task that tests one’s programming skills. In some business projects, I have seen developers who didn’t consider any exception handling while developing the business logic. Instead, they adopted a “production line” approach for exception handling when the project was nearing completion. This approach involves adding try…catch… blocks to all methods to catch any exceptions and log them. Some skilled developers might use AOP (Aspect-Oriented Programming) for similar “uniform exception handling”.

In reality, this way of handling exceptions is highly undesirable. So today, I will share with you the reasons why it should be avoided, the pitfalls related to exception handling, and best practices.

Common Mistakes in Catching and Handling Exceptions #

The first mistake I want to talk about is the “uniform exception handling” approach: not considering exception handling at the business code level, and only roughly catching and handling exceptions at the framework level.

To understand where the mistake lies, let’s take a look at the three-layer architecture commonly used in most business applications:

  • The controller layer is responsible for information gathering, parameter validation, and data adaptation for frontend services, with lightweight business logic.
  • The service layer is responsible for core business logic, including various external service invocations, database access, cache processing, message processing, etc.
  • The repository layer is responsible for data access implementation, generally without business logic.

The nature of the work differs for each layer of the architecture, and exceptions can be classified into two categories: business exceptions and system exceptions. This makes it difficult to have a unified exception handling approach. Let’s take a look at the three-layer architecture from bottom to top:

  • Exceptions in the repository layer may be ignored, degraded, or converted into a friendly exception. If all exceptions are simply caught and logged, it is likely that the business logic has already gone wrong, and the user and the program itself are completely unaware.
  • In the service layer, handling exceptions is also not suitable since it often involves database transactions. Otherwise, transactions cannot roll back automatically. In addition, the service layer involves business logic, and if business exceptions are caught by the framework, the business functionality will be abnormal.
  • If the exception still cannot be handled at the controller layer, the controller layer often provides friendly prompts to the user or returns a specific exception type based on the exception table for each API. However, it is still not possible to treat all exceptions equally.

Therefore, I do not recommend automatically and uniformly handling exceptions at the framework level, especially avoiding catching exceptions at will. However, the framework can serve as a fallback. If the exception rises to the top-level logic and still cannot be handled, the exception can be converted in a unified manner, such as using @RestControllerAdvice + @ExceptionHandler, to catch these “unhandled” exceptions:

  • For custom business exceptions, log the exception with a warning level along with the current URL and the executed method. Extract the error code and message from the exception, and convert them into a suitable API wrapper to return to the API caller.
  • For unhandled system exceptions, log the exception and context information (such as the URL, parameters, and user ID) with an error level. Convert these exceptions into a generic “Server busy, please try again later” exception message, and return it as an API wrapper to the caller.

For example, the following code implements this approach:

@RestControllerAdvice
@Slf4j
public class RestControllerExceptionHandler {

    private static int GENERIC_SERVER_ERROR_CODE = 2000;

    private static String GENERIC_SERVER_ERROR_MESSAGE = "Server busy, please try again later";

    @ExceptionHandler
    public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {

        if (ex instanceof BusinessException) {
            BusinessException exception = (BusinessException) ex;
            log.warn(String.format("Encountered a business exception while accessing %s -> %s!",
                req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, exception.getCode(), exception.getMessage());
        } else {
            log.error(String.format("Encountered a system exception while accessing %s -> %s!",
                req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
        }
    }
}

After a runtime system exception occurs, the exception handling program directly converts the exception into JSON and returns it to the caller:

img

To do better, you can record the relevant input and output parameters and user information in a desensitized manner in the log, making it easier to troubleshoot based on the context when problems occur.

The second mistake is swallowing exceptions after catching them. At any time, we should not swallow exceptions, which means to directly discard exceptions without recording or throwing them. This handling approach is worse than not catching exceptions at all because once the swallowed exception causes a bug, it is difficult to find any traces in the program, making bug troubleshooting even more difficult.

In general, the reason for swallowing exceptions may be that we don’t want our methods to throw checked exceptions, and we only catch and swallow the exceptions just to “handle” them. It may also be a presumption that the exceptions are not important or not likely to occur. However, regardless of the reason or how unimportant you think the exception is, it should not be swallowed, not even a simple log.

The third mistake is discarding the original information of the exception. Let’s take a look at two inappropriate ways of handling exceptions, where although the exceptions are not completely swallowed, valuable exception information is lost.

For example, there is a method readFile that throws a checked exception IOException:

private void readFile() throws IOException {
    Files.readAllLines(Paths.get("a_file"));
}

When calling the readFile method like this, the original exception is not recorded at all. Instead, a converted exception is directly thrown, causing the problem to be unclear about where the IOException is caused:

@GetMapping("wrong1")
public void wrong1() {
    try {
        readFile();
} catch (IOException e) {
    // The original exception information is lost
    throw new RuntimeException("System busy, please try again later");
}

Alternatively, only the exception message is logged, but important information such as the type and stack of the exception is lost:

catch (IOException e) {
    // Only the exception message is retained, no stack is recorded
    log.error("File read error, {}", e.getMessage());
    throw new RuntimeException("System busy, please try again later");
}

The resulting log is as follows, leaving us puzzled as to why the file reading error occurred, whether it is because the file does not exist or there is insufficient permission.

[12:57:19.746] [http-nio-45678-exec-1] [ERROR] [.g.t.c.e.d.HandleExceptionController:35  ] - File read error, a_file

Both of these approaches are not ideal. They can be changed to the following:

catch (IOException e) {
    log.error("File read error", e);
    throw new RuntimeException("System busy, please try again later");
}

Or, the original exception can be set as the cause of the new exception, so that the original exception information is not lost:

catch (IOException e) {
    throw new RuntimeException("System busy, please try again later", e);
}

In fact, the JDK itself sometimes makes similar mistakes. I encountered a case where an application using JDK 10 occasionally failed to start, and the log showed similar error messages:

Caused by: java.lang.SecurityException: Couldn't parse jurisdiction policy files in: unlimited
  at java.base/javax.crypto.JceSecurity.setupJurisdictionPolicies(JceSecurity.java:355)
  at java.base/javax.crypto.JceSecurity.access$000(JceSecurity.java:73)
  at java.base/javax.crypto.JceSecurity$1.run(JceSecurity.java:109)
  at java.base/javax.crypto.JceSecurity$1.run(JceSecurity.java:106)
  at java.base/java.security.AccessController.doPrivileged(Native Method)
  at java.base/javax.crypto.JceSecurity.<clinit>(JceSecurity.java:105)
  ... 20 more

Looking at the source code of the “setupJurisdictionPolicies” method in the JDK’s “JceSecurity” class, I found that the exception “e” is not recorded or set as the cause of the newly thrown exception. As a result, it is impossible to know what specific exception occurred during the file reading process (whether it is a permission issue or an IO issue), causing great difficulties in problem locating.

The fourth mistake is not specifying any message when throwing an exception. I have seen some lazy coding practices where exceptions are thrown without a message:

throw new RuntimeException();

Those who write code like this may think that this logic will never be executed and such exceptions will never occur. However, such exceptions do occur, and when they are intercepted by the ExceptionHandler, the following log information is output:

[13:25:18.031] [http-nio-45678-exec-3] [ERROR] [c.e.d.RestControllerExceptionHandler:24  ] - Access /handleexception/wrong3 -> org.geekbang.time.commonmistakes.exception.demo1.HandleExceptionController#wrong3(String) encounters system exception!
java.lang.RuntimeException: null
...

The word “null” here can easily lead to misunderstandings. It took a long time to investigate the null pointer issue before realizing that the exception message is actually empty.

In conclusion, if you catch an exception for handling, there are usually three modes of handling:

  1. Transformation: Convert and throw a new exception. For the newly thrown exception, it is better to have a specific category and clear exception message, instead of throwing an unrelated or empty exception indiscriminately, and it is better to associate the old exception as the cause of the new one.

  2. Retry: Retry the previous operation. For example, in the case of a remote call timeout due to server overload, blind retry may make the problem worse, so you need to consider whether the current situation is suitable for retrying.

  3. Recovery: Try to perform fallback processing or use default values to replace the original data.

These are some best practices for catching and handling exceptions.

Be careful with exceptions in finally block #

Sometimes, we want to release resources regardless of whether an exception occurs or not after the logic is completed. In this case, we can use the finally block instead of the catch block.

But be extremely careful with exceptions in the finally block, as the handling of resource release and other cleanup operations may also throw exceptions. For example, in the following code, we throw an exception in the finally block:

@GetMapping("wrong")
public void wrong() {
    try {
        log.info("try");
        // Exception lost
        throw new RuntimeException("try");
    } finally {
        log.info("finally");
        throw new RuntimeException("finally");
    }
}

In the log, only the exception in the finally block is visible. Although an exception occurred in the try block, it is overshadowed by the exception in the finally block. This is very dangerous, especially when the exception in the finally block is intermittent, as it can sometimes overshadow the exception in the try block, making the problem less obvious:

[13:34:42.247] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: finally] with root cause
java.lang.RuntimeException: finally

The reason why the exception is overshadowed is simple: a method cannot throw two exceptions at the same time. The fix is for the finally block to handle the exception itself:

@GetMapping("right")
public void right() {
    try {
        log.info("try");
        throw new RuntimeException("try");
    } finally {
        log.info("finally");
        try {
            throw new RuntimeException("finally");
        } catch (Exception ex) {
            log.error("finally", ex);
        }
    }
}

Alternatively, you can throw the exception in the try block as the main exception and use the addSuppressed method to attach the exception in the finally block to the main exception:

@GetMapping("right2")
public void right2() throws Exception {
    Exception e = null;
    try {
        log.info("try");
        throw new RuntimeException("try");
    } catch (Exception ex) {
        e = ex;
    } finally {
        log.info("finally");
        try {
            throw new RuntimeException("finally");
        } catch (Exception ex) {
            if (e != null) {
                e.addSuppressed(ex);
            } else {
                e = ex;
            }
        }
    }
    throw e;
}

Running the methods will produce the following exception information, which includes both the main exception and the suppressed exception:

java.lang.RuntimeException: try
  at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:69)
  at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
  ...
  Suppressed: java.lang.RuntimeException: finally
    at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:75)
    ... 54 common frames omitted

In fact, this is exactly what the try-with-resources statement does. For resources that implement the AutoCloseable interface, it is recommended to use try-with-resources to release resources. Otherwise, the same problem mentioned earlier may occur, where the exception during resource release overshadows the main exception. For example, let’s define a test resource where both the read and close methods throw exceptions:

public class TestResource implements AutoCloseable {

    public void read() throws Exception{
        throw new Exception("read error");
    }

    @Override
    public void close() throws Exception {
        throw new Exception("close error");
    }
}

Using the traditional try-finally statement, we call the read method in the try block and the close method in the finally block:

@GetMapping("useresourcewrong")
public void useresourcewrong() throws Exception {

    TestResource testResource = new TestResource();
    try {
        testResource.read();
    } finally {
        testResource.close();
    }
}

As you can see, the exception in the finally block overshadows the exception in the try block:

java.lang.Exception: close error
  at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
  at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourcewrong(FinallyIssueController.java:27)

After switching to the try-with-resources pattern:

@GetMapping("useresourceright")
public void useresourceright() throws Exception {
    try (TestResource testResource = new TestResource()){
        testResource.read();
    }
}

Both the exceptions in the try and finally blocks are preserved:

java.lang.Exception: read error
  at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.read(TestResource.java:6)
  ...
  Suppressed: java.lang.Exception: close error
    at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
    at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourceright(FinallyIssueController.java:35)
    ... 54 common frames omitted

Never Define Exceptions as Static Variables #

Since we usually define a custom exception type to contain more exception information, such as exception error codes and friendly error messages, we need to manually throw various business exceptions in different parts of the business logic to return specific error code descriptions (such as returning 2001 if the user does not exist when placing an order, and returning 2002 if the product is out of stock, etc.).

For these exception error codes and messages, we expect them to be managed uniformly instead of scattered throughout the program. This idea is good, but if you are not careful, you may fall into the trap of defining exceptions as static variables.

When I was troubleshooting a production issue in a project, I encountered a very strange situation: I found that the method call path displayed in the exception heap information was impossible given the current parameters. Since the project’s business logic was complex, I never thought that the exception information was incorrect, and always felt that it was due to some branch process that caused the business to not proceed as expected.

After difficult troubleshooting, I finally determined that the reason was that the exception was defined as a static variable, which caused the exception stack information to be disordered. It is similar to defining an Exceptions class to summarize all exceptions and storing them in static fields:

public class Exceptions {
    public static BusinessException ORDEREXISTS = new BusinessException("Order already exists", 3001);
...
}

Defining exceptions as static variables will cause the exception information to solidify, which contradicts the fact that the exception stack must be dynamically obtained based on the current invocation.

Let’s write some code to simulate this issue: define two methods createOrderWrong and cancelOrderWrong, both of which will use the Exceptions class to get an exception that the order does not exist. Call the two methods in sequence and then throw the exception.

@GetMapping("wrong")
public void wrong() {

    try {
        createOrderWrong();
    } catch (Exception ex) {
        log.error("createOrder got error", ex);
    }

    try {
        cancelOrderWrong();
    } catch (Exception ex) {
        log.error("cancelOrder got error", ex);
    }

}

private void createOrderWrong() {
    // There is a problem here
    throw Exceptions.ORDEREXISTS;
}

private void cancelOrderWrong() {
    // There is a problem here
    throw Exceptions.ORDEREXISTS;
}

After running the program, we can see the following logs: the error message for “cancelOrder got error” corresponds to the createOrderWrong method. Obviously, the exception thrown by the cancelOrderWrong method after an error occurred is actually the exception thrown by the createOrderWrong method:

[14:05:25.782] [http-nio-45678-exec-1] [ERROR] [.c.e.d.PredefinedExceptionController:25  ] - cancelOrder got error
org.geekbang.time.commonmistakes.exception.demo2.BusinessException: Order already exists
  at org.geekbang.time.commonmistakes.exception.demo2.Exceptions.<clinit>(Exceptions.java:5)
  at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.createOrderWrong(PredefinedExceptionController.java:50)
  at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.wrong(PredefinedExceptionController.java:18)

The fix is simple. Modify the implementation of the Exceptions class and use different methods to new each type of exception and throw it:

public class Exceptions {

    public static BusinessException orderExists(){
        return new BusinessException("Order already exists", 3001);
    }
}

What happens if an exception is thrown when submitting a task to a thread pool? #

In Lecture 3, I mentioned that thread pools are commonly used for asynchronous or parallel processing. So what happens when a task submitted to a thread pool throws an exception?

Let’s take a look at an example: we submit 10 tasks to a thread pool for asynchronous processing, and the 5th task throws a RuntimeException. After each task is completed, a log line is output:

@GetMapping("execute")
public void execute() throws InterruptedException {

    String prefix = "test";
    ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder().setNameFormat(prefix+"%d").get());

    //Submit 10 tasks to the thread pool for processing, the 5th task will throw a runtime exception
    IntStream.rangeClosed(1, 10).forEach(i -> threadPool.execute(() -> {
        if (i == 5) throw new RuntimeException("error");
        log.info("I'm done : {}", i);
    }));

    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
}

By observing the logs, we can see two things:

...
[14:33:55.990] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:26  ] - I'm done : 4
Exception in thread "test0" java.lang.RuntimeException: error
  at org.geekbang.time.commonmistakes.exception.demo3.ThreadPoolAndExceptionController.lambda$null$0(ThreadPoolAndExceptionController.java:25)
  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
  at java.lang.Thread.run(Thread.java:748)
[14:33:55.990] [test1] [INFO ] [e.d.ThreadPoolAndExceptionController:26  ] - I'm done : 6
...

The threads where tasks 1 to 4 are executed are named “test0”, and the thread executing task 6 is named “test1”. Since I named the threads using a thread factory that adds a prefix “test” and a counter, the change in thread names indicates that the old thread exited due to the exception being thrown, and the thread pool had to create a new thread. If every asynchronous task ends with an exception, then the thread pool may not be able to fulfill its purpose of thread reuse.

Since we didn’t manually catch the exception for handling, the ThreadGroup helps us with the default handling of uncaught exceptions by printing the thread name and exception information to the standard error output. Obviously, this form of printing error messages without a unified format is not suitable for production-level code. The relevant source code of ThreadGroup is shown below:

public void uncaughtException(Thread t, Throwable e) {
        if (parent != null) {
            parent.uncaughtException(t, e);
        } else {
            Thread.UncaughtExceptionHandler ueh =
                Thread.getDefaultUncaughtExceptionHandler();
            if (ueh != null) {
                ueh.uncaughtException(t, e);
            } else if (!(e instanceof ThreadDeath)) {
                System.err.print("Exception in thread \""
                                 + t.getName() + "\" ");
                e.printStackTrace(System.err);
            }
        }
    }

There are two steps to fix this:

For asynchronously submitted tasks in the execute method, it is best to handle exceptions inside the task;

Set a custom exception handler as a fallback, such as customizing the uncaught exception handler for the thread pool when declaring it:

new ThreadFactoryBuilder()
  .setNameFormat(prefix+"%d")
  .setUncaughtExceptionHandler((thread, throwable)-> log.error("ThreadPool {} got exception", thread, throwable))
  .get()

Or set a global default uncaught exception handler:

static {
    Thread.setDefaultUncaughtExceptionHandler((thread, throwable)-> log.error("Thread {} got exception", thread, throwable));
}

By using the execute method of the ExecutorService to submit tasks to the thread pool for processing, if an exception occurs, the thread will exit, and the console output will show the exception information. Now, if we change the execute method to submit, will the thread still exit? Can the exception still be caught by the handler?

After modifying the code and re-running the program, we can see the following log, indicating that the thread did not exit and the exception was silently swallowed:

[15:44:33.769] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 1
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 2
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 3
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 4
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 6
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 7
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 8
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 9
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 10

Why does this happen?

By examining the FutureTask source code, we can see that after an exception occurs during task execution, the exception is stored in an outcome field, and the exception will only be re-thrown as an ExecutionException when calling the get method to retrieve the result of the FutureTask:

public void run() {
...
    try {
        Callable<V> c = callable;
        if (c != null && state == NEW) {
            V result;
            boolean ran;
            try {
                result = c.call();
                ran = true;
            } catch (Throwable ex) {
                result = null;
                ran = false;
                setException(ex);
            }
...
}

protected void setException(Throwable t) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = t;
        UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
        finishCompletion();
    }
}

public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    return report(s);
}

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    if (s >= CANCELLED)
        throw new CancellationException();
    throw new ExecutionException((Throwable)x);
}

The modified code is shown below. We put the Future returned by submit into a List, and then iterate through the list to catch exceptions for all tasks. This approach makes sense. Since the tasks are submitted using the submit method, we should be concerned about the execution results of the tasks. Otherwise, we should use the execute method to submit tasks:

List<Future> tasks = IntStream.rangeClosed(1, 10).mapToObj(i -> threadPool.submit(() -> {
    if (i == 5) throw new RuntimeException("error");
    log.info("I'm done : {}", i);
})).collect(Collectors.toList());

tasks.forEach(task-> {
    try {
        task.get();
    } catch (Exception e) {
        log.error("Got exception", e);
    }
});

By executing this program, we can see the following log output:

[15:44:13.543] [http-nio-45678-exec-1] [ERROR] [e.d.ThreadPoolAndExceptionController:69  ] - Got exception
java.util.concurrent.ExecutionException: java.lang.RuntimeException: error

Key Recap #

In today’s article, I introduced several common mistakes and best practices for handling exceptions.

Firstly, pay attention to the best practices for catching and handling exceptions. Firstly, it is not recommended to use AOP to handle exceptions uniformly for all methods. Exceptions should either not be caught and handled, or be handled finely and specifically based on different business logic and different exception types. Secondly, handling exceptions should avoid swallowing them and ensure that the exception stack information is preserved. Finally, if you need to rethrow an exception, please use meaningful exception types and exception messages.

Secondly, be careful with the resource cleanup logic in the finally block, ensuring that the finally block does not throw exceptions and handles exceptions internally, to avoid the exception in the finally block overriding the exception in the try block. Alternatively, consider using the addSuppressed method to attach the exception in the finally block to the exception in the try block, ensuring that the main exception information is not lost. In addition, when using resources that implement the AutoCloseable interface, be sure to use the try-with-resources pattern to ensure that resources are properly released and exceptions are handled properly.

Thirdly, although it is a good practice to define and handle all business exceptions in a unified place, make sure that the exceptions are instantiated each time and not stored in a predefined static field. Otherwise, it may cause confusion in the stack information.

Fourthly, ensure that exceptions in tasks in the thread pool are handled properly. If tasks are submitted using execute, an exception will cause the thread to exit. A large number of exceptions can cause performance problems due to repeated thread creation. We should try to ensure that tasks do not throw exceptions, and at the same time, set a default uncaught exception handler. If tasks are submitted using submit, it means that we care about the execution result of the tasks. We should use the get method of the obtained Future to retrieve the task running result and any possible exceptions. Otherwise, the exception may be swallowed.

The code used today is all available on GitHub, and you can click on this link to view it.

Reflection and Discussion #

Regarding the issue of throwing exceptions in the finally block, what do you think will happen if there is a return value in the finally block? Will the program follow the return value in the try or catch block, or will it follow the return value in the finally block?

For manually thrown exceptions, it is not recommended to directly use Exception or RuntimeException. It is usually recommended to reuse some standard exceptions in the JDK, such as IllegalArgumentException, IllegalStateException, and UnsupportedOperationException. Can you talk about their use cases and list more commonly used exceptions?

Have you encountered any pitfalls in exception handling, and do you have any best practices to share? I am Zhu Ye. Feel free to leave a comment in the comment section and share this article with your friends or colleagues for discussion.