SSerguey Asael Shinder
Java coding notes: the JVM, and writing software that lasts

Serguey Asael Shinder: A thread with no name costs you the next production stack dump

· by Serguey Asael Shinder / Serguey Shinder

Take a thread dump of almost any Java service and count how many lines look like this:

"pool-2-thread-1" #34 prio=5 waiting on condition
"pool-2-thread-2" #35 prio=5 waiting on condition
"pool-3-thread-7" #61 prio=5 runnable

Which pool is pool-3? Nobody knows. The name is assigned by Executors.defaultThreadFactory(), counting up from one across the whole JVM in creation order, and creation order depends on which beans initialised first. It is stable enough to look meaningful and arbitrary enough to be useless.

The fix is one argument

ExecutorService ingest = Executors.newFixedThreadPool(
        8, Thread.ofPlatform().name("ingest-", 0).factory());

Now the dump reads ingest-0ingest-7, and the same strings appear in your logs if the pattern includes %thread, in the profiler, in jcmd Thread.print, and in the flight recorder. The Thread.ofPlatform() builder has been there since Java 21; before that, three lines of ThreadFactory do the same job.

Virtual threads take a name too, and it matters more there, because there are a great many of them:

Thread.ofVirtual().name("req-", 0).start(task);
Serguey Asael Shinder: A thread with no name costs you the next production stack dump
A thread with no name costs you the next production stack dump — Serguey Asael Shinder

Where it pays

In a thread dump you did not plan for. The dump you take during an incident is the one you get — there is no second chance to add instrumentation. If half the threads are pool-N-thread-M, deciding whether the stuck ones belong to the HTTP layer or the batch importer is guesswork done under time pressure.

In lock contention. "ingest-3" waiting to lock <0x...> held by "report-0" is a sentence about your architecture. The same line with pool names is a sentence about the JVM.

In metrics. Any per-thread metric keyed on the name — CPU time, allocation, blocked time — becomes groupable by subsystem for free, because the prefix is the subsystem.

Two details that bite

Name the pool at the factory, not inside the task. Setting Thread.currentThread() .setName(...) at the top of a task works and then leaks: the pool reuses the thread, the name from the last task survives, and the dump shows one job while a different one runs. If you do rename per task, rename back in a finally.

Keep the prefix short and greppable. ingest- is better than com.example.service.ingest.executor-. The name shows up in fixed-width columns in half the tools that display it, and a long prefix means the digit that distinguishes two threads is the part that gets cut off.

The whole change is one argument per executor, applied once, in the place where the pool is built. It costs nothing at runtime and it is worth an hour of the next incident.