Java Synchronization

In the last lesson you learned about the Java thread lifecycle. Threads are powerful, but they bring a danger that catches almost everyone the first time. When two threads change the same data at the same moment, they can step on each other and produce wrong results. Java’s fix is synchronization, which makes shared data safe to touch from many threads.

πŸ€” The race condition problem

Picture two threads, each adding to the same counter a thousand times. You expect 2000. But without protection, you often get less. How can 1000 plus 1000 give you 1873?

The reason is that count++ is not one step. The computer does it in three:

  • It reads the current value of count from memory.
  • It adds one to that value in the CPU.
  • It writes the new value back to memory.

Now the timing goes wrong. Both threads read 41 at almost the same time. Both add one and get 42. Both write 42 back. Two increments happened, but the counter moved up by one. One update was silently lost.

This is a race condition. The result depends on the timing of threads racing to touch shared data. The output is wrong and changes from run to run, so you cannot reproduce it on demand or catch it easily in testing.

Here is the unsafe counter that causes the trouble:

class Counter {
int count = 0;
void increment() {
count++; // ❌ NOT safe: read, add, write can interleave
}
}

Two threads calling increment at once can clobber each other’s update. The fix: let only one thread touch count at a time. While one is reading, adding, and writing, no other thread should start the same work.

πŸ”‘ An analogy: the single bathroom key

Picture an office with one bathroom and one key on a hook by the door.

  • A person who needs the bathroom takes the key, goes in, and locks the door.
  • Anyone else who arrives waits, because the key is gone.
  • When the first person comes out, they hang the key back. Now the next person can take it.

The bathroom is your shared data. The key is the lock. Java works the same way. A thread grabs a lock before touching shared data and releases it when done. While it holds the lock, every other thread waits.

🧩 What is synchronization?

Synchronization means controlling access to shared data so only one thread at a time runs a protected piece of code. That protected piece is called a critical section, the small region that reads or writes shared data. Key facts:

  • Synchronization is about taking turns, not running in parallel, for the protected part.
  • The protected part should be small, so threads wait as little as possible.
  • It only matters for data that is shared and changing. Data nobody else touches needs no protection.

πŸ”’ The synchronized keyword

The simplest way to synchronize is the synchronized keyword on a method. A synchronized method runs by only one thread at a time, per object. Let’s put it on our counter and fix the bug.

The example below runs two threads, each incrementing the same counter a thousand times, then prints the total:

class Counter {
private int count = 0;
public synchronized void increment() { // βœ… only one thread at a time
count++;
}
public synchronized int getCount() {
return count;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) counter.increment();
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join(); // wait for t1 to finish
t2.join(); // wait for t2 to finish
System.out.println("Final count: " + counter.getCount());
}
}

Reading it:

  • The increment method is synchronized, so only one thread runs it at a time on this counter.
  • We start two threads with the same task, each calling increment a thousand times.
  • The join() calls make main wait until both workers finish before reading the count.
  • The threads take turns, so no read-add-write ever overlaps another. No update is lost.

The result is exactly 2000, every single run.

Output

Final count: 2000

Without synchronized, the same program would print a different wrong number below 2000 each run. One keyword turned a flaky program into a correct one.

🧱 The lock object behind synchronized

When you write synchronized, Java uses a lock for you. Every object has a built-in lock, called its monitor, like a bathroom key attached to each object.

  • A synchronized instance method uses the lock of this, the object the method is called on.
  • A synchronized static method uses the lock of the whole Class object.
  • Two threads only block each other if they compete for the same lock.
  • Synchronizing on object A and object B means no waiting, because there are two different keys.

🧱 Synchronized blocks

Synchronizing a whole method can be more than you need. Sometimes only two lines touch shared data and the rest is safe. A synchronized block locks only a small part of the code, so threads wait less.

In the example below, only the line that changes shared data sits inside the block:

class Counter {
private int count = 0;
private final Object lock = new Object(); // a dedicated lock object
public void increment() {
doSomeUnrelatedWork(); // runs freely, not locked
synchronized (lock) {
count++; // βœ… only this line is protected
}
doMoreUnrelatedWork(); // also runs freely
}
}

The synchronized (lock) block protects just count++. The rest of the method runs without the lock, so other threads can do unrelated work at the same time. You get correctness where you need it and speed everywhere else.

The lock is a private final Object. A dedicated lock that nobody outside the class can see is a safe, common habit, so no other code can accidentally grab the same lock.

Synchronize the right amount

Synchronizing too little leaves race conditions in place. Synchronizing too much makes threads wait needlessly and slows the program down. Protect exactly the code that touches shared data, no more and no less. A focused synchronized block is often the right balance.

πŸ‘€ volatile vs synchronized

There is a second, quieter problem, separate from the race condition: visibility. Each thread can keep its own cached copy of a variable for speed. So one thread might change a value while another keeps reading a stale copy and never sees the update.

The volatile keyword fixes visibility. A volatile field is always read from main memory and written straight back, so every thread sees the latest value. A common use is a flag one thread sets to tell another to stop:

class Worker {
private volatile boolean running = true; // βœ… updates are visible to all threads
public void run() {
while (running) {
// do work...
}
}
public void stop() {
running = false; // another thread sees this change right away
}
}

