08 | Is throwing an exception the first choice for error handling?

Java’s error handling is not a feature. However, the flaws and abuses of Java error handling have always been a hot topic. Among them, the use and handling of Java exceptions are the most abused, criticized, and the most difficult to balance.

In order to solve the various Java error handling problems, there have been various methods. However, so far, we have not seen a good way to solve all problems, which is also the direction of programming language researchers.

However, it is precisely because of this that we need to master the Java error handling mechanism, use various solutions in a balanced manner, and properly handle Java exceptions. Let’s take a look at the abuse of Java exceptions and possible solutions through cases and code.

Read the case

We know that the Java language supports three exception conditions: abnormal exception (Error), runtime exception (Runtime Exception) and checked exception (Checked Exception).

Usually, when we talk about exceptions, unless there is a special statement, we are referring to runtime exceptions or checked exceptions.

We also know that handling exceptions will make the code less efficient, so we should not use exception mechanisms to handle normal situations . For a smooth business, ideally, no exceptions occur when executing code. Otherwise, the efficiency of business execution will be greatly reduced.

How much impact does exception handling have on code execution efficiency? We must first have an intuitive feeling about this problem, and then we can understand the weight of the sentence “Exception mechanisms should not be used to deal with normal situations” and realize the harm of abnormal abuse.

The code below tests the throughput of two simple use cases. In both cases, an attempt is made to intercept a string. However, one of the benchmark tests did not throw an exception; the other benchmark test threw a runtime exception due to out-of-bounds string access. In order to make the two benchmark tests more comparable, we used the same code structure in both benchmark tests.

package co.ivi.jus.agility.former;

// snipped
public class OutOfBoundsBench {<!-- -->
    private static String s = "Hello, world!"; // s.length() == 13.

    // snipped

    @Benchmark
    public void withException() {<!-- -->
        try {<!-- -->
            s.substring(14);
        } catch (RuntimeException re) {<!-- -->
            // blank line, ignore the exception.
        }
    }

    @Benchmark
    public void noException() {<!-- -->
        try {<!-- -->
            s.substring(13);
        } catch (RuntimeException re) {<!-- -->
            // blank line, ignore the exception.
        }
    }
}

The benchmark results may surprise you. A use case that does not throw an exception can support 1000 times greater throughput than a use case that throws an exception.

Benchmark Mode Cnt Score Error Units
OutOfBoundsBench.noException thrpt 15 566348609.338 ± 22165278.114 ops/s
OutOfBoundsBench.withException thrpt 15 504193.920 ± 26489.992 ops/s

If you use operating costs to measure it, you can consider an environment where expenses are calculated according to the computing resources used, such as cloud computing. If use cases that don’t throw exceptions cost 10,000, use cases that throw exceptions would cost 10 million to support the same number of users. If a hacker could find such an operating efficiency problem, it could cost an application 1,000 times more money, or until the application exhausted its allocated computing resources and could no longer provide services.

Such an evaluation is of course crude, but it is enough to illustrate the impact of throwing exceptions on software efficiency. We certainly don’t want the code we write to have such a money-burning problem.

At this time, we will imagine: Can our code avoid any abnormal situations? We also mentioned before, “The ideal situation for a smooth business is that no abnormal conditions occur when executing the code.”

Unfortunately, this is an almost impossible task. If you look through the Java code, whether it is a core class library like JDK or an application software that supports business, we can see a lot of exception handling code.

For example, we want to build a server using Java. Normally, if there is a problem with the business logic, for example, if the data entered by the user does not conform to the standard, we will throw an exception, mark the problematic data, and record the path where the problem occurred. However, regardless of the business problem, a server crash is an unacceptable outcome. Therefore, our server will catch all exceptions, whether they are runtime exceptions or checked exceptions; then recover from the exceptions and continue to provide services.

But whether a scene is unusual or not is sometimes just a matter of perspective. For example: the input data is not standardized. From the perspective of checking the user data code, this is an abnormal situation, so an exception is thrown; however, from the perspective of a server that requires uninterrupted operation, this is just an abnormal situation. It is a normal scenario that requires proper handling by the application. Therefore, the server must be able to recover from such exceptions and continue running.

However, now slightly more complex software is integrated with many class libraries. Most class libraries only consider problems from their own perspective and use exceptions to handle problems encountered. Unless it is a very simple code, it is difficult for us to expect that a business will be executed without any exceptions.

