Synchronization in Java
By the Syncfusion DevTutorials team · Last updated
Keep shared data correct across threads: race conditions, thread safety, synchronized methods and blocks, and how to avoid deadlocks.
Think about two people trying to use the same lockable cupboard at once.
If both open the door and move things around at the same time, the contents can end up in a mess. A lock makes them wait their turn. One person finishes, then the next person goes in.
Java threads need the same kind of turn-taking when they share data. If two threads change the same object at the same time, the result can be wrong. Synchronization is the rule that makes them wait their turn.
In simple words: synchronization keeps shared data safe by letting only one thread use the protected part at a time.
What You Will Learn
In this lesson, you will learn:
- What a race condition is and why it causes wrong results.
- What thread safety means.
- How the
synchronizedkeyword works. - How to write synchronized methods.
- How to write synchronized blocks, and why they are often better.
- What a deadlock is and how to avoid one.
Start With a Working Example
Read the code, then look at the output below it.
Two threads add to one shared counter, safely
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable addThousands = () -> {
for (int i = 0; i < 10_000; i++) {
counter.increment();
}
};
Thread a = new Thread(addThousands);
Thread b = new Thread(addThousands);
a.start();
b.start();
a.join();
b.join();
System.out.println("Expected: 20000");
System.out.println("Actual: " + counter.getCount());
}
}
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Output:
Expected: 20000
Actual: 20000
Two threads each add 10,000 to the same counter. Because increment() is synchronized, only one thread can run it at a time, so no update is lost.
Try This
On your own computer, remove the word synchronized from increment() and run the program several times. The total will often be less than 20,000, and it may change each time. The next section explains why.
The in-browser Java runtime runs threads one at a time, so it rarely shows the problems in this lesson. To see a real race condition, run the unsynchronized examples on a computer with a full JVM and more than one CPU core.
Race Conditions
A race condition happens when two or more threads use the same data at the same time, and the result depends on which thread gets there first.
The problem is that count++ looks like one action, but it really has three parts:
- Read the current value of
count. - Add one.
- Write the new value back.
If two threads do those steps at the same time, the steps can overlap:
count = 5
Thread A: read count → 5
Thread B: read count → 5 (A has not written yet)
Thread A: add 1 → 6
Thread B: add 1 → 6
Thread A: write count → 6
Thread B: write count → 6
count = 6 (should be 7, one increment was lost)
Both threads added one, but the counter only went up by one. This is called a lost update.
Think of two people writing the same total on a whiteboard. Both read 5, both add 1 in their heads, and both write 6. The board should say 7. Read the code, then look at the output below it.
Without synchronization, increments can be lost
public class Main {
public static void main(String[] args) throws InterruptedException {
UnsafeCounter counter = new UnsafeCounter();
Runnable addMany = () -> {
for (int i = 0; i < 100_000; i++) {
counter.increment();
}
};
Thread a = new Thread(addMany);
Thread b = new Thread(addMany);
a.start();
b.start();
a.join();
b.join();
System.out.println("Expected: 200000");
System.out.println("Actual: " + counter.count);
}
}
class UnsafeCounter {
int count = 0;
void increment() {
count++;
}
}
On a computer with several cores, the output usually looks something like this, with a different "Actual" number every run:
Expected: 200000
Actual: 137492
Sometimes it prints the right number. That is what makes race conditions so dangerous: the bug appears and disappears.
Why Race Conditions Are Hard to Find
- They depend on timing, so they appear randomly.
- Adding a
printlnto debug them changes the timing and can hide them. - They may only show up under heavy load, such as on a busy server.
The cure is not to test harder. It is to make the code thread-safe from the start.
Thread Safety
Code is thread-safe when it gives the correct result even when many threads use it at the same time.
There are a few main ways to make code thread-safe:
| Approach | Idea | Example |
|---|---|---|
| Do not share | Each thread uses its own data | Local variables inside run() |
| Do not change | Shared data never changes after it is created | String, records, final fields |
| Take turns | Only one thread changes the data at a time | synchronized |
| Use safe classes | Classes built to handle many threads | AtomicInteger, ConcurrentHashMap |
Local variables are always thread-safe, because each thread has its own stack and its own copy.
Immutable objects are always thread-safe, because nobody can change them. That is one more reason immutability is useful.
The rest of this lesson is about taking turns with synchronized.
synchronized Keyword
The synchronized keyword makes threads take turns running a piece of code.
It works with a lock, also called a monitor. Every Java object has its own lock.
- Before a thread runs synchronized code, it must get the lock.
- If another thread already holds the lock, the new thread waits in the
BLOCKEDstate. - When the first thread leaves the synchronized code, it releases the lock, and one waiting thread can take it.
This guarantees that only one thread at a time runs code protected by the same lock. That region of code is called a critical section.
synchronized also helps each thread see the latest values written by another thread. Without it, a thread may keep reading an old copy of a value.
There are two ways to use it: on a whole method or on a block of code.
Synchronized Methods
Add synchronized to a method's declaration, and the whole method becomes a critical section.
public synchronized void deposit(int amount) { ... }
- For an instance method, the lock is the object itself,
this. - For a static method, the lock is the class.
So if one thread is inside any synchronized instance method of an object, no other thread can enter any synchronized instance method of that same object until it leaves. Other objects of the same class have their own locks, so they are not affected. Read the code, then look at the output below it.
Synchronized deposits and withdrawals keep the balance correct
public class Main {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount(1000);
Thread salary = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
account.deposit(10);
}
});
Thread bills = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
account.withdraw(5);
}
});
salary.start();
bills.start();
salary.join();
bills.join();
System.out.println("Final balance: " + account.getBalance());
}
}
class BankAccount {
private int balance;
BankAccount(int balance) {
this.balance = balance;
}
public synchronized void deposit(int amount) {
balance += amount;
}
public synchronized void withdraw(int amount) {
if (amount <= balance) {
balance -= amount;
}
}
public synchronized int getBalance() {
return balance;
}
}
Output:
Final balance: 6000
Start with 1000. Add 1000 × 10 = 10,000. Take away 1000 × 5 = 5,000. Final balance: 6,000.
deposit(), withdraw() and getBalance() all lock the same account object. So a deposit and a withdrawal can never run at the same time, and no update is lost.
Check-Then-Act Needs a Lock Too
Look at withdraw(). It checks the balance, then acts by taking money out. If two threads could run it at once, both might pass the check before either takes the money, and the balance could go below zero.
Because the whole method is synchronized, the check and the action happen together, with no other thread in between.
Easy Way to Remember: if two or more threads share data, and at least one of them changes it, every read and write of that data needs the same lock.
Synchronized Blocks
A synchronized block locks only part of a method.
synchronized (lockObject) {
// critical section
}
You choose which object's lock to use. Often it is this, or a private object made just for locking.
Why use a block instead of a whole method?
- Better speed. Threads only wait for the small part that really needs protection. Slow work, like printing or calculating, can happen outside the lock.
- More control. You can use a different lock for different data, so unrelated work does not block.
- Safer locks. A
private final Object lockcannot be locked by outside code by mistake.
Read the code, then look at the output below it.
Only the shared part is locked
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) throws InterruptedException {
TicketCounter counter = new TicketCounter();
Thread morning = new Thread(() -> counter.sellMany("morning", 300));
Thread evening = new Thread(() -> counter.sellMany("evening", 300));
morning.start();
evening.start();
morning.join();
evening.join();
System.out.println("Tickets sold: " + counter.getSold());
System.out.println("Receipts kept: " + counter.getReceiptCount());
}
}
class TicketCounter {
private final Object lock = new Object();
private int sold = 0;
private final List<String> receipts = new ArrayList<>();
void sellMany(String shift, int howMany) {
for (int i = 1; i <= howMany; i++) {
String receipt = shift + "-" + i; // built outside the lock
synchronized (lock) {
sold++;
receipts.add(receipt);
}
}
}
int getSold() {
synchronized (lock) {
return sold;
}
}
int getReceiptCount() {
synchronized (lock) {
return receipts.size();
}
}
}
Output:
Tickets sold: 600
Receipts kept: 600
Building the receipt text does not touch shared data, so it happens outside the lock. Only updating sold and adding to receipts happen inside it.
ArrayList is not thread-safe on its own. Two threads adding at once could lose items or corrupt the list. Here every add is inside the lock, so it is safe.
| Synchronized method | Synchronized block | |
|---|---|---|
| Locks | The whole method | Only the block |
| Lock used | this (or the class, for static) | Any object you choose |
| Code to write | Just add synchronized | A synchronized (lock) { } block |
| Waiting time for other threads | Longer | Shorter |
| Best for | Short methods that are all critical | Methods with a small critical part |
Deadlocks
A deadlock happens when two or more threads each hold a lock and wait forever for a lock the other one holds. None of them can move.
Think of two people on a narrow bridge, coming from opposite ends. Each waits for the other to move first. Neither does. They stand there forever.
How a Deadlock Happens
Thread 1 Thread 2
───────── ─────────
locks A locks B
waits for B ──── held by ────► (holding B)
(holding A) ◄──── held by ──── waits for A
Both wait forever.
Here is code that can deadlock. Thread 1 locks a then b. Thread 2 locks b then a.
Object a = new Object();
Object b = new Object();
Thread t1 = new Thread(() -> {
synchronized (a) {
sleep(50); // gives t2 time to lock b
synchronized (b) {
System.out.println("t1 has both locks");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (b) {
sleep(50); // gives t1 time to lock a
synchronized (a) {
System.out.println("t2 has both locks");
}
}
});
Run that on a full JVM, and it usually prints nothing and never ends. Each thread holds one lock and waits for the other. The program freezes. It is shown here as plain text, because running it would hang.
A deadlock needs all four of these at the same time:
- Each lock can be held by only one thread.
- A thread holds one lock while waiting for another.
- A lock cannot be taken away from the thread holding it.
- There is a circle of threads, each waiting for the next.
Break any one of these, and a deadlock cannot happen.
How to Avoid Deadlocks
Always Lock in the Same Order
This is the most important rule. If every thread takes lock a before lock b, there can never be a circle of waiting. Read the code, then look at the output below it.
Same lock order for every thread: no deadlock
public class Main {
public static void main(String[] args) throws InterruptedException {
Account asha = new Account(1, 1000);
Account ravi = new Account(2, 1000);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
Transfer.move(asha, ravi, 3);
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
Transfer.move(ravi, asha, 2);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Asha: " + asha.balance);
System.out.println("Ravi: " + ravi.balance);
System.out.println("Total: " + (asha.balance + ravi.balance));
}
}
class Account {
final int id;
int balance;
Account(int id, int balance) {
this.id = id;
this.balance = balance;
}
}
class Transfer {
static void move(Account from, Account to, int amount) {
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
from.balance -= amount;
to.balance += amount;
}
}
}
}
Output:
Asha: 900
Ravi: 1100
Total: 2000
t1 moves money from Asha to Ravi, while t2 moves money the other way. Without care, that is exactly the deadlock pattern.
But move() always locks the account with the smaller id first, whichever direction the money goes. Both threads lock in the same order, so they can never wait for each other in a circle.
Asha sends 100 × 3 = 300 and receives 100 × 2 = 200, so she ends with 900. The total stays 2,000, which proves no money was lost.
More Ways to Avoid Deadlocks
- Hold locks for as short a time as possible. Do slow work outside the lock.
- Avoid holding one lock while asking for another, when you can.
- Use a timeout. The
ReentrantLockclass hastryLock(time, unit), which gives up after a while instead of waiting forever. - Use higher-level tools from
java.util.concurrent, such asConcurrentHashMapandAtomicInteger, which manage their own locking safely.
A deadlocked program shows no error and throws no exception. It just stops making progress. If a multithreaded program freezes, suspect a deadlock, and check that every thread takes its locks in the same order.
| Problem | What happens | Main fix |
|---|---|---|
| Race condition | Threads change shared data at once; results are wrong | Synchronize access to shared data |
| Deadlock | Threads wait for each other's locks forever; the program freezes | Always take locks in the same order |
Common mistakes
The following mistakes are common when sharing data between threads. Each row shows what you see, why it happens and how to fix it.
| Symptom | Cause | Fix |
|---|---|---|
| Totals are too low and change every run | A race condition on shared data | Synchronize every read and write |
| A thread sees an old value | The read was not synchronized | Use the same lock for reads |
| Synchronization seems to do nothing | Each thread locked a different object | Use one shared lock object |
| The program freezes with no error | A deadlock | Take locks in the same order |
| Items go missing from a list | A non-thread-safe collection was shared | Lock around it, or use a concurrent collection |
| The program is slow with many threads | Critical sections are too long | Move slow work outside the lock |
What to Remember
- A race condition happens when threads change shared data at the same time. Results become wrong and unpredictable.
count++is really read, add and write, so two threads can lose an update.- Thread-safe code gives correct results with many threads. Avoid sharing, avoid changing, or take turns.
synchronizeduses an object's lock so only one thread at a time runs the protected code.- A synchronized method locks
thisor the class, if it is static. A synchronized block locks any object you choose, and only for part of a method. - Protect every read and write of shared changing data with the same lock, and keep the locked part short.
- A deadlock is threads waiting for each other's locks forever. Avoid it by always taking locks in the same order.
Next: Optional