Quarkus with Java Virtual Threads
In this article, you will learn how to integrate Quarkus with Java virtual threads. Currently, virtual threads is one the hottest topics in Java world. It has been introduced in Java 19 as a preview feature. Virtual threads reduce the effort of writing, maintaining, and observing high-throughput concurrent applications. In fact, it is one of the biggest changes that comes to Java in the last few years. Do you want to try how it works? It seems that the Quarkus framework provides an easy way to start with virtual threads. Let’s begin.
There are a lot of articles about Quarkus on my blog. If you don’t have any experience with that framework and you are looking for something to begin you can read, for example, the following article. Also, there are a lot of simple guides to the Quarkus features available on its website here.
Source Code
If you would like to try this exercise yourself, you may always take a look at my source code. In order to do that, you need to clone my GitHub repository. Then you need to go to the person-virtual-service
directory. After that, just follow my instructions.
Prerequisites
Before we start, we need to install several tools on the local machine. First of all, you have to install JDK 19. There are also some other tools I’m using in that exercise:
- Docker – our sample Quarkus app connects to the Postgresql database. We can easily run Postgres on Docker automatically using the Quarkus Dev Services feature
- JProfiler – in order to visualize and observe the thread pool
- k6 – a javascript-based tool for load testing
Use Case
We will build a simple REST application using the Quarkus framework. It will connect to the Postgres database and expose some endpoints for adding data and the basic search operations. Apart from enabling virtual threads support in Quarkus, our main goal is not to block the thread. In the virtual threads nomenclature, this thread is called “carrier thread”. It is the platform thread responsible for executing a virtual thread. It might be blocked by e.g. JDBC client driver. In order to avoid it, we should use non-blocking clients.
Dependencies
We need to include two Quarkus modules into the Maven dependencies. The first of them is Quarkus Resteasy Reactive which provides an implementation of JAX-RS specification and allows us to create reactive REST services. The quarkus-reactive-pg-client
module provides an implementation of a reactive driver for the Postgres database.
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-reactive-pg-client</artifactId>
</dependency>
We should also set JDK 19 as a default compiler. Since virtual threads are available as a preview feature we need to set --enable-preview
as a JVM argument.
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
</goals>
</execution>
</executions>
<configuration>
<source>19</source>
<target>19</target>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
<jvmArgs>--enable-preview --add-opens java.base/java.lang=ALL-UNNAMED</jvmArgs>
</configuration>
</plugin>
I’m also generating some test data for load tests. There is a really useful library for that – Datafaker.
<dependency>
<groupId>net.datafaker</groupId>
<artifactId>datafaker</artifactId>
<version>1.6.0</version>
</dependency>
Enable Virtual Threads for Reactive Services
The Quarkus Resteasy Reactive module works in a non-blocking, event-loop style way. Instead of using the “event loop”, we need to force the execution of an endpoint handler on a new virtual thread. To do that we need to annotate the REST endpoint with the @RunOnVirtualThread
. The rest of the code may be pretty similar to the blocking-style way of building services. Of course, instead of blocking the database driver, we will use its reactive alternative. The implementation is provided inside the PersonRepositoryAsyncAwait
class.
@Path("/persons")
public class PersonResource {
@Inject
PersonRepositoryAsyncAwait personRepository;
@Inject
Logger log;
@POST
@RunOnVirtualThread
public Person addPerson(Person person) {
person = personRepository.save(person);
return person;
}
@GET
@RunOnVirtualThread
public List<Person> getPersons() {
return personRepository.findAll();
}
@GET
@Path("/name/{name}")
@RunOnVirtualThread
public List<Person> getPersonsByName(@PathParam("name") String name) {
return personRepository.findByName(name);
}
@GET
@Path("/age-greater-than/{age}")
@RunOnVirtualThread
public List<Person> getPersonsByName(@PathParam("age") int age) {
return personRepository.findByAgeGreaterThan(age);
}
@GET
@Path("/{id}")
@RunOnVirtualThread
public Person getPersonById(@PathParam("id") Long id) {
log.infof("(%s) getPersonById(%d)", Thread.currentThread(), id);
return personRepository.findById(id);
}
}
Let’s switch to the repository implementation. We use an implementation of the SmallRye Mutiny client for Postgres. The PgPool
client allows us to create and execute SQL queries in a non-blocking way. In order to create a query, we should call the preparedQuery
method. Finally, we need to invoke the executeAndAwait
method to perform the operation asynchronously.
@ApplicationScoped
public class PersonRepositoryAsyncAwait {
@Inject
PgPool pgPool;
@Inject
Logger log;
public Person save(Person person) {
Long id = pgPool
.preparedQuery("INSERT INTO person(name, age, gender) VALUES ($1, $2, $3) RETURNING id")
.executeAndAwait()
.iterator().next().getLong("id");
person.setId(id);
return person;
}
public List<Person> findAll() {
log.info("FindAll()" + Thread.currentThread());
RowSet<Row> rowSet = pgPool
.preparedQuery("SELECT id, name, age, gender FROM person")
.executeAndAwait();
return iterateAndCreate(rowSet);
}
public Person findById(Long id) {
RowSet<Row> rowSet = pgPool
.preparedQuery("SELECT id, name, age, gender FROM person WHERE id = $1")
.executeAndAwait(Tuple.of(id));
List<Person> persons = iterateAndCreate(rowSet);
return persons.size() == 0 ? null : persons.get(0);
}
public List<Person> findByName(String name) {
RowSet<Row> rowSet = pgPool
.preparedQuery("SELECT id, name, age, gender FROM person WHERE id = $1")
.executeAndAwait(Tuple.of(name));
return iterateAndCreate(rowSet);
}
public List<Person> findByAgeGreaterThan(int age) {
RowSet<Row> rowSet = pgPool
.preparedQuery("SELECT id, name, age, gender FROM person WHERE age > $1")
.executeAndAwait(Tuple.of(age));
return iterateAndCreate(rowSet);
}
private List<Person> iterateAndCreate(RowSet<Row> rowSet) {
List<Person> persons = new ArrayList<>();
for (Row row : rowSet) {
persons.add(Person.from(row));
}
return persons;
}
}
Prepare Test Data
Before we run load tests let’s add some test data to the Postgres database. We will use the Datafaker library for generating persons’ names. We will use the same reactive, non-blocking PgPool
client as before. The following part of the code generates and inserts 1000 persons into the database on the Quarkus app startup. It is a part of our repository implementation.
@ApplicationScoped
public class PersonRepositoryAsyncAwait {
// ... methods
@Inject
@ConfigProperty(name = "myapp.schema.create", defaultValue = "true")
boolean schemaCreate;
void config(@Observes StartupEvent ev) {
if (schemaCreate) {
initDb();
}
}
private void initDb() {
List<Tuple> persons = new ArrayList<>(1000);
Faker faker = new Faker();
for (int i = 0; i < 1000; i++) {
String name = faker.name().fullName();
String gender = faker.gender().binaryTypes().toUpperCase();
int age = faker.number().numberBetween(18, 65);
int externalId = faker.number().numberBetween(100000, 999999);
persons.add(Tuple.of(name, age, gender, externalId));
}
pgPool.query("DROP TABLE IF EXISTS person").execute()
.flatMap(r -> pgPool.query("""
create table person (
id serial primary key,
name varchar(255),
gender varchar(255),
age int,
external_id int
)
""").execute())
.flatMap(r -> pgPool
.preparedQuery("insert into person(name, age, gender, external_id) values($1, $2, $3, $4)")
.executeBatch(persons))
.await().indefinitely();
}
}
Load Testing with k6 and JProfiler
I’m using the k6
tool for load testing, but you can use any other popular tool as well. In order to install it on macOS just run the following homebrew command:
$ brew install k6
The k6
tool uses Javascript for creating tests. We need to prepare an input with the test definition. My test generates a number between 1 and 1000 and then places it as a path parameter for the GET /persons/{id}
endpoint. Finally, it checks if the response status is 200
and the body is not empty.
import http from 'k6/http';
import { sleep, check } from 'k6';
export default function () {
let r = Math.floor(Math.random() * 1000) + 1;
const res = http.get(`http://localhost:8080/persons/${r}`);
check(res, {
'is status 200': (res) => res.status === 200,
'body size is > 0': (r) => r.body.length > 0,
});
sleep(1);
}
To simplify running the Quarkus app from your IDE you can annotate the main class with @QuarkusMain
.
@QuarkusMain
public class PersonVirtualApp {
public static void main(String... args) {
Quarkus.run(args);
}
}
Don’t forget to enable Java preview mode in your IDE. Here’s how it looks in IntelliJ.
Once you run the app and then attach JProfiler to the running JVM process you may execute tests with k6
. To do that pass the location of the file with the test definition, and then set the number of concurrent threads (--vus
parameter). Finally set the test duration with the --duration
parameter. I’m running the test four times using a different number of concurrent threads (20, 50, 100, 200).
$ k6 run --vus 20 --duration 90s k6-test.js
Analyze Test Results
Let’s take a look at the fragment of application logs. You can see that there are many virtual threads running by the same “carrier” thread, for example, on the ForkJoinPool-1-worker-9
.
Let’s switch to the JProfiler UI for a moment. Here’s the fragment of a thread pool visualization.
We run the same test four times with a different number of concurrent users (20, 50, 100, 200). Here’s a visualization of total Java threads (non-virtual) running. As you can see there is no significant difference between tests for 20 and 200 users.
During the first test (20 virtual users), we generated ~1.8k requests in 90 seconds.
During the last test (200 virtual users), we generated ~17.8k requests in 90 seconds.
Final Thoughts
One of the most important things I really like in Quarkus is that it is always up-to-date with the latest features. Once, the virtual threads have been released I can refactor my current app and use them instead of the standard platform threads. In this article, I presented how to use virtual threads with the app that connects to the database. If you are interested in other articles about Quarkus you can see the full list here. Enjoy 🙂
6 COMMENTS