To create and start a thread in Java, we put the work in a Runnable, wrap it in a Thread object and call start(), which asks the JVM to run that work on a new thread of execution. Since Java 21, the Thread.ofPlatform() and Thread.ofVirtual() builders do the same with less code, and an ExecutorService creates and reuses the threads for us.
We start a new thread whenever a slow job must not hold up the current one, for example resizing an uploaded photo while the web request returns, or sending a confirmation email after an order is saved.
The following example starts one thread with a name, waits for it, and reads which thread ran the task.
AtomicReference<String> ranOn = new AtomicReference<>();
Runnable task = () -> ranOn.set(Thread.currentThread().getName());
Thread worker = new Thread(task, "thumbnail-1");
worker.start(); // run() executes on the new thread
worker.join(); // the caller waits until it ends
String name = ranOn.get(); // "thumbnail-1"
Thread virtual = Thread.ofVirtual().start(task); // Java 21+ virtual thread
virtual.join();
Notice that the task itself knows nothing about threads, so the same Runnable runs on a platform thread or a virtual thread without a change. We cover what start() does, five ways to create threads, custom names and settings with a ThreadFactory, thread priority, and a background thumbnail job that puts it all together.
1. What start() Does and Why run() Is Not Enough
A Thread object is a plain Java object until we call start(). The method start() registers the thread with the JVM, which creates the underlying thread (an operating system thread for a platform thread, or a JVM-scheduled task for a virtual thread) and calls run() on it. The calling thread does not wait and continues with its next statement.