There is no doubt that throwing exceptions affects the efficiency of the code. However, there is no other way for us to avoid such influence. Therefore, some new programming languages (such as Go language) simply abandon the exception mechanism similar to Java and re-embrace the error code method of C language.

Discuss cases

In the following discussion, in order to facilitate us to modify the code repeatedly, I will use the following case.

We know that when designing the public interface of an algorithm, the agility of the algorithm must be considered. Because algorithms will always evolve, old algorithms will become obsolete, and new algorithms will appear. An application should be able to easily upgrade its algorithm, automatically eliminate old algorithms and adopt new ones, without making major changes or even changing the source code. Therefore, public interfaces to algorithms often use common parameters and structures.

For example, when we obtain an instance of a single-term hash function, we generally do not directly call the constructor of the single-term hash function. Instead, an integrated environment similar to the factory pattern is used to construct an instance of this single-term hash function.

Just like the of method in the code below. The of method uses a string as an input parameter. We can write it as a configuration parameter in the configuration file. After modifying the configuration file, the algorithm can be upgraded without changing the source code that calls it.

package co.ivi.jus.agility.former;

import java.security.NoSuchAlgorithmException;

public sealed abstract class Digest {<!-- -->
    private static final class SHA256 extends Digest {<!-- -->
        @Override
        byte[] digest(byte[] message) {<!-- -->
            // snipped
        }
    }
    
    private static final class SHA512 extends Digest {<!-- -->
        @Override
        byte[] digest(byte[] message) {<!-- -->
            // snipped
        }
    }

    public static Digest of(String algorithm) throws NoSuchAlgorithmException {<!-- -->
        return switch (algorithm) {<!-- -->
            case "SHA-256" -> new SHA256();
            case "SHA-512" -> new SHA512();
            default -> throw new NoSuchAlgorithmException();
        };
    }

    abstract byte[] digest(byte[] message);
}

Of course, generic parameters have their own problems. For example, the string input parameter may be omitted, or it may not be a supported algorithm. At this time, from the perspective of the of method, you need to handle such an abnormal situation. Reflected in the code, the of method should declare how to handle illegal input parameters. The above code uses a checked exception.

Then, the code that uses this of method needs to handle this checked exception. The following code describes a typical example of using this method.

try {<!-- -->
    Digest md = Digest.of(digestAlgorithm);
    md.digest("Hello, world!".getBytes());
} catch (NoSuchAlgorithmException nsae) {<!-- -->
    // snipped
}

Since exception handling is used, of course there will be performance issues with exception handling that we discussed in the reading case. I also tried to benchmark this method on exception handling. Test results show that the throughput that it can support for use cases that do not throw exceptions is nearly 2,000 times greater than that for use cases that throw exceptions. With the knowledge and foreshadowing from reading the previous cases, you should be mentally prepared for such performance differences.

Benchmark Mode Cnt Score Error Units
ExceptionBench.noException thrpt 15 1318854854.577 ± 14522418.634 ops/s
ExceptionBench.withException thrpt 15 713057.511 ± 16631.048 ops/s

Return error code

So, since the efficiency of exception handling is so worrying, can the Java code we write return to the error code method like the Go language? This is the first direction we want to explore.

In other words, if a method does not need to return a value, we can try to modify it to return an error code. This is a very intuitive modification method.

- // no return value
- public void doSomething();

 + // return an error code if run into problems, otherwise 0.
 + public int doSomething();

However, if a method requires a return value, we cannot use the method of returning only an error code. If there was a way to return both a return value and an error code, the code would be significantly improved. Therefore, we need to design a data structure to support such a return method.

The Coded file class in the code below is a data structure that can meet such requirements.

public record Coded<T>(T returned, int errorCode) {<!-- -->
    // blank
};

If a method executes successfully, its return value should be stored in the returned variable of Coded; if the execution fails, the failure error code should be stored in the errorCode variable of Coded. We can modify the of method in the discussion case to use an error code, like the following code.

public static Coded<Digest> of(String algorithm) {<!-- -->
    return switch (algorithm) {<!-- -->
        case "SHA-256" -> new Coded(sha256, 0);
        case "SHA-512" -> new Coded(sha512, 0);
        default -> new Coded(null, -1);
    };
}

Correspondingly, the use of this method requires processing error codes. The following code is an example of how to use error codes.

Coded<Digest> coded = Digest.of("SHA-256");
if (coded.errorCode() != 0) {<!-- -->
    // snipped
} else {<!-- -->
    coded.returned().digest("Hello, world!".getBytes());
}

