The Problem: Doing More Than One Thing
Imagine a Java application handling thousands of HTTP requests.
At the same time, it might be:
-
reading from a database
-
calling another service
-
processing messages from a queue
-
writing logs
-
calculating something
-
handling new HTTP requests
If the application had to finish one task completely before starting another, it would spend a huge amount of time waiting.
For example:
User user = userService.findUser(id);
Account account = accountService.findAccount(id);
Orders orders = orderService.findOrders(id);
If each operation takes 100 ms:
findUser() 100ms
findAccount() 100ms
findOrders() 100ms
---------------------
Total 300ms
But if these operations are independent, we could perform them concurrently:
findUser() ──────────
findAccount() ──────────
findOrders() ──────────
~100ms
Now the total time can be close to 100 ms rather than 300 ms, assuming the downstream systems and hardware can handle the concurrency.
This is where concurrency comes in.
Concurrency vs Multithreading
These two terms are related, but they are not the same.
Concurrency is about dealing with multiple tasks that are in progress during overlapping periods of time.
Multithreading is one mechanism for implementing concurrency using multiple threads.
There is also a third important concept:
Parallelism means that multiple tasks are actually executing at the same time.
Think about it this way:
Concurrency
|
+-- Multithreading
|
+-- Asynchronous programming
|
+-- Multiprocessing
And:
Concurrency = multiple tasks making progress
Parallelism = multiple tasks executing simultaneously
A single CPU can still provide concurrency by switching between tasks:
Task A → Task B → Task A → Task C → Task B
With multiple CPU cores, tasks can also execute in parallel:
Core 1 → Task A
Core 2 → Task B
Core 3 → Task C
Core 4 → Task D
The distinction becomes important when designing Java applications.
What Is a Thread?
A thread is an independent path of execution inside a process.
A Java application can have many threads:
JVM
|
+-- Main Thread
|
+-- HTTP Worker Threads
|
+-- Scheduler Threads
|
+-- Database Worker Threads
|
+-- GC Threads
|
+-- JIT Compiler Threads
|
+-- Application Threads
The simplest way to create a thread is:
Thread thread = new Thread(() -> {
System.out.println("Running in another thread");
});
thread.start();
There is an important difference between:
thread.start();
and:
thread.run();
Calling run() is just a normal method call:
Main Thread
|
+-- run()
|
+-- task executes
Calling start() creates a new execution path:
Main Thread
|
+-- start()
|
+-- Worker Thread
|
+-- run()
This small distinction is one of the first things to understand about Java threads.
Thread Lifecycle
A Java thread moves through several states:
NEW
|
v
RUNNABLE
|
+----> BLOCKED
|
+----> WAITING
|
+----> TIMED_WAITING
|
v
TERMINATED
A newly created thread is in the NEW state:
Thread thread = new Thread(task);
After:
thread.start();
it becomes RUNNABLE.
A thread may become BLOCKED when it is waiting to acquire a monitor:
synchronized (lock) {
// critical section
}
It can enter WAITING when waiting indefinitely for another thread:
thread.join();
or:
object.wait();
It can enter TIMED_WAITING when waiting for a specific amount of time:
Thread.sleep(1000);
Eventually, when its work is complete:
TERMINATED
One subtle point is worth remembering:
Java's RUNNABLE state does not necessarily mean the thread is currently executing on a CPU. It can also mean that the thread is ready to run and waiting for CPU time.
The Real Problem: Shared Mutable State
Creating threads is easy.
Making multiple threads safely access the same data is the difficult part.
Consider:
class Counter {
private int count;
void increment() {
count++;
}
int get() {
return count;
}
}
At first glance:
count++;
looks like one operation.
It isn't.
Conceptually, it is:
read count
|
add 1
|
write count
Now imagine two threads:
Thread A Thread B
read count = 10 read count = 10
add 1 add 1
write 11 write 11
The expected result after two increments is:
12
But the actual result can be:
11
Both threads read the same old value and then overwrote each other's result.
This is a race condition.
Race Conditions
A race condition happens when the correctness of a program depends on the timing or ordering of concurrent operations.
The dangerous thing about race conditions is that they are often intermittent.
Your program might work:
999 times
and fail:
1 time
You might run it again and get a different result.
This makes concurrency bugs particularly difficult to reproduce.
The key question when reviewing concurrent code is:
What happens if two threads execute this code at exactly the same time?
Atomicity
An operation is atomic when it appears to happen as one indivisible operation.
This:
count++;
is not atomic.
Java provides atomic classes for common operations:
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();
Now the increment operation is atomic.
You can also use:
counter.get();
counter.set(100);
counter.incrementAndGet();
counter.decrementAndGet();
counter.compareAndSet(10, 20);
Atomicity is one part of concurrency.
But there is another problem.
Visibility
Consider:
class Worker {
private boolean running = true;
void work() {
while (running) {
doSomething();
}
}
void stop() {
running = false;
}
}
One thread executes:
worker.work();
Another thread executes:
worker.stop();
It is tempting to assume that the first thread will immediately see:
running == false
But concurrent programs cannot simply assume that writes made by one thread are immediately visible to another thread.
Modern CPUs have caches, and the JVM and JIT compiler perform optimizations and reorderings.
This is why Java has the Java Memory Model.
The Java Memory Model
The Java Memory Model, usually called the JMM, defines the rules for how threads interact through memory.
A useful simplified model is:
Java Code
|
v
JVM
|
v
JIT Compiler
|
v
CPU Instructions
|
v
CPU Caches / Main Memory
The important question is not simply:
"Did Thread A write the value?"
It is:
"What guarantees that Thread B will see that write, and in what order?"
This leads to three fundamental concurrency concepts:
Atomicity
Visibility
Ordering
A correct concurrent program needs to reason about all three.
volatile
For the running example, volatile can solve the visibility problem:
class Worker {
private volatile boolean running = true;
void work() {
while (running) {
doSomething();
}
}
void stop() {
running = false;
}
}
Now writes to running have the visibility guarantees required by the Java Memory Model.
But there is an important warning:
volatiledoes not make compound operations atomic.
This is still unsafe:
volatile int count;
count++;
Because:
read
+
increment
+
write
is still a compound operation.
Use an atomic class or synchronization when atomicity is required.
synchronized
The simplest Java synchronization mechanism is:
synchronized
For example:
class Counter {
private int count;
synchronized void increment() {
count++;
}
synchronized int get() {
return count;
}
}
Now only one thread can execute a synchronized method on the same object at a time.
Conceptually:
Thread A
|
acquire monitor
|
count++
|
release monitor
Thread B
|
wait
|
acquire monitor
|
count++
This provides mutual exclusion.
But synchronized does more than prevent two threads from entering the same critical section simultaneously.
It also provides the required memory visibility guarantees.
So synchronization addresses two major problems:
synchronized
|
+-- Mutual exclusion
|
+-- Visibility / ordering guarantees
What Is a Monitor?
Every Java object can be used as an intrinsic lock.
For example:
synchronized (lock) {
// critical section
}
The thread entering this block acquires the monitor associated with lock.
Another thread attempting to acquire the same monitor has to wait.
You will also see:
synchronized (this) {
// ...
}
Although this works, using a private lock can sometimes make the locking boundary clearer:
private final Object lock = new Object();
synchronized (lock) {
// critical section
}
The important idea is not the syntax.
The important idea is:
Which state is protected by which lock?
The Critical Section
A critical section is a piece of code that accesses shared state and must be protected from concurrent interference.
For example:
synchronized (lock) {
balance -= amount;
}
The smaller the critical section, the better - provided the synchronization still correctly protects the state.
Avoid doing unrelated work while holding a lock:
synchronized (lock) {
database.call();
remoteHttpCall();
writeLargeFile();
}
Now every thread waiting for lock is blocked while external systems respond.
Prefer:
Acquire lock
|
Modify shared state
|
Release lock
|
Perform slow I/O
when the application logic allows it.
Explicit Locks
Java also provides:
Lock
The most commonly used implementation is:
ReentrantLock
Example:
private final Lock lock = new ReentrantLock();
void update() {
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
}
The finally is important.
Without it, an exception could leave the lock acquired.
One advantage of Lock is that it provides capabilities beyond intrinsic synchronization.
For example:
if (lock.tryLock()) {
try {
// work
} finally {
lock.unlock();
}
}
You can also use timeouts:
lock.tryLock(1, TimeUnit.SECONDS);
This can be useful when you don't want a thread to wait forever.
ReadWriteLock
Imagine a cache where:
95% operations = reads
5% operations = writes
A normal exclusive lock forces readers to wait for other readers.
A ReadWriteLock allows multiple readers to operate concurrently.
private final ReadWriteLock lock =
new ReentrantReadWriteLock();
Reading:
lock.readLock().lock();
try {
return cache.get(key);
} finally {
lock.readLock().unlock();
}
Writing:
lock.writeLock().lock();
try {
cache.put(key, value);
} finally {
lock.writeLock().unlock();
}
Conceptually:
Reader A ─────────
Reader B ─────────
Reader C ─────────
Writer
|
+---- waits
Once a writer obtains the lock:
Reader A ── X
Reader B ── X
Reader C ── X
Writer ─────────────
Again, this is useful only when the workload actually benefits from it. More sophisticated synchronization does not automatically mean better performance.
Atomic Classes
Java provides several atomic classes:
AtomicInteger
AtomicLong
AtomicBoolean
AtomicReference
For example:
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();
These classes commonly rely on CAS, or Compare-And-Set/Compare-And-Swap.
The basic idea is:
Current value == expected value?
|
yes
|
update value
Otherwise:
Current value != expected value
|
v
retry
This allows certain operations to be performed without traditional locking.
AtomicReference
AtomicReference becomes particularly interesting when dealing with immutable state.
For example:
AtomicReference<State> state =
new AtomicReference<>(initialState);
A thread can attempt:
state.compareAndSet(oldState, newState);
This is useful for implementing state transitions safely.
Instead of modifying a complicated shared object in place, you can construct a new immutable state and atomically replace the reference.
This is one of the most useful patterns for reducing concurrency complexity.
Immutability
One of the best concurrency techniques is surprisingly simple:
Don't share mutable state if you don't need to.
Consider an immutable object:
public final class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String name() {
return name;
}
public int age() {
return age;
}
}
Multiple threads can safely read it without synchronization.
Compare that with a mutable shared object:
Thread A ──┐
Thread B ──┼──> shared mutable object
Thread C ──┘
Now every mutation needs careful synchronization.
With immutable state:
Thread A ──┐
Thread B ──┼──> immutable object
Thread C ──┘
Everyone can safely read it.
This leads to a powerful design principle:
Less shared mutable state
|
v
Less synchronization
|
v
Fewer race conditions
|
v
Simpler concurrent programs
ExecutorService
Creating threads manually is usually not the best way to build an application.
Instead of:
new Thread(task).start();
Java provides executors.
ExecutorService executor =
Executors.newFixedThreadPool(10);
executor.submit(task);
This separates two concerns:
What should execute?
|
v
Runnable / Callable
from:
How should it execute?
|
v
Executor
This separation is extremely useful.
Thread Pools
Suppose your application receives:
10,000 requests
Creating 10,000 platform threads would be expensive.
Instead:
10,000 Tasks
|
v
Queue
|
v
Thread Pool
┌────┬────┬────┬────┐
T1 T2 T3 T4
The pool controls how many tasks execute concurrently.
This introduces an important idea:
Concurrency should usually be controlled, not unlimited.
CPU-Bound vs I/O-Bound Work
Thread-pool sizing depends heavily on the type of work.
CPU-bound work looks like:
calculateHash();
compressFile();
processImage();
The thread spends most of its time using CPU.
If the machine has 8 CPU cores, creating thousands of CPU-intensive threads doesn't give you thousands of CPUs.
Instead, excessive threads cause scheduling and context-switching overhead.
I/O-bound work looks like:
database.query();
httpClient.call();
file.read();
The thread may spend much of its time waiting.
Therefore, a workload that waits frequently can often support more concurrent operations than the number of CPU cores.
But there is an important limit.
If your application has:
500 worker threads
|
v
100 database connections
then increasing the number of threads doesn't magically create more database capacity.
The database becomes the bottleneck.
Backpressure
Imagine:
10,000 requests/sec
|
v
Application
|
v
Database
but the database can only process:
1,000 requests/sec
If the application simply keeps accepting more work, queues and memory usage can grow indefinitely.
Eventually:
Queue grows
|
Memory grows
|
Latency grows
|
System becomes unstable
This is why concurrency needs backpressure.
A system must sometimes say:
"I cannot safely process more work right now."
Queues, bounded executors, semaphores, connection pools, rate limits, and timeouts are all tools for controlling this.
Callable and Future
Runnable doesn't return a result.
Callable does:
Callable<Integer> task = () -> {
return 42;
};
Submit it:
Future<Integer> future =
executor.submit(task);
Then:
Integer result = future.get();
But there is an important problem.
get() blocks.
So this:
Future<Result> result = executor.submit(task);
Result value = result.get();
is concurrent internally, but the calling thread eventually waits for the result.
This isn't necessarily wrong, but you need to understand where blocking occurs.
CompletableFuture
Java provides a more composable abstraction:
CompletableFuture
For example:
CompletableFuture<User> user =
CompletableFuture.supplyAsync(
() -> userService.findUser(id)
);
You can transform the result:
user.thenApply(User::name)
.thenAccept(System.out::println);
You can also combine independent operations.
CompletableFuture<User> user =
getUser(id);
CompletableFuture<Account> account =
getAccount(id);
CompletableFuture<Result> result =
user.thenCombine(
account,
Result::new
);
The structure becomes:
getUser()
\
\
+----> Result
/
getAccount()
Instead of:
getUser()
|
wait
|
getAccount()
|
wait
|
Result
The difference can be significant when the operations are independent I/O operations.
Don't Use the Common Pool Blindly
This is an easy mistake:
CompletableFuture.supplyAsync(() ->
database.call()
);
Without an explicit executor, asynchronous work may use the common ForkJoinPool.
That may be fine for some workloads.
But if the operation is blocking I/O, blindly putting many blocking operations into a pool intended for other work can produce poor behavior.
You should understand:
What work?
|
CPU-bound?
I/O-bound?
Blocking?
Short?
Long?
|
What executor?
The executor is part of the design.
ForkJoinPool
ForkJoinPool is designed around splitting work into smaller pieces.
For example:
Task
/ \
A B
/ \ / \
A1 A2 B1 B2
Workers can execute these smaller tasks.
It also uses work stealing.
If one worker finishes its work while another worker still has tasks:
Worker A → no work
Worker B → Task 1
Task 2
Task 3
Worker A can steal work from Worker B.
This is particularly useful for parallel algorithms.
Parallel Streams
Java makes parallel processing very easy:
list.parallelStream()
.map(this::process)
.toList();
But:
parallelStream()
does not mean:
automatically faster
Parallelism has overhead.
For a small collection:
Splitting
Scheduling
Synchronization
Combining
may cost more than simply processing the collection sequentially.
Parallel streams are most useful when the work is sufficiently large, independent, CPU-intensive, and suitable for parallel decomposition.
Measure rather than assume.
ConcurrentHashMap
A normal HashMap isn't designed for concurrent modification.
For concurrent access:
ConcurrentHashMap<String, Integer> map =
new ConcurrentHashMap<>();
It supports concurrent operations with much better scalability than simply putting one giant lock around a normal HashMap.
More importantly, use its atomic operations when performing compound updates.
For example:
map.merge(key, 1, Integer::sum);
This is much better than:
if (!map.containsKey(key)) {
map.put(key, 1);
} else {
map.put(key, map.get(key) + 1);
}
The second version contains a classic:
check
+
act
race.
BlockingQueue
BlockingQueue is one of the most useful concurrency abstractions.
It naturally implements producer-consumer behavior:
Producer
|
v
BlockingQueue
|
v
Consumer
Producer:
queue.put(item);
Consumer:
Item item = queue.take();
If the queue is empty, the consumer waits.
If the queue is bounded and full, the producer can wait.
This naturally introduces backpressure.
Producer-Consumer
A real system might look like:
HTTP Requests
|
v
Producer
|
v
Queue
|
+----> Worker 1
|
+----> Worker 2
|
+----> Worker 3
|
v
Database
This separates:
How fast work arrives
from:
How fast work can be processed
That separation is extremely valuable in backend systems.
Semaphore
A Semaphore limits the number of threads that can access something simultaneously.
Suppose an expensive external resource should allow only 20 concurrent operations:
Semaphore semaphore = new Semaphore(20);
Then:
semaphore.acquire();
try {
expensiveOperation();
} finally {
semaphore.release();
}
Now at most 20 threads can be inside that section.
This is different from a normal lock.
A lock usually allows:
1 thread
A semaphore can allow:
N threads
It is therefore useful for limiting access to scarce resources.
CountDownLatch
Suppose several tasks need to finish before another operation can start:
Task A ──┐
Task B ──┤
Task C ──┼──> Continue
Task D ──┘
Use:
CountDownLatch latch =
new CountDownLatch(4);
Each worker:
try {
doWork();
} finally {
latch.countDown();
}
Coordinator:
latch.await();
Once all four tasks have called countDown():
count = 0
and the waiting thread continues.
CyclicBarrier
CyclicBarrier solves a related but different problem.
Several threads reach a synchronization point:
Thread A ──┐
Thread B ──┤
Thread C ──┼── Barrier
Thread D ──┘
|
v
Continue
All participants wait until everyone reaches the barrier.
Unlike CountDownLatch, a barrier can be reused.
Deadlock
One of the most dangerous concurrency problems is deadlock.
Consider:
synchronized (lock1) {
synchronized (lock2) {
// ...
}
}
Another thread does:
synchronized (lock2) {
synchronized (lock1) {
// ...
}
}
Now:
Thread A Thread B
owns lock1 owns lock2
| |
waits for lock2 waits for lock1
| |
+---------- DEADLOCK ---+
Neither thread can continue.
Preventing Deadlocks
One simple technique is consistent lock ordering.
Always acquire:
lock1 → lock2
Never:
lock2 → lock1
Then:
Thread A → lock1 → lock2
Thread B → lock1 → lock2
There is no circular dependency.
Other techniques include:
-
reducing lock scope
-
avoiding nested locks
-
using
tryLock() -
using timeouts
-
using higher-level concurrency abstractions
-
minimizing shared mutable state
Livelock
A livelock is different from a deadlock.
In a deadlock:
Threads are blocked.
In a livelock:
Threads are active
but make no progress.
For example:
Thread A → detects conflict → backs off
Thread B → detects conflict → backs off
Thread A → retries
Thread B → retries
Thread A → backs off
Thread B → backs off
Both threads are doing work, but the system isn't progressing.
Starvation
Starvation happens when a thread continually fails to obtain the resources it needs.
For example:
Thread A ── gets resource
Thread B ── gets resource
Thread C ── waits
Thread A ── gets resource again
Thread B ── gets resource again
Thread C ── waits
If this continues indefinitely, Thread C is starving.
This is one reason fairness and scheduling behavior can matter when choosing synchronization mechanisms.
Thread Interruption
Java's interruption mechanism is cooperative.
Calling:
thread.interrupt();
doesn't mean:
Kill this thread immediately.
It signals that the thread should consider stopping or changing what it is doing.
Blocking operations such as:
Thread.sleep()
can throw:
InterruptedException
A good pattern is:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
The important part is:
Thread.currentThread().interrupt();
If you catch InterruptedException and simply ignore it, you can accidentally destroy the cancellation signal.
ThreadLocal
ThreadLocal gives each thread its own value.
ThreadLocal<String> context =
new ThreadLocal<>();
Thread A might have:
request-A
while Thread B has:
request-B
The values are independent.
This can be useful for thread-confined data.
But there is an important problem with thread pools.
Threads are reused.
Imagine:
Request A
|
Thread 5
|
ThreadLocal = request-A
Request finishes
Request B
|
Thread 5
If the value isn't removed, Request B may accidentally inherit state associated with Request A.
Therefore:
try {
context.set(requestId);
process();
} finally {
context.remove();
}
is an important pattern when using ThreadLocal with reusable threads.
Virtual Threads
Modern Java provides another major approach to concurrency:
virtual threads.
Traditional platform threads are relatively expensive because they map to operating-system threads.
Virtual threads are lightweight threads managed by the JVM.
For example:
Thread.startVirtualThread(() -> {
handleRequest();
});
Or:
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> handleRequest());
}
The interesting part is that you can write straightforward blocking-style code:
var user = userService.findUser(id);
var account = accountService.findAccount(id);
return createResponse(user, account);
while supporting a very large number of concurrent tasks.
Why Virtual Threads Matter
Imagine an application handling many operations like:
Request
|
HTTP call
|
wait
|
Database call
|
wait
|
Response
A platform thread may spend much of its lifetime waiting.
Virtual threads are particularly useful for this type of workload because blocking operations can be handled with much less thread-resource overhead.
But there is an important distinction:
Virtual threads improve concurrency capacity. They do not make the CPU faster.
If you have a CPU-intensive operation:
while (...) {
expensiveCalculation();
}
a virtual thread doesn't create additional CPU cores.
If the machine has:
8 CPU cores
you still have approximately the same CPU capacity.
Virtual threads are especially attractive for:
High concurrency
+
I/O-bound workloads
Concurrency Is About Resource Management
This is perhaps the most important idea.
Concurrency isn't:
"How many threads can I create?"
It is:
"How much work can I safely execute concurrently?"
Imagine:
Application
|
+-- CPU
|
+-- Database
|
+-- Redis
|
+-- HTTP APIs
|
+-- File system
Every resource has a limit.
For example:
CPU → 8 cores
DB connections → 100
HTTP connections→ 200
Memory → 16 GB
External API → 1,000 req/sec
Your concurrency must respect those limits.
Increasing concurrency beyond the capacity of a bottleneck can actually make the system slower.
Context Switching
Suppose you have:
8 CPU cores
and:
10,000 runnable threads
Only a small number can execute at any given moment.
The operating system continually switches between them.
This is called context switching.
Context switching isn't free.
It involves things such as:
Save execution state
|
Load another thread's state
|
CPU scheduling
|
Cache effects
|
Continue execution
Therefore:
More threads ≠ more performance
There is an optimal concurrency level for a workload.
Contention
Suppose 100 threads need the same lock:
lock
|
+------+------+------+
| | | |
T1 T2 T3 ... T100
Only one can proceed.
The other 99 are competing for the same resource.
This is contention.
A program can therefore become slower even after adding more threads.
A typical performance curve looks something like:
Throughput
^
| ______
| /
| /
| /
| /
|______/
+----------------------> Concurrency
^
saturation
Initially, more concurrency increases throughput.
Eventually a resource becomes saturated.
Beyond that point, additional concurrency may increase:
-
latency
-
context switching
-
memory usage
-
lock contention
-
queue length
-
CPU overhead
without increasing useful throughput.
Concurrency and Database Connections
This is particularly important in backend applications.
Imagine:
1,000 application tasks
|
v
100 DB connections
Only 100 can actually communicate with the database simultaneously.
The other tasks wait.
If you increase the application thread pool from:
100 → 1,000
you haven't increased database capacity.
You may simply have created a larger waiting queue.
This is why thread pools, connection pools, queues, and external rate limits must be considered together.
The Bigger Picture
A production application often looks like this:
HTTP Requests
|
v
Request Handling
|
+---------+---------+
| |
v v
Concurrent Queue
Tasks |
| v
+-----+-----+ Workers
| | | |
v v v v
DB API Cache DB/API
| | |
+-----+-----+
|
v
Response
Concurrency is not simply about threads.
It involves:
Threads
Executors
Queues
Locks
Atomics
Memory visibility
Connection pools
Backpressure
Timeouts
Cancellation
Resource limits
Failure handling
All of these interact.
A Better Way to Think About Concurrent Code
When you encounter concurrent Java code, don't immediately ask:
Which thread executes first?
Instead ask:
What state is shared?
Objects
Collections
Caches
Counters
Configuration
Which operations mutate it?
write
increment
remove
update
What makes those operations safe?
Immutable object?
synchronized?
Lock?
Atomic?
Concurrent collection?
Thread confinement?
Message passing?
What establishes visibility?
volatile?
lock/unlock?
thread start?
join?
other happens-before relationship?
What limits concurrency?
CPU?
Database?
Connection pool?
Memory?
External API?
Queue capacity?
What happens when something fails?
Timeout?
Cancellation?
Retry?
Partial result?
Task failure?
Circuit breaker?
What happens when traffic increases?
Queue grows?
Threads grow?
Memory grows?
Database saturates?
Latency increases?
These questions turn concurrency from an API problem into a system-design problem.
The Java Concurrency Toolkit
You don't need to memorize every concurrency API.
Understand what problem each abstraction solves.
Thread
→ basic unit of execution
ExecutorService
→ manage task execution
Thread Pool
→ limit and reuse platform threads
Virtual Thread
→ lightweight high-concurrency execution
synchronized
→ mutual exclusion + memory visibility
volatile
→ visibility / ordering for a variable
Atomic*
→ atomic lock-free style operations
Lock
→ explicit locking and advanced lock control
ReadWriteLock
→ concurrent reads + exclusive writes
ConcurrentHashMap
→ concurrent map operations
BlockingQueue
→ producer-consumer + backpressure
Semaphore
→ limit concurrent access
CountDownLatch
→ wait for a set of operations
CyclicBarrier
→ synchronize participants at a point
CompletableFuture
→ compose asynchronous operations
ForkJoinPool
→ parallel task decomposition
The Mental Model
Concurrency becomes much easier when you stop thinking about it as:
"multiple threads"
and start thinking about:
Shared State
|
+------+------+
| |
Safety Visibility
| |
+-----+-----+ |
| | | |
Locks Atomics Immutable
|
v
Coordination
|
+---+---+-------+
| | | |
Queue Pool Future Semaphore
|
v
Resource Limits
|
+------+------+-------+
CPU DB Network
The fundamental challenge is not creating threads.
The fundamental challenge is controlling shared state, execution, coordination, and resources so that many tasks can make progress without corrupting state or overwhelming the system.
Your Concurrency Toolkit
If you understand these concepts, you have the foundation needed to reason about almost any Java concurrency problem:
-
Concurrency vs parallelism
-
Threads and their lifecycle
-
Race conditions
-
Atomicity
-
Visibility
-
Ordering
-
Java Memory Model
-
Happens-before
-
synchronized -
volatile -
Atomic classes and CAS
-
Locks and
ReadWriteLock -
ExecutorService and thread pools
-
CompletableFuture -
ForkJoinPool
-
Concurrent collections
-
Producer-consumer
-
Blocking queues
-
Semaphores
-
Latches and barriers
-
Deadlocks
-
Livelocks
-
Starvation
-
Interruption and cancellation
-
ThreadLocal
-
Virtual threads
-
Backpressure
-
Resource limits
-
Contention and performance
Once these ideas become clear, Java's concurrency APIs stop looking like a collection of unrelated classes.
They become different tools for solving the same fundamental problem:
How can multiple tasks make progress safely, efficiently, and predictably?