1. Overview

In Java, exceptions are generally considered expensive and shouldn't be used for flow control. This tutorial will prove that this perception is correct and pinpoint what causes the performance issue.

2. Setting Up Environment

Before writing code to evaluate the performance cost, we need to set up a benchmarking environment.

2.1. Java Microbenchmark Harness

Measuring exception overhead isn't as easy as executing a method in a simple loop and taking note of the total time.

The reason is that a just-in-time compiler can get in the way and optimize the code. Such optimization may make the code perform better than it would actually do in a production environment. In other words, it might yield falsely positive results.

To create a controlled environment that can mitigate JVM optimization, we'll use Java Microbenchmark Harness, or JMH for short.

The following subsections will walk through setting up a benchmarking environment without going into the details of JMH. For more information about this tool, please check out our Microbenchmarking with Java tutorial.

2.2. Obtaining JMH Artifacts

To get JMH artifacts, add these two dependencies to the POM:

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.21</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.21</version>
</dependency>

Please refer to Maven Central for the latest versions of JMH Core and JMH Annotation Processor.

2.3. Benchmark Class

We'll need a class to hold benchmarks:

@Fork(1)
@Warmup(iterations = 2)
@Measurement(iterations = 10)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ExceptionBenchmark {
    private static final int LIMIT = 10_000;
    // benchmarks go here
}

Let's go through the JMH annotations shown above:

  • @Fork: Specifying the number of times JMH must spawn a new process to run benchmarks. We set its value to 1 to generate only one process, avoiding waiting for too long to see the result
  • @Warmup: Carrying warm-up parameters. The iterations element being 2 means the first two runs are ignored when calculating the result
  • @Measurement: Carrying measurement parameters. An iterations value of 10 indicates JMH will execute each method 10 times
  • @BenchmarkMode: This is how JHM should collect execution results. The value AverageTime requires JMH to count the average time a method needs to complete its operations
  • @OutputTimeUnit: Indicating the output time unit, which is the millisecond in this case

Additionally, there's a static field inside the class body, namely LIMIT. This is the number of iterations in each method body.

2.4. Executing Benchmarks

To execute benchmarks, we need a main method:

public class MappingFrameworksPerformance {
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

We can package the project into a JAR file and run it at the command line. Doing so now will, of course, produce an empty output as we haven't added any benchmarking method.

For convenience, we can add the maven-jar-plugin to the POM. This plugin allows us the execute the main method inside an IDE:

<groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>3.2.0</version>
    <configuration>
        <archive>
            <manifest>
                <mainClass>com.baeldung.performancetests.MappingFrameworksPerformance</mainClass>
            </manifest>
        </archive>
    </configuration>
</plugin>

The latest version of maven-jar-plugin can be found here.

3. Performance Measurement

It's time to have some benchmarking methods to measure performance. Each of these methods must carry the @Benchmark annotation.

3.1. Method Returning Normally

Let's start with a method returning normally; that is, a method that doesn't throw an exception:

@Benchmark
public void doNotThrowException(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        blackhole.consume(new Object());
    }
}

The blackhole parameter references an instance of Blackhole. This is a JMH class that helps prevent dead code elimination, an optimization a just-in-time compiler may perform.

The benchmark, in this case, doesn't throw any exception. In fact, we'll use it as a reference to evaluate the performance of those that do throw exceptions.

Executing the main method will give us a report:

Benchmark                               Mode  Cnt  Score   Error  Units
ExceptionBenchmark.doNotThrowException  avgt   10  0.049 ± 0.006  ms/op

There's nothing special in this result. The average execution time of the benchmark is 0.049 milliseconds, which is per se pretty meaningless.

3.2. Creating and Throwing an Exception

Here's another benchmark that throws and catches exceptions:

@Benchmark
public void throwAndCatchException(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        try {
            throw new Exception();
        } catch (Exception e) {
            blackhole.consume(e);
        }
    }
}

Let's have a look at the output:

Benchmark                                  Mode  Cnt   Score   Error  Units
ExceptionBenchmark.doNotThrowException     avgt   10   0.048 ± 0.003  ms/op
ExceptionBenchmark.throwAndCatchException  avgt   10  17.942 ± 0.846  ms/op

