Multithreading & Concurrency
Run several tasks at once with threads — and learn why shared data needs protecting to avoid race conditions.
What you will learn
- Start a task on its own thread
- Understand race conditions on shared data
- Fix them with synchronized
Doing more than one thing at a time
So far our programs ran one line after another, top to bottom — a single path of execution called a thread. Sometimes we want several things happening at the same time: downloading a file while the menu stays responsive, or doing two long calculations together. For that we create extra threads. Running multiple threads at once is called concurrency.
The easiest way to start a thread is to give it a task as a lambda (a Runnable — a functional interface with one method, run) and call start().
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 1; i <= 3; i++) {
System.out.println("Worker: " + i);
}
};
Thread worker = new Thread(task);
worker.start(); // runs task on a SEPARATE thread
System.out.println("Main thread keeps going too");
}
}Note: Output (order may vary):
Main thread keeps going too
Worker: 1
Worker: 2
Worker: 3
start() launched the task on a new thread, so the worker counted while the main thread carried on. Because they run independently, the exact interleaving of lines can differ between runs — that unpredictability is the first thing to understand about threads.
Watch out: Use start(), not run(). Calling run() directly just executes the code on the current thread like a normal method call — no new thread is created. Only start() launches a real separate thread.
Waiting for a thread to finish
Often the main thread needs the worker result before continuing. The join() method makes the caller wait until that thread has finished.
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> System.out.println("Work done"));
worker.start();
worker.join(); // wait here until worker finishes
System.out.println("Now main continues, safely after the worker");
}
}Note: Output:
Work done
Now main continues, safely after the worker
join() paused the main thread until the worker had completely finished, so the two lines now always appear in this order. (The throws InterruptedException is required because waiting can, in theory, be interrupted.)
The danger: race conditions
Threads get tricky when they share data. If two threads change the same variable at the same time, their steps can interleave badly and the result comes out wrong. This bug is called a race condition — the threads are "racing" to update the value and tripping over each other.
Imagine two threads each adding 1 to a shared counter 1000 times. We expect 2000, but without protection we often get less, because both threads sometimes read the same old value before either writes back.
public class Main {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> {
for (int i = 0; i < 1000; i++) {
counter++; // UNSAFE: two threads clash here
}
};
Thread t1 = new Thread(job);
Thread t2 = new Thread(job);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("Counter: " + counter); // often NOT 2000
}
}Note: Output (varies, often less than 2000):
Counter: 1873
We expected 2000, but got fewer. counter++ is really three tiny steps (read, add one, write back). When the two threads interleave those steps on the shared counter, some increments get lost. This wrong, unpredictable result is the classic race condition.
The fix: synchronized
To make a shared update safe, we let only one thread at a time run the critical code. The synchronized keyword acts like a single key to a room: a thread must hold the key to enter, and others wait their turn. This guarantees the increments do not overlap.
public class Main {
static int counter = 0;
// only one thread can be inside this method at a time
static synchronized void increment() {
counter++;
}
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> {
for (int i = 0; i < 1000; i++) {
increment(); // now SAFE
}
};
Thread t1 = new Thread(job);
Thread t2 = new Thread(job);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("Counter: " + counter); // always 2000
}
}Note: Output:
Counter: 2000
By moving the update into a synchronized method, only one thread can increment at a time, so no steps overlap and no increments are lost. The result is now a reliable 2000 every run. Whenever threads share changeable data, you must protect it like this.
Tip: For real projects, prefer the higher-level ExecutorService to manage a pool of threads instead of creating Thread objects by hand: ExecutorService pool = Executors.newFixedThreadPool(4); then pool.submit(task);. It reuses threads efficiently and is the modern, recommended approach once you understand the basics shown here.
Q. What is a race condition?
✍️ Practice
- Start two threads that each print a short message five times, and observe how the output order can change between runs.
- Take the unsafe counter example, add
synchronizedto the update, and confirm it now always reaches 2000.
🏠 Homework
- Write a program with a shared
totalthat three threads each add to in a loop. Run it once without synchronization and once with it, and write 3 to 4 lines explaining the difference you saw.