Java Thread Creation: Traditional Threads vs ExecutorService vs Virtual Threads
Java Thread Creation Has Changed (And Most Developers Haven’t Noticed)
For years, we’ve been taught:
👉 Extend Thread
👉 Implement Runnable
👉 Use ExecutorService
But now?
Java has introduced Virtual Threads (Project Loom).
And this changes everything about how we think about concurrency.
Let’s not just learn syntax.
Let’s trace execution like we’re debugging a production system.
The Base: One Runnable, Multiple Execution Strategies
We are NOT going to create separate logic per thread type.
Instead:
👉 One task (BatchJob)
👉 Multiple execution strategies
This is how real systems are designed.
Base Classes (Given)
package org.code2java;
public class BatchJob implements Runnable {
private final String jobName;
public BatchJob(String jobName) {
this.jobName = jobName;
}
@Override
public void run() {
System.out.println("Hello from a batch job: " + jobName);
InitiateJob job = new InitiateJob();
job.executeJob(jobName);
}
}package org.code2java;
import java.util.HashMap;
import java.util.Map;
public class InitiateJob {
public static Map<String, String> jobParameters = new HashMap<>();
public void executeJob(String jobName) {
System.out.println("Initiating batch job : " + jobName);
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
jobParameters.put(jobName, "Completed");
System.out.println("Batch job " + jobName + " completed successfully.");
}
public String getJobStatus(String jobName) {
return jobParameters.get(jobName);
}
}
Now the Real Question
👉 Same Runnable
👉 Different execution models
What actually changes?
Let’s explore.
1. Traditional Thread (Classic OS Thread)
package org.code2java;
public class TraditionalThread {
public static void main(String[] args) {
Runnable job1 = new BatchJob("Job-1");
Runnable job2 = new BatchJob("Job-2");
Thread t1 = new Thread(job1);
Thread t2 = new Thread(job2);
t1.start();
t2.start();
}
}What happens internally?
👉 Each Thread = 1 OS thread
👉 Heavy resource allocation
👉 Limited scalability
Problem ⚠️
👉 1000 threads = 1000 OS threads
👉 Memory + context switching explosion
2. ExecutorService (Thread Pool Model)
package org.code2java;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MyExecutorServiceThread {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(new BatchJob("Job-1"));
executor.submit(new BatchJob("Job-2"));
executor.submit(new BatchJob("Job-3"));
executor.shutdown();
}
}Internal Working
👉 Tasks → Queue
👉 Fixed threads → reuse
👉 Controlled concurrency
Why this works better
👉 Thread reuse
👉 Reduced overhead
👉 Better control
3. Virtual Threads (Project Loom — GAME CHANGER)
Code (Java 21+)
package org.code2java;
public class MyVirtualThread {
public static void main(String[] args) {
Runnable job1 = new BatchJob("Job-1");
Runnable job2 = new BatchJob("Job-2");
Thread vt1 = Thread.startVirtualThread(job1);
Thread vt2 = Thread.startVirtualThread(job2);
try {
vt1.join();
vt2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}What’s happening internally?
👉 Virtual threads are NOT OS threads
👉 Managed by JVM
👉 Mapped to few carrier threads
Why this is revolutionary
👉 Create millions of threads
👉 No thread pool needed
👉 Blocking calls are cheap
Important Detail (Deep Insight)
When Thread.sleep() happens:
👉 Traditional → OS thread blocked
👉 Virtual → thread parked, carrier reused
This is HUGE.
This is Good, but this is even Better – Virtual Threads with Executor (IMPORTANT PATTERN)
package org.code2java;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class VirtualThreadsExample {
static void main() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> futures = List.of(
executor.submit(() -> new BatchJob("Job-1").run()),
executor.submit(() -> new BatchJob("Job-2").run()),
executor.submit(() -> new BatchJob("Job-3").run())
);
// Block and surface any exceptions
for (Future<?> future : futures) {
future.get(); // throws ExecutionException if task failed
}
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException(e);
}
}
}Why This Pattern Matters (Deep Insight)
This is NOT just syntax sugar.
Let’s trace what happens:
- You submit task
- Executor creates new virtual thread per task
- Virtual thread runs Runnable
- If blocking → thread is parked
- Carrier thread reused
What Can Go Wrong ⚠️
1. Shared Mutable State
jobParameters.put(jobName, "Completed");👉 Not thread-safe
👉 Race conditions possible
Fix:
👉 Use ConcurrentHashMap
2. Blocking inside Virtual Threads (Safe but misunderstood)
👉 Blocking is fine
👉 But CPU-heavy tasks still expensive
3. Forgetting join()
👉 Main thread exits early
When NOT to Use 🚫
Traditional Threads
👉 High concurrency systems
ExecutorService
👉 When you need millions of tasks (Virtual threads better)
Virtual Threads
👉 CPU-intensive parallel work (use ForkJoinPool instead)
Real-World Use Case
Imagine:
👉 10,000 API calls
👉 Each waits on DB
Traditional:
❌ 10,000 threads → crash
ExecutorService:
⚠️ Limited by pool
Virtual Threads:
✅ 10,000 virtual threads → smooth
Interview Questions
- What are Virtual Threads in Java?
- Difference between platform thread and virtual thread?
- How does ExecutorService differ from Virtual Threads?
- Why is Thread.sleep cheap in virtual threads?
- When NOT to use virtual threads?