The small change in the execution time of method doNotThrowException isn't important. It's just the fluctuation in the state of the underlying OS and the JVM. The key takeaway is that throwing an exception makes a method run hundreds of times slower.

The next few subsections will find out what exactly leads to such a dramatic difference.

3.3. Creating an Exception Without Throwing It

Instead of creating, throwing, and catching an exception, we'll just create it:

@Benchmark
public void createExceptionWithoutThrowingIt(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        blackhole.consume(new Exception());
    }
}

Now, let's execute the three benchmarks we've declared:

Benchmark                                            Mode  Cnt   Score   Error  Units
ExceptionBenchmark.createExceptionWithoutThrowingIt  avgt   10  17.601 ± 3.152  ms/op
ExceptionBenchmark.doNotThrowException               avgt   10   0.054 ± 0.014  ms/op
ExceptionBenchmark.throwAndCatchException            avgt   10  17.174 ± 0.474  ms/op

The result may come as a surprise: the execution time of the first and the third methods are nearly the same, while that of the second is substantially smaller.

At this point, it's clear that the throw and catch statements themselves are fairly cheap. The creation of exceptions, on the other hand, produces high overheads.

3.4. Throwing an Exception Without Adding the Stack Trace

Let's figure out why constructing an exception is much more expensive than doing an ordinary object:

@Benchmark
@Fork(value = 1, jvmArgs = "-XX:-StackTraceInThrowable")
public void throwExceptionWithoutAddingStackTrace(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        try {
            throw new Exception();
        } catch (Exception e) {
            blackhole.consume(e);
        }
    }
}

The only difference between this method and the one in subsection 3.2 is the jvmArgs element. Its value -XX:-StackTraceInThrowable is a JVM option, keeping the stack trace from being added to the exception.

Let's run the benchmarks again:

Benchmark                                                 Mode  Cnt   Score   Error  Units
ExceptionBenchmark.createExceptionWithoutThrowingIt       avgt   10  17.874 ± 3.199  ms/op
ExceptionBenchmark.doNotThrowException                    avgt   10   0.046 ± 0.003  ms/op
ExceptionBenchmark.throwAndCatchException                 avgt   10  16.268 ± 0.239  ms/op
ExceptionBenchmark.throwExceptionWithoutAddingStackTrace  avgt   10   1.174 ± 0.014  ms/op

By not populating the exception with the stack trace, we reduced execution duration by more than 100 times. Apparently, walking through the stack and adding its frames to the exception bring about the sluggishness we've seen.

3.5. Throwing an Exception and Unwinding Its Stack Trace

Finally, let's see what happens if we throw an exception and unwind the stack trace when catching it:

@Benchmark
public void throwExceptionAndUnwindStackTrace(Blackhole blackhole) {
    for (int i = 0; i < LIMIT; i++) {
        try {
            throw new Exception();
        } catch (Exception e) {
            blackhole.consume(e.getStackTrace());
        }
    }
}

Here's the outcome:

Benchmark                                                 Mode  Cnt    Score   Error  Units
ExceptionBenchmark.createExceptionWithoutThrowingIt       avgt   10   16.605 ± 0.988  ms/op
ExceptionBenchmark.doNotThrowException                    avgt   10    0.047 ± 0.006  ms/op
ExceptionBenchmark.throwAndCatchException                 avgt   10   16.449 ± 0.304  ms/op
ExceptionBenchmark.throwExceptionAndUnwindStackTrace      avgt   10  326.560 ± 4.991  ms/op
ExceptionBenchmark.throwExceptionWithoutAddingStackTrace  avgt   10    1.185 ± 0.015  ms/op

Just by unwinding the stack trace, we see a whopping increase of some 20 times in the execution duration. Put another way, the performance is much worse if we extract the stack trace from an exception in addition to throwing it.

4. Conclusion

In this tutorial, we analyzed the performance effects of exceptions. Specifically, it found out the performance cost is mostly in the addition of the stack trace to the exception. If this stack trace is unwound afterward, the overhead becomes much larger.

Since throwing and handling exceptions is expensive, we shouldn't use it for normal program flows. Instead, as its name implies, exceptions should only be used for exceptional cases.

The complete source code can be found over on GitHub.

Java bottom

I just announced the new Learn Spring course, focused on the fundamentals of Spring 5 and Spring Boot 2:

>> CHECK OUT THE COURSE
Comments are closed on this article!