Speeding up Spring MVC with CompletableFuture

Recently we’re beginning to see a shift towards asynchronous/reactive workloads within the Spring ecosystem, especially since the release of WebFlux and the more recent support for Kotlin co-routines. However, both these require moving towards monos, fluxes, and libraries that support these reactive constructs. Support for reactive paradigms is steadily increasing however many of us are stuck with a legacy code base or libraries that haven’t yet made the switch to the reactive world.

Typically those of us in this situation are using the tried-and-tested Spring MVC. Did you know, that Spring MVC allows you to return a CompletableFuture? New in Java 8, these are like Futures on steroids and allow you to do even more. For example, when using Futures we’d generally do something like:

// from https://www.baeldung.com/java-future

Future<Integer> future = new SquareCalculator().calculate(10);
 
while(!future.isDone()) {
    System.out.println("Calculating...");
    Thread.sleep(300);
}
 
Integer result = future.get();

Note that due to the requirement of using “.get()”, we still need to go into a blocking loop to wait for the future to be done and allow us to return results. It’s more elegant and more performant if we are allowed to define a “callback” that automatically gets executed when the future is done, just like in JavaScript. That’s where CompletableFutures come in. They do allows us to define handlers for when the future is complete, like so:

// from https://www.baeldung.com/java-completablefuture
CompletableFuture<String> completableFuture
  = CompletableFuture.supplyAsync(() -> "Hello");
 
CompletableFuture<String> future = completableFuture
  .thenApply(s -> s + " World");

We first supply the function that we’d like the future to execute, followed by a call to “.thenApply” to specify our callback that gets run when the function is done. So far the code is completely asynchronous.

Spring MVC allows us to return CompletableFuture from our controllers, allowing us to have an entirely asynchronous controller. For example, consider the following simple Spring MVC RestController (written in Kotlin):

package com.example.mvc
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import java.lang.Thread.sleep
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@RestController
class Controller {
val worker = {
sleep(1000)
"Done"
}
private val pool : ExecutorService = Executors.newFixedThreadPool(500)
@GetMapping("/asyncTest")
fun asyncTest() : CompletableFuture<String> {
return CompletableFuture.supplyAsync(worker, pool::execute)
}
@GetMapping("/syncTest")
fun syncTest() : String {
return worker()
}
}

In lines 13-16 we define an “expensive” lambda function called worker which we’ll use to simulate an expensive workload such as a big data call. The typical REST controller handler is in lines 25-28, where we simple create a new instance of worker and return the result.

Now, by default, Spring MVC uses Tomcat, which is configured to use 200 threads. We can improve on this by offloading expensive work to a separate thread pool. We define this thread pool on line 18, and then we use CompletableFutures to submit jobs to this thread pool asynchronously in lines 20-23. Note how in line 22 we use “.supplyAsync” to submit our “expensive” lambda to the thread pool’s “execute” method reference. Nothing else is needed. Let’s stress test the two functions.

We use “ab” (apache benchmark) for this:

ab -n 1000 -c 1000 http://localhost:8080/asyncTest

We’ll specify 1000 requests, all at one go concurrently.

Concurrency Level:      1000
Time taken for tests:   2.254 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      136000 bytes
HTML transferred:       4000 bytes
Requests per second:    443.61 [#/sec] (mean)
Time per request:       2254.229 [ms] (mean)
Time per request:       2.254 [ms] (mean, across all concurrent requests)
Transfer rate:          58.92 [Kbytes/sec] received

And the same for the synchronous function:

Concurrency Level:      1000
Time taken for tests:   5.181 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      136000 bytes
HTML transferred:       4000 bytes
Requests per second:    193.02 [#/sec] (mean)
Time per request:       5180.750 [ms] (mean)
Time per request:       5.181 [ms] (mean, across all concurrent requests)
Transfer rate:          25.64 [Kbytes/sec] received

Visually:

We more than doubled the throughput in a really simple and elegant syntax – that’s the advantage of CompletableFuture