Here is the key difference between the two tools, in plain terms:

  • volatile guarantees visibility. Every thread sees the newest value of the field. But it does not make read-add-write safe.
  • synchronized guarantees mutual exclusion. Only one thread runs the protected code at a time, and it also makes changes visible.

When is each enough? Use volatile for a single flag one thread writes and others read, like a stop signal. Use synchronized for read-then-modify-then-write, like our counter. volatile alone would not fix count++, because two threads could still read the same value and lose an update. Visibility is not the same as taking turns.

⚠️ A note on deadlock

Locks can create another problem, called deadlock. Two threads each hold a lock the other needs, so both wait forever. Picture Alex holding key A and waiting for key B, while Riya holds key B and waits for key A.

  • The most reliable fix is a consistent lock order. If every thread grabs lock A before lock B, no cycle can form.
  • Keeping the number of locks small helps.
  • Not calling unknown code while holding a lock helps too.

🧰 Higher-level tools

The synchronized keyword is the foundation, but Java gives you friendlier tools for common cases:

  • AtomicInteger makes counters safe with no synchronized code. Its incrementAndGet() does read-add-write as one unbreakable step.
  • ReentrantLock (in java.util.concurrent.locks) is a lock you control by hand, with extras like trying for a lock and giving up if busy.
  • Thread-safe collections like ConcurrentHashMap handle the locking for you.

For a plain counter, AtomicInteger is often cleaner. Learn synchronized first, because it explains why these tools exist.

βš–οΈ When do you need synchronization?

The rule is about shared, changing data:

  • You need it when multiple threads read and write the same data, like a shared counter, list, or account balance.
  • You do not need it when threads work on separate data.
  • You do not need it for data only ever read and never changed after setup.

If threads never touch the same changing data, there is no race condition and nothing to protect.

⚠️ Common Mistakes

A few synchronization slip-ups to watch for.

  • Forgetting to synchronize shared writes. This is the classic bug. If two threads change the same data without protection, you get a race condition and wrong, unpredictable results.
// ❌ Wrong: shared write with no protection, updates get lost
void increment() {
count++;
}
// βœ… Right: only one thread at a time touches count
synchronized void increment() {
count++;
}
  • Locking on different objects. Two threads only take turns if they share the same lock. Locking on separate objects gives no protection at all, even though the code looks synchronized.
// ❌ Wrong: each thread locks a different object, so they never wait for each other
synchronized (new Object()) {
count++;
}
// βœ… Right: one shared lock that all threads compete for
synchronized (sharedLock) {
count++;
}
  • Over-synchronizing. Wrapping a lock around everything makes threads wait too much and removes the benefit of using threads in the first place. Protect only the critical section.

  • Reading the result before threads finish. Use join() to wait for worker threads, or you may read the data while it is still being updated and see a half-done value.

βœ… Best Practices

Habits for safe multithreading.

  • Synchronize every access to shared mutable data. If one thread writes it, the reads need protection too, or they may see a stale or half-written value.
  • Prefer focused synchronized blocks. Lock only the critical section, not whole methods, when you can.
  • Use one clear, private lock object. A private final Object lock keeps your locking under your own control.
  • Always grab multiple locks in the same order. Consistent lock order is the simplest way to prevent deadlock.
  • Use volatile for simple flags, and synchronized for read-modify-write work.
  • Reach for AtomicInteger and concurrent collections for common cases instead of hand-writing locks.
  • Use join() to wait for threads so work is done before you read shared results.
  • Avoid sharing when possible. Data that is not shared needs no synchronization at all.

🧩 What You’ve Learned

Nicely done. Let’s recap synchronization.

  • βœ… A race condition happens when threads change shared data at the same time, because steps like count++ are really read, add, write and can interleave.
  • βœ… Synchronization lets only one thread at a time run a protected critical section, like a single bathroom key.
  • βœ… The synchronized keyword on a method allows one thread at a time, using the object’s monitor lock (this for instance methods, the Class for static methods).
  • βœ… A synchronized block with a private lock object protects just a small section, which is more efficient.
  • βœ… volatile gives visibility across threads, while synchronized gives mutual exclusion; each fits a different job.
  • βœ… Deadlock happens when threads hold locks each other needs; a consistent lock order prevents it.
  • βœ… Tools like AtomicInteger and concurrent collections handle common cases for you.

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    What is a race condition?

    Why: A race condition is when the result depends on threads racing to touch shared data, causing bugs.

  2. 2

    What does the synchronized keyword ensure?

    Why: synchronized allows just one thread at a time into the protected code, preventing interference.

  3. 3

    When do you need synchronization?

    Why: Synchronization is needed when threads share mutable data; separate data needs none.

  4. 4

    How does volatile differ from synchronized?

    Why: volatile guarantees every thread sees the latest value, but synchronized provides mutual exclusion for read-modify-write work.

πŸš€ What’s Next?

Locks keep shared data safe, but they bring a danger of their own. Two threads can each hold a lock the other needs and freeze forever. Next you will learn what a deadlock is, why it happens, and how to prevent it. Let’s learn it.

Java Deadlock

Share & Connect