Java Concurrency Basics
Threads & Their Nuances
"A thread is the smallest unit of execution that the OS schedules. Get the fundamentals wrong here, and every concurrent program you write will be a house of cards."
This article covers the absolute foundations: what a thread is, how to create one, the thread lifecycle, and the subtle nuances that trip up even experienced developers.
What is a Thread?
A thread is an independent path of execution within a process. Every Java program starts with one thread — the main thread — created by the JVM to execute public static void main().
public class MainThreadDemo {
public static void main(String[] args) {
// This code runs on the "main" thread
System.out.println("Thread: " + Thread.currentThread().getName());
// Output: Thread: main
}
}
Internally, a Java thread maps 1:1 to an OS-level thread (on most JVMs). The OS scheduler decides when each thread gets CPU time.
Key insight: The JVM internally uses multiple threads beyond what you create — garbage collection, JIT compilation, finalization, and reference handling all run on their own threads.
Processes vs Threads
| Aspect | Process | Thread |
|---|---|---|
| Memory | Own address space | Shares process memory |
| Creation cost | Heavy (fork, allocate) | Light (~1 MB stack) |
| Communication | IPC (pipes, sockets, shared memory) | Shared heap — direct variable access |
| Isolation | Crash doesn't affect other processes | Crash can kill entire process |
| Context switch | Expensive (TLB flush, page tables) | Cheaper (shared page tables) |
Threads within the same process share:
Heap memory (objects)
Static fields
Open file handles
Network connections
Each thread has its own:
Stack (local variables, method call chain)
Program counter (current instruction)
Register state
Why Threads? The Motivation
Responsiveness
// ❌ Without threads — UI freezes during download
public void onButtonClick() {
byte[] data = downloadFile("10GB.zip"); // Blocks UI for 30 minutes!
showComplete();
}
// ✅ With threads — UI stays responsive
public void onButtonClick() {
new Thread(() -> {
byte[] data = downloadFile("10GB.zip"); // Background thread
Platform.runLater(() -> showComplete()); // Update UI from UI thread
}).start();
}
Parallelism (Utilizing Multiple Cores)
// Sequential: processes one file at a time
for (File file : files) {
processFile(file); // Total: N × T seconds
}
// Parallel: processes multiple files simultaneously
files.parallelStream()
.forEach(this::processFile); // Total: ~T seconds (on N cores)
Simplification of Async Models
Servers like Tomcat use one thread per request, keeping the programming model simple:
Creating Threads in Java
Extending Thread
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("Running in: " + Thread.currentThread().getName());
}
}
MyThread t = new MyThread();
t.start(); // ✅ Creates a new OS thread and calls run()
// t.run(); // ❌ This just calls run() on the CURRENT thread — no new thread!
Implementing Runnable (Preferred)
public class MyTask implements Runnable {
@Override
public void run() {
System.out.println("Running in: " + Thread.currentThread().getName());
}
}
Thread t = new Thread(new MyTask());
t.start();
// Lambda version (Java 8+)
Thread t2 = new Thread(() -> {
System.out.println("Lambda thread: " + Thread.currentThread().getName());
});
t2.start();
Using Callable (Returns a Result)
Callable<Integer> task = () -> {
Thread.sleep(1000);
return 42;
};
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(task);
Integer result = future.get(); // Blocks until result is available
System.out.println("Result: " + result); // 42
executor.shutdown();
start() vs run() — The Critical Distinction
Interview question: "What happens if you call
run()instead ofstart()?" Answer:run()executes synchronously on the caller's thread — no new thread is created.start()creates a new OS thread, then the JVM callsrun()on that new thread.
Thread Lifecycle & States
Thread States in Java
| State | Thread.State |
What's Happening |
|---|---|---|
| NEW | NEW |
Thread object created but start() not called |
| RUNNABLE | RUNNABLE |
Eligible for scheduling (may be running or waiting for CPU) |
| BLOCKED | BLOCKED |
Waiting to enter a synchronized block |
| WAITING | WAITING |
Waiting indefinitely: wait(), join(), park() |
| TIMED_WAITING | TIMED_WAITING |
Waiting with timeout: sleep(ms), wait(ms) |
| TERMINATED | TERMINATED |
run() finished (normally or by exception) |
Thread t = new Thread(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println(t.getState()); // NEW
t.start();
System.out.println(t.getState()); // RUNNABLE (or TIMED_WAITING)
Thread.sleep(100);
System.out.println(t.getState()); // TIMED_WAITING
t.join();
System.out.println(t.getState()); // TERMINATED
Thread Priority & Scheduling
Java supports thread priorities from 1 (lowest) to 10 (highest):
Thread t = new Thread(task);
t.setPriority(Thread.MAX_PRIORITY); // 10
t.setPriority(Thread.NORM_PRIORITY); // 5 (default)
t.setPriority(Thread.MIN_PRIORITY); // 1
Warning: Thread priorities are hints to the OS scheduler, not guarantees. Most modern OS schedulers (CFS on Linux) largely ignore Java priorities. Never rely on priorities for correctness. Use proper synchronization instead.
Daemon vs User Threads
| Aspect | User Thread | Daemon Thread |
|---|---|---|
| JVM shutdown | JVM waits for ALL user threads to finish | JVM does NOT wait for daemon threads |
| Default | ✅ All threads are user by default | Must explicitly set |
| Use case | Application logic | Background services (GC, monitoring) |
Thread daemon = new Thread(() -> {
while (true) {
cleanupOldFiles();
Thread.sleep(60_000);
}
});
daemon.setDaemon(true); // Must set BEFORE start()!
daemon.start();
// When main thread and all user threads finish,
// the JVM will exit — killing this daemon thread abruptly
Gotcha: Never use daemon threads for operations that need graceful cleanup (writing files, flushing caches, closing connections). The JVM kills them without calling
finallyblocks.
Thread Safety — The Core Problem
Thread safety means that shared mutable state behaves correctly when accessed by multiple threads simultaneously. The three enemies:
Atomicity Violations
// count++ is NOT atomic — it's 3 operations:
// 1. READ count
// 2. ADD 1
// 3. WRITE count
private int count = 0;
public void increment() {
count++; // Two threads can both read 0, both write 1
}
Visibility Problems
// Thread A may never see Thread B's update!
private boolean running = true;
// Thread A
public void run() {
while (running) { // May loop forever — reads cached value!
doWork();
}
}
// Thread B
public void stop() {
running = false; // Thread A might never see this
}
Without volatile or synchronization, the JVM is not required to propagate changes between thread-local caches.
Ordering (Reordering)
// The JVM/CPU can reorder these instructions!
int a = 0, b = 0;
// Thread A
a = 1;
b = 2;
// CPU might execute as: b = 2; a = 1; (reordering!)
// Thread B can see b == 2 but a == 0!
The Critical Section
A critical section is a block of code that accesses shared mutable state and must be executed by only one thread at a time.
public class BankAccount {
private double balance = 1000.0;
// The entire method is a critical section
public synchronized void withdraw(double amount) {
if (balance >= amount) { // CHECK
// ⚠️ Without synchronization, another thread
// could withdraw between CHECK and ACT
balance -= amount; // ACT
}
}
}
This is the classic check-then-act race condition pattern:
Solution: Protect the critical section with a lock (either synchronized or a ReentrantLock) so that only one thread executes the check-then-act atomically.
Race Conditions & Data Races
Race Condition
A race condition occurs when the correctness of a program depends on the timing of thread execution.
// Classic race: lazy initialization
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // Thread A checks: null ✅
// Thread B also checks: null ✅ (before A creates it)
instance = new Singleton(); // Both create instances!
}
return instance;
}
Data Race
A data race is when two threads access the same memory location concurrently, at least one is a write, and there's no ordering guarantee (no happens-before).
// Data race: no synchronization, no volatile
private int x = 0;
// Thread A // Thread B
x = 42; int r = x; // r might be 0 or 42 — undefined!
Key distinction: A race condition is a logic bug (wrong result due to timing). A data race is a memory model violation (undefined behavior). You can have one without the other.
Problem: Thread-Safe API Counter
Goal: Build a counter that tracks API calls across multiple threads accurately.
Naive (Broken) Version
public class ApiCounter {
private int count = 0;
public void recordCall() {
count++; // NOT THREAD-SAFE!
}
public int getCount() {
return count;
}
}
Version 1: synchronized
public class SynchronizedApiCounter {
private int count = 0;
public synchronized void recordCall() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Version 2: AtomicInteger (Lock-Free)
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicApiCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void recordCall() {
count.incrementAndGet(); // CAS-based, lock-free!
}
public int getCount() {
return count.get();
}
}
Version 3: LongAdder (High Contention)
import java.util.concurrent.atomic.LongAdder;
public class HighPerfApiCounter {
private final LongAdder count = new LongAdder();
public void recordCall() {
count.increment(); // Striped — minimal contention
}
public long getCount() {
return count.sum(); // Aggregates all stripes
}
}
Problem: Thread-Safe Concurrent HashMap
Goal: Build a simplified thread-safe map without using ConcurrentHashMap.
Approach: Bucket-Level Locking (Striped Locks)
public class StripedHashMap<K, V> {
private static final int STRIPES = 16;
private final Object[] locks;
private final List<Map.Entry<K, V>>[] buckets;
@SuppressWarnings("unchecked")
public StripedHashMap(int numBuckets) {
buckets = new List[numBuckets];
locks = new Object[STRIPES];
for (int i = 0; i < numBuckets; i++) buckets[i] = new ArrayList<>();
for (int i = 0; i < STRIPES; i++) locks[i] = new Object();
}
private int bucketIndex(K key) {
return Math.abs(key.hashCode() % buckets.length);
}
private Object lockFor(K key) {
return locks[bucketIndex(key) % STRIPES];
}
public void put(K key, V value) {
synchronized (lockFor(key)) { // Only lock the relevant stripe!
List<Map.Entry<K, V>> bucket = buckets[bucketIndex(key)];
for (Map.Entry<K, V> entry : bucket) {
if (entry.getKey().equals(key)) {
entry.setValue(value);
return;
}
}
bucket.add(Map.entry(key, value));
}
}
public V get(K key) {
synchronized (lockFor(key)) {
List<Map.Entry<K, V>> bucket = buckets[bucketIndex(key)];
for (Map.Entry<K, V> entry : bucket) {
if (entry.getKey().equals(key)) return entry.getValue();
}
return null;
}
}
}
Common Pitfalls
Pitfall 1: Calling run() Instead of start()
Thread t = new Thread(() -> System.out.println("Thread: " + Thread.currentThread().getName()));
t.run(); // ❌ Output: Thread: main (no new thread!)
t.start(); // ✅ Output: Thread: Thread-0 (new thread!)
Pitfall 2: Swallowing InterruptedException
// ❌ WRONG — silently swallows the interrupt
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Do nothing — BAD!
}
// ✅ CORRECT — restore the interrupt status
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restore interrupt flag
throw new RuntimeException("Thread was interrupted", e);
}
Pitfall 3: Setting Daemon After start()
Thread t = new Thread(task);
t.start();
t.setDaemon(true); // 💥 IllegalThreadStateException!
// Must set daemon BEFORE start()
Pitfall 4: Assuming Thread Priority Controls Execution Order
// ❌ This does NOT guarantee t1 runs before t2
t1.setPriority(Thread.MAX_PRIORITY);
t2.setPriority(Thread.MIN_PRIORITY);
t1.start();
t2.start();
// Execution order is OS-dependent!
Summary
The one-liner: A thread is an independent execution path sharing the process heap; thread safety requires protecting shared mutable state from atomicity, visibility, and ordering violations — synchronized, volatile, and Atomic* classes are your tools.