On 07/03/2026 17:48, Eirik Bjørsnøs wrote:
Hi!

I'm looking into startup performance using Spring PetClinic as an example.
The technique I'm exploring is to preload classes in parallel before
running the main method to start the app. I observed that around 50 % of
startup time is spent in URLClassLoader::findClass so it would make sense
to run this work in parallel.

The process is as follows:

1: Create a URLClassLoader with 96 the Spring PetClinic JAR files
2: In parallel (using Executors::newFixedThreadPool), call
ClassLoader::loadClass(name) for 12163 class names known from a previous run
3: Run the application class main method as normal to start Spring PetClinic

With URLClassLoader advertising itself as "parallel capable", this
preloading is expected to work and indeed it does!

Without step 2 above, I observe startups like this:

  *Started PetClinicStarter in 7.307 seconds (process running for 7.729)*

with step 2 I observe startups like this:

*Started PetClinicStarter in 4.976 seconds (process running for 5.607)*

However, the observation I wanted to share here is that my first prototype
used Executors.newVirtualThreadPerTaskExecutor() instead, but that caused a
deadlock.

Is this difference in behavior expected? Is my preloading technique sound?

Virtual threads are pinned to their carrier when there are native frames on the continuation stack. If a virtual blocks (on monitorenter in this case) with native frames on the stack then there is one less platform thread available to the scheduler to carry other virtual threads. In your thread dump then it looks like 8 virtual threads are blocked with native frames (VM frames in this case) on the stack and there are no scheduler threads available for other virtual threads to continue.

The thread dump (with jstack or jcmd Thread.print) doesn't show all virtual threads. It only shows the mounted virtual threads. The alternative thread dump, with jcmd Thread.dump_to_file will show all the virtual threads and you'll find that there are unmounted virtual thread owning the monitors that the mounted virtual threads are waiting to enter. If you using jcmd Thread.vthread_scheduler then it will likely reveal that there are virtual threads scheduled to continue but all scheduler threads are in use.

The summary is that it is currently a limitation, or quality of implementation issue, that blocking when class loading (esp. with custom class loaders) and executing class initializers are unable to release the underlying carrier to do other work. Mostly a startup hazard and made worse where there is a "storm" of virtual threads triggering class loading and initialization.

-Alan

Reply via email to