After reading the above code, I think you should be able to judge its performance. Let’s use benchmark testing to verify our conjecture.

The test results show that there is almost no difference in the throughput that it can support for use cases that do not return error codes and use cases that return error codes. This is the result we want.

Benchmark Mode Cnt Score Error Units
CodedBench.noErrorCode thrpt 15 1320977784.955 ± 7487395.023 ops/s
CodedBench.withErrorCode thrpt 15 1068513642.240 ± 69527558.874 ops/s

Defects in returning error codes

However, the choice to revert to error codes does not come without a cost. Just now, while optimizing performance, we also gave up the readability and maintainability of the code. What exception handling can solve, that is, the shortcomings of error handling in the C language era, are back again.

More code required

Using exception handling code, we can include multiple method calls in a try-catch block; each method call can throw an exception. In this way, due to the hierarchical design of exceptions, all exceptions are subclasses of Exception; we can also handle exceptions thrown by multiple methods at once.

try {<!-- -->
    doSomething(); // could throw Exception
    doSomethingElse(); // could throw RuntimeException
    socket.close(); // could throw IOException
} catch (Exception ex) {<!-- -->
    // handle the exception in one place.
}

If the error code method is used, the error code returned must be checked for each method call. In general, using error codes requires writing more code for the same logic and interface structure.

For simple logical AND statements, we can merge multiple statements using logical operators. This compact approach sacrifices the readability of the code and is not the coding style we like.

if (doSomething() != 0 & amp; & amp;
    doSomethingElse() != 0 & amp; & amp;
    socket.close() != 0) {<!-- -->
    // handle the exception
}

However, for complex logic and statements, the compact approach won’t work. At this time, a separate code block is needed to handle the error code. In this case, the code with repeated structures will increase, which is a phenomenon we often see in code written in C language.

if (doSomething() != 0) {<!-- -->
    // handle the exception
};

if (doSomethingElse() != 0) {<!-- -->
    // handle the exception
};

if (socket.close() != 0) {<!-- -->
    // handle the exception
}

Debug information discarded

However, the biggest cost of returning error codes is that maintainability is greatly reduced. Using abnormal code, we can clearly see the execution trace of the code through the abnormal call stack and quickly find the problematic code. This is also one of the main motivations for us to use exception handling.

Exception in thread "main" java.security.NoSuchAlgorithmException: \
        Unsupported digest algorithm SHA-128
  at co.ivi.jus.agility.former.Digest.of(Digest.java:31)
  at co.ivi.jus.agility.former.NoCatchCase.main(NoCatchCase.java:12)

However, after using the error code, the call stack is no longer generated. Although this can reduce resource consumption and improve code performance, the benefits of the call stack are gone.

In addition, being able to quickly find code problems is also the competitiveness of a programming language. If we decide to go back to error code handling, don’t forget to provide alternatives for quickly troubleshooting the problem. For example, use more detailed logs, or enable JFR (Java Flight Recorder) to collect diagnostic and analysis data. Without an alternative, I’m sure you’ll really miss the benefits of using exceptions.

In fact, the error codes in the C language era and the exception handling mechanism in the Java language era are like two ends of a seesaw, one end is performance and the other end is maintainability. When Java was born, there was an assumption that computing power would evolve rapidly, so the weight of performance would decrease, while the weight of maintainability would be heavily weighted. However, if we evolve into an era of billing based on computing power, we may need to reconsider the proportions of these two indicators. At this time, some codes may need to put more emphasis on performance.

Fragile data structures

If you have read my other column “The Road to Code Improvement”, you should be able to understand that the design of a new mechanism must be simple and solid. The so-called solid skin means that you can use it correctly, with little discipline and low requirements, and it is not easy to make mistakes. We use this criterion to see if the Coded file class designed above is solid enough.

To generate a Coded instance, two disciplines need to be followed. The first rule is that the value of the error code must be consistent. 0 means no error, and any other value means an error has occurred. The second rule is that the return value and error code cannot be set at the same time. If any discipline is violated, unpredictable mistakes will occur.

However, these two disciplines need to be consciously implemented by the person who writes the code, and the compiler will not help us check for errors.

For example, the following code is legal code for the compiler. But to us, such code clearly violates the rules for using error codes. This means that the way to generate error codes is not solid enough.

