Threads & Virtual Threads
So far every program has done one thing at a time. A real server does not: while one customer's payment waits for the bank, hundreds of other customers are browsing, searching and booking. A thread is an independent path of execution inside a program. Several threads can run at the same time, which is called concurrency.
Concurrency is powerful and also one of the trickiest parts of programming, because threads can interfere with each other. In this lesson you will start threads, see a real race condition and fix it, use thread pools, and then meet virtual threads, the Java 21 feature that lets one program handle thousands of simultaneous tasks easily.
Starting threads
Every Java program starts with one thread, called main. To start another, create a Thread with a Runnable (a functional interface with a run() method, so a lambda works) and call start(). join() makes the current thread wait until another thread finishes.
public class Basics {
static void download(String file, int seconds) {
String who = Thread.currentThread().getName();
System.out.println(who + " started " + file);
try {
Thread.sleep(seconds * 100L); // pretend to download
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(who + " finished " + file);
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> download("syllabus.pdf", 3), "worker-1");
Thread t2 = new Thread(() -> download("timetable.pdf", 1), "worker-2");
t1.start();
t2.start();
t1.join(); // wait for both to finish
t2.join();
System.out.println("Both downloads done");
}
}One possible output (the order of the first two lines can change from run to run):
worker-2 started timetable.pdf
worker-1 started syllabus.pdf
worker-2 finished timetable.pdf
worker-1 finished syllabus.pdf
Both downloads doneBoth downloads run at the same time, so the shorter one finishes first even though it started second. You cannot predict exactly how threads interleave; the operating system decides. That is why the output order may differ on your computer.
Call start(), not run(). Calling run() directly just runs the code in the current thread, with no concurrency at all. Thread.sleep can throw the checked InterruptedException, which signals that another thread asked this one to stop; restoring the flag with interrupt() is the standard response.
Race conditions and how to fix them
When two threads change the same variable, updates can be lost. count++ looks like one step but is really three: read the value, add one, write it back. If two threads read the same old value at the same moment, one increment disappears. This bug is called a race condition.
import java.util.concurrent.atomic.AtomicInteger;
public class Race {
static int unsafeCount = 0;
static final AtomicInteger safeCount = new AtomicInteger();
static int lockedCount = 0;
static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable sellTickets = () -> {
for (int i = 0; i < 100_000; i++) {
unsafeCount++; // read, add, write: not atomic!
safeCount.incrementAndGet(); // atomic
synchronized (lock) { // one thread at a time
lockedCount++;
}
}
};
Thread counterA = new Thread(sellTickets);
Thread counterB = new Thread(sellTickets);
counterA.start();
counterB.start();
counterA.join();
counterB.join();
System.out.println("Unsafe: " + unsafeCount);
System.out.println("Atomic: " + safeCount.get());
System.out.println("Synchronized: " + lockedCount);
}
}Three runs on the same laptop printed 198767, 199900 and 193668 for the unsafe counter. For example:
Unsafe: 193668
Atomic: 200000
Synchronized: 200000Two ticket counters each sold 100,000 tickets, so the answer must be 200,000. The plain int lost thousands of sales, and a different number each time, which makes such bugs very hard to find. Two fixes work:
- Atomic classes such as
AtomicIntegerandAtomicLong(injava.util.concurrent.atomic) perform the read-add-write as one indivisible step. - A
synchronizedblock lets only one thread at a time run the code inside, using an object as a lock. Methods can be markedsynchronizedtoo.
The best fix is often to avoid sharing changeable data at all: give each task its own data and combine the results at the end. For shared maps, use ConcurrentHashMap instead of HashMap.
Thread pools with ExecutorService
Creating a platform thread by hand for every task is expensive and hard to manage. An ExecutorService keeps a pool of threads and runs the tasks you submit. Submitting a Callable (a task that returns a value) gives you a Future, a placeholder for the result; future.get() waits for it.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Pool {
static int countPrimes(int from, int to) {
int count = 0;
for (int n = Math.max(from, 2); n <= to; n++) {
boolean prime = true;
for (int d = 2; d * d <= n; d++) {
if (n % d == 0) { prime = false; break; }
}
if (prime) count++;
}
return count;
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
List<Future<Integer>> parts = new ArrayList<>();
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
for (int i = 0; i < 4; i++) {
int from = i * 250_000 + 1;
int to = (i + 1) * 250_000;
parts.add(pool.submit(() -> countPrimes(from, to)));
}
int total = 0;
for (Future<Integer> f : parts) {
total += f.get(); // waits for that task's result
}
System.out.println("Primes up to 1,000,000: " + total);
}
}
}Primes up to 1,000,000: 78498
The work is split into four ranges that run in parallel on four threads, each with its own counter, so nothing is shared and no locking is needed. Since Java 19, ExecutorService can be used in try-with-resources: close() waits for submitted tasks and shuts the pool down. In older code you will see explicit shutdown() and awaitTermination(...) calls instead.
Virtual threads
A normal Java thread, now called a platform thread, is a wrapper around an operating-system thread. Each one reserves a lot of memory, so a server can only have a few thousand. That is a problem for web applications, where most of a request's time is spent waiting: for the database, for a payment gateway, for another web service.
Virtual threads, final since Java 21 (JEP 444), are lightweight threads managed by the JVM. When a virtual thread waits, for example during Thread.sleep or a network call, the JVM parks it and uses the underlying operating-system thread for other work. You can create millions of them.
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class Virtual {
public static void main(String[] args) {
AtomicInteger done = new AtomicInteger();
long start = System.currentTimeMillis();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // pretend to call a slow web service
done.incrementAndGet();
return null;
});
}
} // close() waits for all tasks
long ms = System.currentTimeMillis() - start;
System.out.println("Requests finished: " + done.get());
System.out.println("Took less than 5 seconds? " + (ms < 5000));
Thread vt = Thread.ofVirtual().name("bill-printer").start(
() -> System.out.println("Hello from " + Thread.currentThread().getName()
+ ", virtual? " + Thread.currentThread().isVirtual()));
try {
vt.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}Requests finished: 10000 Took less than 5 seconds? true Hello from bill-printer, virtual? true
Ten thousand tasks that each wait one second finish in about one second in total, because every task gets its own virtual thread. With a fixed pool of 100 platform threads, the same work would take about 100 seconds. The code is ordinary, blocking, easy-to-read Java; no callbacks or special async style is needed.
Guidelines for virtual threads:
- Use them for tasks that spend most of their time waiting (I/O): web requests, database queries, file and network access.
- They do not make CPU-heavy work faster; for number crunching, use a pool sized to your CPU cores, as in the primes example.
- Do not pool them. Create a new virtual thread per task;
newVirtualThreadPerTaskExecutor()does exactly that. - Race conditions still apply: shared data needs the same care as before.
Common mistakes
- Calling
run()instead ofstart(). No new thread is created. - Sharing a plain variable between threads. Use atomic classes,
synchronized, concurrent collections, or better, no shared changeable state. - Ignoring
InterruptedException. Restore the flag withThread.currentThread().interrupt()or pass the exception on. - Forgetting to shut down executors. Use try-with-resources so the program does not hang at the end.
- Using virtual threads for CPU-bound work. They help with waiting, not with calculation.
Key points
- A thread is an independent path of execution;
start()launches it andjoin()waits for it. - Thread interleaving is unpredictable; unsynchronised shared data causes race conditions.
AtomicInteger,synchronizedandConcurrentHashMapmake shared updates safe.ExecutorServicemanages a pool of threads;submitreturns aFuturewith the result.- Virtual threads (Java 21+) are cheap; use
Executors.newVirtualThreadPerTaskExecutor()for many waiting tasks. - Since Java 24,
synchronizedno longer pins virtual threads in most cases.
Exercise
A college website must fetch results for 5 students from a slow exam server. Simulate each fetch with a method String fetchResult(String roll) that sleeps 500 ms and returns roll + ": PASS". Use a virtual-thread executor to fetch all five at the same time, collect the Futures in a list, print each result in the original order, and print whether the total time was under 1.5 seconds.
Show solution
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Results {
static String fetchResult(String roll) throws InterruptedException {
Thread.sleep(500); // slow exam server
return roll + ": PASS";
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
List<String> rolls = List.of("R101", "R102", "R103", "R104", "R105");
long start = System.currentTimeMillis();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (String roll : rolls) {
futures.add(executor.submit(() -> fetchResult(roll)));
}
for (Future<String> f : futures) {
System.out.println(f.get());
}
}
long ms = System.currentTimeMillis() - start;
System.out.println("Under 1.5 seconds? " + (ms < 1500));
}
}R101: PASS R102: PASS R103: PASS R104: PASS R105: PASS Under 1.5 seconds? true
All five fetches wait at the same time, so the total is about half a second instead of two and a half. Reading the futures in list order keeps the output in roll-number order, even if the tasks finish in a different order.
Quick Quiz
1. What is the difference between thread.start() and thread.run()?
Only start() creates a new thread of execution.
2. Two threads each run count++ 1,000 times on a shared int. What can the final value be?
count++ is not atomic, so concurrent updates can overwrite each other.
3. For which work do virtual threads help most?
Virtual threads make waiting cheap; they do not speed up calculation.
4. What does future.get() do?
A Future is a placeholder; get() blocks until the result is ready.
Learn Java with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
