- How to Create a Thread in Java
- Thread Lifecycle in Java
- Thread Synchronization in Java
Introduction
Once you start running multiple threads, you quickly run into a fundamental problem: threads share memory, and without any coordination, they can read and write the same data at the same time. The results are unpredictable — values get corrupted, updates are silently lost, and bugs appear only under load, making them extremely hard to reproduce.
Synchronization is the mechanism Java provides to solve this. It lets you define regions of code that only one thread can execute at a time, and it guarantees that changes made by one thread become visible to others at the right moment.
This article covers the core tools Java gives you: the synchronized keyword (both methods and blocks), and the volatile modifier.
Before reading this article, it helps to be familiar with how to create a thread in Java and the thread lifecycle, particularly the BLOCKED and WAITING states that a thread enters when synchronization is at work.
1. The Problem: Race Conditions and Memory Visibility
Before looking at the solutions, it is worth understanding exactly what can go wrong without synchronization. There are two distinct categories of problems.
- Race conditions occur when the correctness of a program depends on the relative timing of multiple threads. The classic example is incrementing a shared counter. What looks like a single operation —
count++— is actually three steps: read the current value, add one, and write the result back. If two threads interleave these steps, one thread’s write can overwrite the other’s, causing an update to be silently lost. - Memory visibility is a separate but related problem. Even when only one thread writes to a variable, and another only reads it, there is no guarantee — without synchronization — that the reader will see the latest value. Modern CPUs cache values in registers and write buffers. Without an explicit happens-before relationship, the JVM is free to let a thread read a stale cached value indefinitely.
The following snippet demonstrates a race condition. Two threads each increment a shared counter 10,000 times. The expected result is 20,000, but because neither thread coordinates with the other, the actual result is non-deterministic and frequently lower:
// Race condition: unsynchronized counter shared between two threads
int[] counter = {0};
Thread t1 = new Thread(() -> { for (int i = 0; i < 10_000; i++) counter[0]++; });
Thread t2 = new Thread(() -> { for (int i = 0; i < 10_000; i++) counter[0]++; });
t1.start(); t2.start();
t1.join(); t2.join();
// On a multi-core machine, the actual result is frequently less than 20000
System.out.println("Expected: 20000, Actual: " + counter[0]);
As Brian Goetz writes in Java Concurrency in Practice:
“In the absence of synchronization, the compiler, processor, and runtime can do some downright surprising things to the order in which operations appear to execute.”
With that foundation in place, let us look at the tools Java provides to fix these problems.
2. The synchronized Keyword
The synchronized keyword is Java’s most fundamental synchronization mechanism. It delivers two guarantees simultaneously:
- Mutual exclusion — only one thread can hold a given lock at a time, so
synchronizedregions execute atomically with respect to othersynchronizedregions that use the same lock. - Visibility — when a thread releases a lock, all changes it made are flushed to main memory; when a thread acquires a lock, it reads from main memory, ensuring it sees the latest values written by any thread that previously held that lock.
Every Java object has an implicit intrinsic lock (also called a monitor lock). The synchronized keyword works by acquiring this lock before entering the protected region and releasing it on exit — even if an exception is thrown.
2.1 Synchronized Methods
The simplest way to use synchronized is to apply it to an entire method. When an instance method is declared synchronized, the intrinsic lock of the current object (this) is acquired for the duration of the call. As a result, only one thread can execute any synchronized instance method on the same object at a time.
// Synchronized method: the intrinsic lock of 'this' guards the entire method body
class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++; // read-modify-write is now atomic
}
public synchronized int getCount() {
return count; // guaranteed to return the latest written value
}
}
// Usage
SafeCounter counter = new SafeCounter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 10_000; i++) counter.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 10_000; i++) counter.increment(); });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("Count: " + counter.getCount()); // always 20000
The synchronized method approach is straightforward to read. However, it locks the entire object for the full duration of every method call. If a method does work that does not actually require the lock — such as I/O or an expensive computation — other threads are forced to wait unnecessarily. In those cases, a synchronized block is a better fit.
2.2 Synchronized Blocks
A synchronized block lets you specify exactly which lines of code require mutual exclusion and exactly which lock object to use. This finer granularity reduces contention and improves throughput whenever not all the work in a method needs to be protected.
Using a dedicated private lock object — rather than this — is an important practice here. It prevents external code from accidentally acquiring the same lock, which could otherwise cause deadlocks or unexpected interference.
// Synchronized block with a dedicated private lock object
class FineGrainedCounter {
private int count = 0;
private final Object lock = new Object(); // private — not accessible externally
public void increment() {
synchronized (lock) {
count++; // only the critical section is protected
}
// Code outside the block runs without holding the lock
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}
// Usage — same result as SafeCounter, always 20000
FineGrainedCounter counter = new FineGrainedCounter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 10_000; i++) counter.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 10_000; i++) counter.increment(); });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("Count: " + counter.getCount()); // always 20000
3. The volatile Keyword
The volatile keyword addresses the memory visibility problem without providing mutual exclusion. Declaring a field volatile guarantees that every read of that field observes the latest value written by any thread. The JVM achieves this by bypassing CPU caches for volatile reads and writes, ensuring they go directly to and from main memory.
volatile is the right tool when:
- Only one thread writes to the variable (or the write is inherently atomic, such as assigning a reference or a
boolean) - Other threads only read it
- You do not need compound operations (such as read-modify-write) to be atomic
A common use case is a stop flag — one thread sets it to signal another to shut down gracefully:
// volatile flag: one thread writes; another reads — visibility is all that's needed
class Worker implements Runnable {
private volatile boolean running = true; // volatile ensures the flag is always fresh
public void stop() {
running = false; // the signalling thread writes the flag
}
@Override
public void run() {
while (running) {
try {
Thread.sleep(10); // simulate a small unit of work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
System.out.println("Worker stopped: " + Thread.currentThread().getName());
}
}
// Usage
Worker worker = new Worker();
Thread workerThread = new Thread(worker, "worker-thread");
workerThread.start();
Thread.sleep(50); // let the worker run briefly
worker.stop(); // signal it to stop via the volatile flag
workerThread.join();
It is important to understand what volatile does not do. Even if count is declared volatile, the three-step sequence of count++ is still not atomic. Two threads can still interleave their reads and writes and produce an incorrect result. For compound read-modify-write operations, you need synchronized or the atomic classes in java.util.concurrent.atomic.
4. Deadlocks and How to Avoid Them
Whenever two or more threads each hold a lock that the other needs, they wait for each other forever. This is a deadlock — one of the most disruptive concurrency bugs, because the program simply hangs without any error or exception.
The classic scenario involves two locks acquired in opposite order:
- Thread A holds Lock 1 and waits for Lock 2
- Thread B holds Lock 2 and waits for Lock 1
Neither thread can proceed. The JVM does not detect or resolve deadlocks automatically, so the threads block indefinitely.
The most reliable prevention strategy is consistent lock ordering: whenever code needs to acquire multiple locks, always acquire them in the same fixed order everywhere. If every thread always acquires Lock 1 before Lock 2, the circular dependency that causes deadlock can never form.
The following snippet applies this principle to a bank transfer operation. Two threads transfer money in opposite directions simultaneously. Without ordering, they would deadlock. With ordering, they always acquire the lower-id account’s lock first:
// Deadlock prevention: always acquire locks in order of account ID
class Account {
final int id;
private int balance;
Account(int id, int balance) { this.id = id; this.balance = balance; }
static void transfer(Account from, Account to, int amount) throws InterruptedException {
// Impose a total order on lock acquisition — lower id is always locked first
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
synchronized (first) {
Thread.sleep(10); // simulate work while holding first lock
synchronized (second) {
from.balance -= amount;
to.balance += amount;
System.out.printf("Transferred %d: Account %d -> Account %d%n",
amount, from.id, to.id);
}
}
}
}
// Usage — transfers in opposite directions; no deadlock because lock order is consistent
Account a1 = new Account(1, 1000);
Account a2 = new Account(2, 1000);
Thread t1 = new Thread(() -> {
try { Account.transfer(a1, a2, 100); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "transfer-1-to-2");
Thread t2 = new Thread(() -> {
try { Account.transfer(a2, a1, 200); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "transfer-2-to-1");
t1.start(); t2.start();
t1.join(); t2.join();
Beyond lock ordering, the broader lesson is to keep critical sections as small as possible. The less time a thread holds a lock, the fewer opportunities there are for circular dependencies to form — and the less contention other threads experience.
5. Best Practices for Thread Synchronization
Having covered the core mechanisms, it is helpful to consolidate the guidelines that make synchronized code safer and easier to reason about.
5.1 Keep Synchronized Regions as Small as Possible
Holding a lock longer than necessary reduces parallelism and increases contention. Move any work that does not touch shared state — I/O, logging, computation on local variables — outside the synchronized block. Only the actual read or write of shared data needs to be protected.
5.2 Always Use a Private Lock Object
Synchronizing on this or on a publicly accessible field exposes your lock to external code, which can accidentally acquire it and cause interference or deadlock. Instead, always use a dedicated private final Object lock = new Object() that no external code can reach.
5.3 Consider Higher-Level Abstractions for Production Code
The synchronized keyword is low-level and, when used carelessly, error-prone. For most production use cases, the classes in java.util.concurrent — ReentrantLock, Semaphore, CountDownLatch, BlockingQueue — are easier to use correctly and offer additional features such as timed lock attempts and fair scheduling.
The following program demonstrates these best practices together. String formatting happens outside the lock; only the shared counter update and the print need mutual exclusion:
// Best practices: private lock + minimal synchronized region
class SafeLogger {
private final Object logLock = new Object(); // private — inaccessible to external code
private int messageCount = 0;
public void log(String message) {
// Build the log entry outside the lock — this touches only local variables
String entry = "[" + Thread.currentThread().getName() + "] " + message;
synchronized (logLock) {
// Only the shared counter update and the print need mutual exclusion
messageCount++;
System.out.println(entry + " (msg #" + messageCount + ")");
}
}
}
// Usage
SafeLogger logger = new SafeLogger();
Thread t1 = new Thread(() -> logger.log("Hello from thread 1"), "t1");
Thread t2 = new Thread(() -> logger.log("Hello from thread 2"), "t2");
t1.start(); t2.start();
t1.join(); t2.join();
Conclusion
Thread synchronization is the foundation of correct concurrent programming in Java. The synchronized keyword eliminates race conditions by providing mutual exclusion and establishing visibility guarantees. The volatile keyword addresses visibility alone — at lower cost — for the specific case of single-writer, multi-reader fields. And consistent lock ordering is the practical technique that prevents deadlocks before they happen.
Getting synchronization right requires discipline: keep critical sections small, use private lock objects, and acquire multiple locks in a fixed order. These habits prevent the most common synchronization bugs — race conditions, stale reads, and deadlocks.
You can find the complete code of this article on GitHub
