Thread Distribution — per-thread bean map (v1.1.0)
WireDoctor records which thread instantiated each bean and reports a per-thread breakdown in the Timing tab. It answers one question honestly: did anything actually leave the main thread?
How it works
WireDoctorBufferingApplicationStartup overrides start(String) and tags every spring.beans.instantiate step with threadName = Thread.currentThread().getName() at step-creation time — the only point where the instantiating thread is still on the stack. Spring already tags that step with beanName, so the two combine without extra bookkeeping.
The analyzer then builds:
beanThreadMap— bean name → set of thread names that instantiated it (a set, so a prototype created on several threads keeps all of them)threadToBeans— thread name → list of beans instantiated on that threadthreadToCount— thread name → bean count
{
"threadDistribution": {
"perThread": {
"main": ["dataSource", "entityManagerFactory", "transactionManager"],
"app-bg-1": ["warmCacheBean"]
},
"counts": { "main": 3, "app-bg-1": 1 }
}
}
Thread names are whatever the JVM and your executor produce — main plus the threadNamePrefix of the bootstrapExecutor you registered. There are no fixed names like task-1 to expect.
In the HTML report
The Timing tab renders a donut chart sized by bean count per thread, next to a table of thread / bean count / share, ordered by count descending. When every bean landed on one thread the card shows a parallel init not active pill instead of implying something is wrong.
The perThread bean lists are present in wiredoctor-report.json but are not yet surfaced in the HTML card — only the counts are. Drilling into “which beans ran on app-bg-1” means reading the JSON for now.
Why main almost always dominates
Spring does not parallelise bean initialisation by default, and no Boot version has. Background initialisation is opt-in per bean and needs two things:
- a bean marked
@Bean(bootstrap = Bean.Bootstrap.BACKGROUND)(Spring Framework 6.2+ / Boot 3.4+), and - a
bootstrapExecutorbean for it to run on
Without both, a 600-bean application legitimately reports 100% on main. That is not a misconfiguration, and no property — @Lazy placement, bean-definition overriding, @Async — changes it. @Async affects method invocation, not instantiation.
Measured on Spring Cloud Config Server (616 beans, Boot 4.1):
main: 610 beans (99.8%)
configserver-bg-1: 1 bean → the one @Bean(bootstrap = BACKGROUND)
What it is good for
- Confirming background bootstrap actually took effect. You marked a slow bean
BACKGROUNDand gave it an executor — did it move offmain, or is it silently still blocking startup? This is the check that answers it, and the one case where the card earns its place. - Spotting the thread a bean unexpectedly landed on when several executors are in play.
- Reading the donut as a coverage number. One slice means your startup is fully serial, so slow-bean timings add up linearly and the critical path is the whole story.
Limitations
- Only beans that went through
spring.beans.instantiateappear.@Lazyand prototype beans never triggered during the run are absent by design. - Distribution only — which beans ran on which threads. Not a swimlane timeline and not blocking inference; those need per-thread start/end timestamps plus a dependency overlay (a v1.2+ research item).
- Requires WireDoctor’s own
ApplicationStartupto be installed. If your application sets a foreignApplicationStartup, WireDoctor respects it, logs a warning, and thethreadDistributionsection is omitted — all other analysis still runs. - The donut’s centre count sums the per-thread counts, so a prototype instantiated on two threads is counted once per thread.