public static Coded<Digest> of(String algorithm) {<!-- -->
    return switch (algorithm) {<!-- -->
        // INCORRECT: set both error code and value.
        case "SHA-256" -> new Coded(sha256, -1);
        case "SHA-512" -> new Coded(sha512, 0);
        default -> new Coded(sha256, -1);
    };
}

Let’s look at the code using error codes again. There is also an iron rule when using error codes: the error code must be checked first, and then the return value can be used. Likewise, the compiler won’t help us check for disciplinary errors. The code below does not use error codes correctly. We need to rely on experience to avoid such mistakes. Therefore, the method of using error codes is not reliable enough.

Coded<Digest> coded = Digest.of("SHA-256");
// INCORRECT: use returned value before checking error code.
coded.returned().digest("Hello, world!".getBytes());

The more discipline required, the more likely we are to make mistakes. Are there any improved solutions that can reduce these additional requirements?

Improvement plan: shared error codes

We hope that the improved scheme can consider the needs of both generating error codes and using error codes. The following code is an improved design.

public sealed interface Returned<T> {<!-- -->
    record ReturnValue<T>(T returnValue) implements Returned {<!-- -->
    }
    
    record ErrorCode(Integer errorCode) implements Returned {<!-- -->
    }
}

In this improved design, we use closed classes. We know that the subclasses of closed classes can be exhausted, which is an important feature required for this improvement. We define Returned’s permission classes (ReturnValue and ErrorCode) as file classes, representing return values and error codes respectively. This way, we have a streamlined solution.

The following code is an example of using the new scheme to generate return values and error codes. As you can see, compared to the example using the Coded file class, the return value and error code here are separated. A method returns either a return value or an error code, rather than both values at the same time. This method brings us back to the familiar coding method.

public static Returned<Digest> of(String algorithm) {<!-- -->
    return switch (algorithm) {<!-- -->
        case "SHA-256" -> new ReturnValue(new SHA256());
        case "SHA-512" -> new ReturnValue(new SHA512());
        case null, default -> new ErrorCode(-1);
    };
}

Moreover, the two disciplines that need to be followed to generate Coded instances are not needed here. Because returning the ReturnValue permission class means there is no error; returning the ErrorCode permission class means there is an error. This kind of design becomes simpler and more solid.

Next, let’s look at the use of error codes. In the code below, we use the new switch matching feature discussed earlier. Returned This closed class is designed as an interface without methods. To obtain the return value, we must use its permission class ReturnValue, or ErrorCode.

Returned<Digest> rt = Digest.of("SHA-256");
switch (rt) {<!-- -->
    case ReturnValue rv -> {<!-- -->
            Digest d = (Digest) rv.returnValue();
            d.digest("Hello, world!".getBytes());
        }
    case ErrorCode ec ->
            System.out.println("Failed to get instance of SHA-256");
}

If a method call returns a Returned instance, we know that it is either a ReturnValue object representing the return value, or an ErrorCode object representing the error code. Furthermore, if you want to use the return value, you must check whether it is an instance of ReturnValue. In this case, the discipline that needs to be followed when writing code using the Coded file class, that is, the error code must be checked first, is no longer needed here. Using the error code side has become simpler and more practical.

Of course, using closed classes to represent return values and error codes separately is just one way to improve error codes. This approach still has some flaws, such as it carrying no debugging information itself. In terms of Java error handling, we hope to have better design and more exploration in the future to make our code more perfect.

Summary

Okay, that’s it for this lesson, let me make a summary. From the previous discussion, we learned about the performance issues caused by Java exception handling, and I also showed you a solution for error handling using error codes. Use error codes for error handling. Error codes cannot carry debugging information. This improves the performance of error handling, but increases the difficulty of error troubleshooting and reduces the maintainability of the code.

In our code, whether we should use error codes or exceptions is a question that needs to be carefully weighed based on the application scenario. Java’s new features, especially closed classes and archive classes, provide us with powerful support for using error codes in Java software and give us new choices.

If you want to enrich your code review checklist, error codes can be included as an evaluable option in your inspection metrics:

Is it the best choice to use exception mechanism for error handling?

In addition, I also mentioned a few technical points discussed today, which may appear in your interview. After today’s study, you should be able to:

  • Understand the performance problems caused by Java exception handling and have a rough idea of the impact of this problem;
  • Interview question: Do you know what problems can arise with exception handling in Java?
  • Understand the alternatives to Java exception handling, and its advantages and disadvantages;
  • Interview question: Do you know how to improve the performance of Java code?