Calling run() ourselves is a common mistake, because it compiles and runs the task. The difference is which thread runs it. A direct run() call is an ordinary method call on the current thread, so nothing runs in parallel.
AtomicReference<String> caller = new AtomicReference<>();
Thread worker = new Thread(() -> caller.set(Thread.currentThread().getName()), "worker");
worker.run(); // ordinary call on the current thread
String afterRun = caller.get(); // "main"
worker.start();
worker.join();
String afterStart = caller.get(); // "worker"
worker.start(); // IllegalThreadStateException
A thread can be started only once. The second start() call throws IllegalThreadStateException, even after the first run has finished, so a job that must run again needs a new Thread object or, better, an executor. The states a thread goes through between start() and the end of run() are covered in the thread life cycle.
2. Five Ways to Create a Thread in Java
All five options produce a Thread that runs our code. They differ in how much configuration they need, whether the thread is a platform or a virtual thread, and whether we manage its lifetime ourselves.
2.1. Passing a Runnable to the Thread Constructor
The Runnable interface has a single method, void run(), so a lambda expression or a method reference is enough to describe the task. We pass it to one of the Thread constructors, optionally with a name.
static String thumbnailName(String photo) {
return photo.replace(".jpg", "-200px.jpg");
}
AtomicReference<String> result = new AtomicReference<>();
Thread thread = new Thread(() -> result.set(thumbnailName("beach.jpg")), "resize-beach");
thread.start();
thread.join();
String thumbnail = result.get(); // "beach-200px.jpg"
A named class that implements Runnable works the same way and is the better choice when the task has fields or is reused in several places. This separation of the task from the thread is the main argument in Runnable vs Thread.
2.2. Extending the Thread Class
We can also subclass Thread and override run(). The class itself becomes the thread, so it cannot extend any other class, and each instance can be started only once.
class ThumbnailThread extends Thread {
private final String photo;
private volatile String thumbnail;
ThumbnailThread(String photo) {
super("thumb-" + photo);
this.photo = photo;
}
@Override
public void run() {
thumbnail = photo.replace(".jpg", "-200px.jpg");
}
String thumbnail() {
return thumbnail;
}
}
ThumbnailThread job = new ThumbnailThread("forest.jpg");
job.start();
job.join();
String done = job.thumbnail(); // "forest-200px.jpg"
String jobName = job.getName(); // "thumb-forest.jpg"
Subclassing still appears in older code and in tutorials, but it mixes the task with the threading mechanism. Prefer a Runnable unless the class needs to change how the thread itself behaves.
2.3. Building Platform Threads With Thread.ofPlatform()
Java 21 added the Thread.Builder API with JEP 444. The method Thread.ofPlatform() returns a builder for classic operating system threads, and we set the name, daemon flag, priority or stack size in one chain instead of calling setters on each thread.
Thread.Builder.OfPlatform builder = Thread.ofPlatform()
.name("thumbnail-", 1)
.daemon(false)
.priority(Thread.NORM_PRIORITY);
Thread first = builder.start(() -> thumbnailName("city.jpg"));
Thread second = builder.unstarted(() -> thumbnailName("lake.jpg"));
first.join();
String firstName = first.getName(); // "thumbnail-1"
String secondName = second.getName(); // "thumbnail-2"
Thread.State waiting = second.getState(); // NEW
The call name(“thumbnail-“, 1) sets a prefix and a start number, and the builder increments the number for each thread it creates. The method start() creates and starts the thread in one step, whereas unstarted() returns a thread in the NEW state that we start later.
2.4. Starting Virtual Threads With Thread.ofVirtual()
A virtual thread is a thread that the JVM schedules on a small pool of platform threads. Blocking calls such as JDBC queries or HTTP requests park the virtual thread and free its platform thread, so an app can run many thousands of blocking tasks at once. Virtual threads are final since Java 21, and since Java 24 (JEP 491) a synchronized block no longer pins them to their platform thread.
Thread named = Thread.ofVirtual().name("upload-", 1).start(() -> thumbnailName("park.jpg"));
Thread quick = Thread.startVirtualThread(() -> thumbnailName("road.jpg"));
named.join();
quick.join();
boolean isVirtual = named.isVirtual(); // true
String nameOfNamed = named.getName(); // "upload-1"
String nameOfQuick = quick.getName(); // "" (no name by default)
boolean daemon = quick.isDaemon(); // true, always
Virtual threads are cheap, so we create one per task and never pool them. They are always daemon threads, and their priority is fixed, which matters in section 5.
2.5. Submitting Tasks to an ExecutorService
In application code, we rarely create Thread objects one by one. An ExecutorService accepts tasks and decides which thread runs each one. A fixed pool reuses a set number of platform threads, which suits CPU-heavy work, and Executors.newVirtualThreadPerTaskExecutor() starts a new virtual thread for every task, which suits blocking I/O.
Future<String> resized;
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
resized = pool.submit(() -> thumbnailName("snow.jpg"));
} // close() waits for submitted tasks
String fromPool = resized.resultNow(); // "snow-200px.jpg"
CompletableFuture<String> async = CompletableFuture.supplyAsync(() -> thumbnailName("dune.jpg"));
String fromAsync = async.join(); // "dune-200px.jpg"
The close() call at the end of the try-with-resources block waits for the submitted tasks to finish, because ExecutorService has been AutoCloseable since Java 19. The block therefore replaces the older shutdown() and awaitTermination() pair described in shutting down an ExecutorService. For a task that returns a value, the executor also accepts a Callable, and for chained async steps we use CompletableFuture. Delayed and repeating jobs go to a ScheduledExecutorService.
3. Choosing a Way to Start Threads
For a quick test or a single long-running background thread, the constructor or a builder is fine. For anything that runs many tasks, an executor is the better default, because it limits or reuses threads, reports failures through Future and shuts down cleanly.
| Approach | Thread type | Best for | Main drawback |
|---|---|---|---|
| new Thread(runnable) | Platform | Learning, one-off background threads | No reuse, failures only reach the uncaught exception handler |
| extends Thread | Platform | Legacy code | Blocks inheritance, mixes task and thread |
| Thread.ofPlatform() | Platform | Configured long-lived threads (name, daemon, priority) | Still one thread per call |
| Thread.ofVirtual() | Virtual | Many blocking tasks, one thread per task | Not a fit for long CPU-bound loops |
| ExecutorService | Either | Production code with many tasks | Must be closed, pool size needs tuning |
The concurrency overview at Java concurrency groups the executor, lock and collection tutorials by task, once a single thread is not enough.
4. Customizing Threads With a ThreadFactory
A ThreadFactory is an interface with one method, Thread newThread(Runnable r). Executors call it every time they need a new thread, so a custom factory is the one place where we set names, the daemon flag, priority and an uncaught exception handler for every thread of a pool. It is an application of the factory design pattern.
Say an invoicing app renders PDF files in a pool of four threads. When a thread dump shows pool-3-thread-2 stuck in a font library, nobody knows which feature owns it. With a factory that names the threads invoice-pdf-1 to invoice-pdf-4, the thread dump and every log line point at the PDF renderer.
class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger counter = new AtomicInteger(1);
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable task) {
Thread thread = new Thread(task, prefix + counter.getAndIncrement());
thread.setDaemon(false);
thread.setUncaughtExceptionHandler(
(t, e) -> System.err.println(t.getName() + " failed: " + e));
return thread;
}
}
ThreadFactory factory = new NamedThreadFactory("invoice-pdf-");
String firstThread = factory.newThread(() -> {}).getName(); // "invoice-pdf-1"
String secondThread = factory.newThread(() -> {}).getName(); // "invoice-pdf-2"
Future<String> job;
try (ExecutorService pool = Executors.newFixedThreadPool(4, new NamedThreadFactory("report-"))) {
job = pool.submit(() -> Thread.currentThread().getName());
}
String poolThread = job.resultNow(); // "report-1"
The AtomicInteger counter keeps the names unique when several threads ask the factory for threads at the same time. Since Java 21, a builder can also produce a factory, which saves the class for simple cases.
ThreadFactory platformFactory = Thread.ofPlatform().name("export-", 1).daemon(true).factory();
ThreadFactory virtualFactory = Thread.ofVirtual().name("fetch-", 1).factory();
String exportName = platformFactory.newThread(() -> {}).getName(); // "export-1"
String fetchName = virtualFactory.newThread(() -> {}).getName(); // "fetch-1"
ExecutorService namedVirtual = Executors.newThreadPerTaskExecutor(virtualFactory);
namedVirtual.close();
Without a custom factory, executors use Executors.defaultThreadFactory(), which creates non-daemon threads with normal priority and names like pool-1-thread-1. More on default and custom names is in setting and getting thread names.
5. Thread Priority and Why It Is Only a Hint
Every platform thread has a priority between Thread.MIN_PRIORITY (1) and Thread.MAX_PRIORITY (10), and Thread.NORM_PRIORITY (5) is the priority of the main thread. A new thread inherits the priority of the thread that creates it, and setPriority() changes it before or after start().
Thread report = new Thread(() -> {});
int inherited = report.getPriority(); // 5, same as the creating thread
report.setPriority(Thread.MIN_PRIORITY);
int lowered = report.getPriority(); // 1
Thread virtual = Thread.ofVirtual().unstarted(() -> {});
virtual.setPriority(Thread.MAX_PRIORITY);
int fixed = virtual.getPriority(); // 5, virtual threads ignore it
report.setPriority(11); // IllegalArgumentException
A thread priority is only a hint that the operating system scheduler may ignore, so a program must never depend on it for correctness or ordering. On Linux, HotSpot ignores Java priorities by default, and on other systems the effect varies. A value outside 1 to 10 throws IllegalArgumentException, and a value above the maximum priority of the thread group is lowered to that maximum.
If one kind of work must not slow down another, we give each kind its own executor with a fitting size, rather than raising priorities. A background reindex job, for example, gets a single-thread executor, so it can never take more than one core from the request threads.
6. Generating Thumbnails in the Background
A photo-sharing app accepts uploads and needs a small thumbnail for each photo. The upload request should return as soon as the files are stored, and the thumbnails are produced in the background. Each job spends much of its time reading the photo from storage and writing the thumbnail back, so virtual threads fit well, and a named factory keeps the logs readable. For heavy CPU-bound image processing, a fixed pool sized to the number of cores is the better choice.
List<String> photos = List.of("beach.jpg", "forest.jpg", "city.jpg");
List<Future<String>> jobs = new ArrayList<>();
try (ExecutorService executor = Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("thumb-", 1).factory())) {
for (String photo : photos) {
jobs.add(executor.submit(() -> thumbnailName(photo)));
}
}
List<String> thumbnails = jobs.stream().map(Future::resultNow).toList(); // [beach-200px.jpg, forest-200px.jpg, city-200px.jpg]
Each photo runs on its own virtual thread named thumb-1, thumb-2 and so on, while the results come back in upload order because we keep the futures in a list. If a resize fails, Future.resultNow() throws IllegalStateException, so production code checks Future.state() or calls get() and handles the ExecutionException.
7. Creating Threads FAQs
Starting threads looks simple until a thread refuses to start twice, runs on the wrong thread or keeps the JVM alive, and those cases drive most searches.
7.1. What is the difference between start() and run() in Java?
The method start() creates a new thread and calls run() on it, whereas calling run() ourselves executes the task on the current thread like any other method. Only start() gives us concurrency, as the example in section 1 shows.
7.2. Can we start a thread twice?
No. A second start() call throws IllegalThreadStateException, even when the first run has ended. To run the same task again, we create a new Thread or submit the task to an executor, which can run it as often as needed.
7.3. Which is the best way to create a thread in Java?
For application code, submit tasks to an ExecutorService. Use Executors.newVirtualThreadPerTaskExecutor() for blocking I/O and a fixed pool for CPU-bound work. Create threads by hand only for a few long-lived background threads, and use Thread.ofPlatform() for them so the name and daemon flag are set in one place.
7.4. How many threads can a Java program create?
There is no fixed number in Java. Each platform thread reserves a stack (often 1 MB by default on 64-bit systems) and an operating system thread, so the limit depends on memory, stack size and OS settings such as the maximum number of processes per user. Virtual threads keep their stacks on the heap and grow them only as needed, so an app can run millions of them.
7.5. Does the JVM wait for threads to finish before exiting?
Yes, for non-daemon threads. The JVM exits only when every non-daemon thread has finished, whereas daemon threads, including all virtual threads, are stopped when the last non-daemon thread ends. To wait for a specific thread in our own code, we call join().
8. Conclusion
A thread starts when we call start() on a Thread object, and the task goes in a Runnable so it stays independent of the thread that runs it. The constructor and subclassing still work, but Java 21 builders set the name, daemon flag and priority in one chain, and Thread.ofVirtual() gives us cheap threads for blocking work.
In real applications, an ExecutorService with a named ThreadFactory is the safer default, and try-with-resources closes it and waits for the tasks. Priorities are hints at best and do nothing for virtual threads, so we separate workloads with executors instead.
9. References
- Thread (Java SE 25 API)
- Thread.Builder (Java SE 25 API)
- ThreadFactory (Java SE 25 API)
- Executors (Java SE 25 API)
- JEP 444, Virtual Threads
- JEP 491, Synchronize Virtual Threads without Pinning
Happy Learning !!