Multithreading in Java: Running Tasks in Parallel (Beginner Guide)
Learn multithreading in Java gently — what threads are, how to start one, the danger of race conditions with shared data, synchronized, and modern thread pools.
So far, every program you've written does one thing at a time, top to bottom. But modern computers have multiple CPU cores, and modern apps do many things at once — downloading while you scroll, handling hundreds of users simultaneously. Multithreading is how Java runs several tasks in parallel. It's a genuinely advanced topic, so this lesson keeps it approachable: the core idea, how to start a thread, and the one big danger to respect.
What is a thread?
A thread is a single path of execution — a task running independently. Your program already has one (the "main" thread that runs main). Multithreading means starting additional threads so multiple tasks progress at the same time, instead of waiting in line.
A good analogy: a restaurant with one waiter serves tables one at a time; add more waiters (threads) and many tables get served concurrently. That's the appeal — doing more work in the same wall-clock time.
Creating a thread
The easiest modern way is to hand a Runnable (which, thanks to lambdas, is just a block of code) to a Thread and start it:
Thread t = new Thread(() -> { for (int i = 1; i <= 3; i++) { System.out.println("Worker: " + i); } }); t.start(); // runs the code on a NEW thread, alongside main System.out.println("Main keeps going too");
start(), not run()! Calling t.start() launches a new thread. Calling t.run() directly just runs the code on the current thread like a normal method — no parallelism at all. This is a classic beginner mix-up.Output isn't in a fixed order
Here's the first mind-shift: with multiple threads, you can't predict the exact order things print. The main thread and the worker thread run independently, so their output interleaves differently each run. That unpredictability is normal — and it's also the source of the big danger below.
The big danger: shared data & race conditions
When two threads change the same data at the same time, they can step on each other. Imagine two threads both doing count++ a thousand times. You'd expect 2000 — but you often get less, because count++ is really three steps (read, add, write) and the threads can interleave and overwrite each other. This is called a race condition, and it causes some of the nastiest, hardest-to-reproduce bugs in software.
The fix: synchronization
The classic tool is the synchronized keyword, which lets only one thread at a time into a critical section:
private int count = 0; public synchronized void increment() { // one thread at a time count++; }
This is also exactly why immutable objects are so prized: if data can never change, threads can share it with zero risk of a race condition. That's a big part of why immutability matters.
The modern approach: ExecutorService
In real applications you rarely create raw Thread objects by hand. Instead you use a thread pool via ExecutorService, which manages a set of reusable threads for you — more efficient and far easier to manage:
import java.util.concurrent.*; ExecutorService pool = Executors.newFixedThreadPool(3); pool.submit(() -> System.out.println("Task ran on a pooled thread")); pool.shutdown();
Web servers work this way: a pool of threads handles incoming requests so thousands of users can be served concurrently. This website's server does exactly that under the hood.
Multithreading is genuinely one of the harder areas of Java — I'll be honest, it took me a while and a few baffling bugs to build a real feel for it. My advice: understand the core idea and, above all, respect shared mutable data — that's where the pain lives. In day-to-day work you'll mostly rely on higher-level tools (thread pools, and frameworks that handle concurrency for you) rather than hand-managing threads. Knowing what's happening underneath, though, makes you far better at using those tools safely.
Key takeaways
- A thread is an independent path of execution; multithreading runs tasks in parallel.
- Start one with
new Thread(() -> {...}).start()— usestart(), neverrun(). - Shared mutable data causes race conditions; guard it with
synchronized(or prefer immutable data). - Real apps use
ExecutorServicethread pools rather than raw threads.
← Previous: Lesson 29 — Optional in Java
Next: Lesson 31 — File I/O in Java →