Threads let a program do several things at once. They also introduce a class of bug that is intermittent, unreproducible and dependent on timing — which is why the most useful advice about concurrency is to use the highest-level tool that solves your problem.
Starting a thread
class Demo {
void run() throws InterruptedException {
Thread thread = new Thread(() -> System.out.println("on another thread"));
thread.start(); // start(), never run()
thread.join(); // wait for it to finish
// Java 21: a virtual thread, and the same API
Thread virtual = Thread.ofVirtual().start(() -> System.out.println("virtual"));
virtual.join();
}
}
start() creates a thread and runs the task on it. run() just calls the
method on the current thread — a mistake that produces code which looks threaded, works perfectly,
and is entirely sequential.
You will rarely do that
Managing threads by hand is the assembly language of concurrency. An
ExecutorService handles creation, reuse and shutdown:
class Demo {
void run() throws Exception {
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
Future<Integer> future = pool.submit(() -> 21 * 2);
System.out.println(future.get()); // 42, blocking until ready
List<Callable<String>> tasks = List.of(
() -> "one", () -> "two", () -> "three");
for (Future<String> f : pool.invokeAll(tasks)) {
System.out.println(f.get());
}
} // AutoCloseable since Java 19 — waits for tasks, then shuts down
}
}
Sizing the pool: roughly the number of cores for CPU-bound work, and much larger for I/O-bound work — or, on Java 21, virtual threads, which remove the question entirely.
The actual problem: shared mutable state
class Counter {
private int count = 0;
void increment() {
count++; // NOT atomic: read, add, write — three steps
}
int get() { return count; }
}
class Demo {
void run() throws Exception {
Counter counter = new Counter();
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
for (int i = 0; i < 10_000; i++) {
pool.submit(counter::increment);
}
}
System.out.println(counter.get()); // almost never 10000
}
}
count++ looks like one operation and is three. Two threads can read the same value,
both add one, and both write back — one increment vanishes. This is a race condition,
and it is the defining hazard of shared state.
Four ways to fix it, best first
1. Do not share mutable state. The only fix that scales. Give each thread its own data and combine results at the end — which is what a stream collector does:
class Demo {
void run() {
long count = IntStream.range(0, 10_000).parallel().filter(n -> n % 2 == 0).count();
System.out.println(count); // 5000, always — nothing is shared
}
}
2. Use an atomic or a concurrent collection. Correct, fast, and no locking to get wrong:
class Demo {
void run() throws Exception {
AtomicInteger counter = new AtomicInteger();
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
for (int i = 0; i < 10_000; i++) {
pool.submit(() -> {
counter.incrementAndGet();
map.merge("hits", 1, Integer::sum);
});
}
}
System.out.println(counter.get()); // 10000, reliably
System.out.println(map.get("hits")); // 10000
}
}
3. Synchronise. A lock lets one thread at a time into a block:
class SafeCounter {
private int count = 0;
private final Object lock = new Object();
void increment() {
synchronized (lock) { // a private lock, not `this`
count++;
}
}
int get() {
synchronized (lock) { // reads need it too — see below
return count;
}
}
}
4. volatile, for visibility only. Worth understanding because it is
routinely misused:
class Worker {
private volatile boolean running = true; // guarantees other threads SEE the change
void stop() { running = false; }
void loop() {
while (running) {
// without volatile this loop may never see stop() and spin forever
}
}
}
volatile guarantees visibility, not atomicity. A volatile int still
breaks under ++. Use it for a flag one thread writes and others read, and nothing
else.
Visibility, and why reads need locking too
The counter-intuitive part. Without synchronisation, one thread's write may never become visible to
another — the JVM and CPU are allowed to cache values in registers and reorder instructions. That is
why get() above is synchronised even though reading an int is atomic:
atomicity is not the issue, visibility is.
Deadlock
class Demo {
private final Object a = new Object();
private final Object b = new Object();
void first() {
synchronized (a) {
synchronized (b) { } // thread 1: a then b
}
}
void second() {
synchronized (b) {
synchronized (a) { } // thread 2: b then a — deadlock
}
}
}
Two threads each hold what the other wants, and neither ever proceeds. The fix is a
consistent lock ordering everywhere, or holding one lock at a time. When it happens
in production, jstack names the threads and the locks — see
Debugging.
Interruption, done properly
One idiom worth getting right, because getting it wrong silently breaks cancellation. Catching
InterruptedException clears the thread's interrupt flag, so code further up the
stack can no longer tell that cancellation was requested:
class Demo {
// Wrong: the interrupt is swallowed and the caller never learns of it
void bad() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// nothing
}
}
// Right: restore the flag before moving on
void good() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", e);
}
}
}
Either rethrow the exception or restore the flag — never neither. This is the concurrency-specific form of the swallowed exception, and it is why a task sometimes refuses to stop when its executor is shut down.
The rules
- Prefer immutability. An object that cannot change is automatically thread-safe.
- Prefer the highest-level tool — a parallel stream, an executor, a concurrent collection — over threads and locks.
- Keep synchronised blocks small, and never do I/O inside one.
- Document thread-safety on any class meant to be shared. If it is not safe, say so.
Next
Regex is next — pattern matching on text, and how to keep it readable.