Java Full Course: Mastering the Language Synchronization and Inter-Thread Communication

Synchronization and Inter-Thread Communication

1. Introduction

In a multithreaded environment, multiple threads often share the same resources (e.g., variables, objects, files). When threads access shared data concurrently, race conditions can occur, leading to inconsistent or corrupted states. Synchronization is the mechanism that controls access to shared resources, ensuring that only one thread can execute a critical section at a time.

Inter‑thread communication allows threads to coordinate their actions—for example, one thread waits for a condition to be satisfied before proceeding, while another thread signals that the condition is met.

Java provides built‑in synchronization using monitors (associated with every object) and methods wait(), notify(), notifyAll(). For more advanced concurrency, the java.util.concurrent package offers higher‑level constructs (locks, semaphores, queues, etc.), but understanding the low‑level primitives is essential.


Part 1: Synchronization

2. The Need for Synchronization

Consider a simple counter incremented by multiple threads without synchronization:

class Counter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } } public class UnsafeExample { public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); }); Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Final count: " + counter.getCount()); // often not 2000 } }

The count++ operation is not atomic; it consists of read, increment, and write. Without synchronization, two threads may interfere, resulting in lost updates.


3. Synchronized Methods

Java uses intrinsic locks (monitors). Every object has an intrinsic lock. A thread that enters a synchronized method acquires the lock on the object (for instance methods) or on the class (for static methods). No other thread can enter any synchronized method on the same object until the lock is released.

class Counter { private int count = 0; public synchronized void increment() { // acquires lock on this count++; } public synchronized int getCount() { // acquires lock on this return count; } }

Now the two threads cannot interleave increment() calls because they compete for the same object lock.

Key points:

  • Only one thread can execute any synchronized instance method of an object at a time.
  • Other threads trying to execute any synchronized method on the same object will block until the lock is released.
  • The lock is released when the method exits (normally or via exception).

4. Synchronized Blocks

Synchronized blocks allow finer control: you specify the object whose lock to acquire. This can improve performance by reducing the scope of synchronization.

class Counter { private int count = 0; private final Object lock = new Object(); // dedicated lock object public void increment() { synchronized (lock) { count++; } } public int getCount() { synchronized (lock) { return count; } } }

Why use a separate lock object?

  • Avoid exposing this to external synchronized code.
  • Allows multiple independent locks within the same class.

Static synchronized methods lock on the Class object:

public static synchronized void staticMethod() { ... } // equivalent to public static void staticMethod() { synchronized (ClassName.class) { ... } }

5. Important Concepts

  • Reentrant synchronization – A thread can re‑enter a lock it already holds (e.g., a synchronized method calling another synchronized method on the same object).
  • Lock is released only after the synchronized block/method exits, not when wait() is called (covered later).
  • Volatile keyword – Guarantees visibility of changes across threads, but does not guarantee atomicity. Use for simple flags or when only one thread writes and others read.

6. Common Pitfalls with Synchronization

PitfallExplanation / Solution
DeadlockTwo or more threads are blocked forever, each waiting for a lock held by the other. Avoid by consistent lock ordering or using tryLock from java.util.concurrent.locks.
Liveness issues (starvation)A thread never gets CPU time because other threads continuously acquire locks. Use fair locks if needed.
Synchronizing on a mutable objectIf the lock object reference changes, threads may not synchronize on the same lock. Use final lock objects.
Over‑synchronizationHolding locks longer than necessary degrades performance. Synchronize only the critical section.
Using this as lock in a class that is extendedSubclasses can inadvertently use the same lock, causing interference. Prefer private lock objects.

Part 2: Inter‑Thread Communication

7. The wait(), notify(), notifyAll() Methods

These methods are defined in java.lang.Object and allow threads to communicate:

  • wait() – Causes the current thread to release the lock on the object and enter the WAITING state until another thread calls notify() or notifyAll() on the same object (or a timeout).
  • notify() – Wakes up one thread that is waiting on the object’s monitor.
  • notifyAll() – Wakes up all threads waiting on the object’s monitor.
Crucial

[!IMPORTANT] Important rules:

  • These methods must be called from within a synchronized context (synchronized method or block) on the same object. Otherwise, IllegalMonitorStateException is thrown.
  • The thread calling wait() releases the lock and waits. When it is notified, it re‑acquires the lock before returning from wait().

8. Producer‑Consumer Example

A classic example of inter‑thread communication: one thread produces data, another consumes it, using a shared buffer.

class Buffer { private int data; private boolean empty = true; public synchronized void produce(int value) throws InterruptedException { while (!empty) { // use while, not if, to avoid spurious wakeups wait(); // wait for buffer to become empty } data = value; empty = false; System.out.println("Produced: " + value); notifyAll(); // wake up consumer } public synchronized int consume() throws InterruptedException { while (empty) { wait(); // wait for data } empty = true; System.out.println("Consumed: " + data); notifyAll(); // wake up producer return data; } } public class ProducerConsumer { public static void main(String[] args) { Buffer buffer = new Buffer(); Thread producer = new Thread(() -> { for (int i = 1; i <= 5; i++) { try { buffer.produce(i); Thread.sleep(500); } catch (InterruptedException e) {} } }); Thread consumer = new Thread(() -> { for (int i = 1; i <= 5; i++) { try { buffer.consume(); Thread.sleep(800); } catch (InterruptedException e) {} } }); producer.start(); consumer.start(); } }

Key points:

  • Use while around wait() to handle spurious wakeups (threads can wake up without notify).
  • Always call notifyAll() (or notify()) after changing the condition.
  • Synchronize on the same object (here, buffer) for both producer and consumer.

9. Why wait() Must Be in a Loop

A thread can wake up from wait() without an explicit notify (spurious wakeup). By checking the condition in a loop, you ensure that the thread only proceeds when the condition is actually satisfied.

synchronized (lock) { while (!condition) { lock.wait(); } // proceed }

10. notify() vs notifyAll()

  • notify() – Wakes up one arbitrarily chosen waiting thread. Use it when only one thread can make progress (e.g., multiple consumers but only one item). However, if the wrong thread wakes up, it may wait again.
  • notifyAll() – Wakes all waiting threads. They will compete for the lock, and only one will proceed. This is safer and often preferred, though it may cause some overhead.

11. Modern Alternatives

The java.util.concurrent package provides higher‑level synchronization and communication utilities:

  • BlockingQueue (e.g., ArrayBlockingQueue, LinkedBlockingQueue) – Handles producer‑consumer without explicit wait/notify.
  • CountDownLatch, CyclicBarrier, Semaphore – For coordinating threads.
  • Lock and Condition – Offer more flexible locking (e.g., ReentrantLock, Condition with multiple await/signal).

Example using BlockingQueue:

import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; public class ProducerConsumerQueue { public static void main(String[] args) { BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(1); Thread producer = new Thread(() -> { for (int i = 1; i <= 5; i++) { try { queue.put(i); System.out.println("Produced: " + i); Thread.sleep(500); } catch (InterruptedException e) {} } }); Thread consumer = new Thread(() -> { for (int i = 1; i <= 5; i++) { try { int value = queue.take(); System.out.println("Consumed: " + value); Thread.sleep(800); } catch (InterruptedException e) {} } }); producer.start(); consumer.start(); } }

The put() and take() methods block automatically and handle synchronization internally.


12. Common Pitfalls

PitfallExplanation / Solution
Calling wait() or notify() without synchronizationThrows IllegalMonitorStateException. Always synchronize on the same object.
Using if instead of while around wait()Spurious wakeups or missed signals can cause incorrect behavior. Use while.
Not using notifyAll() when multiple threads waitnotify() may wake the wrong thread, causing deadlock. Use notifyAll() unless you are certain.
Forgetting to re‑check condition after waitThe condition may have changed again. Loop ensures correctness.
Deadlock due to nested locksBe careful with lock ordering. Use tryLock to avoid indefinite blocking.

13. Best Practices

  • Prefer java.util.concurrent utilities over low‑level wait/notify for new code. They are easier to use and less error‑prone.
  • Synchronize only on final objects to avoid accidental reassignment of the lock.
  • Keep synchronized blocks as small as possible to reduce contention.
  • Use volatile for simple flags to avoid synchronization overhead.
  • Name your threads to help debugging.
  • Always handle InterruptedException properly (restore interrupt status if you cannot throw it).

14. Key Points to Remember

  • Synchronization prevents race conditions by controlling access to shared resources.
  • Every object has an intrinsic lock; synchronized methods/blocks acquire that lock.
  • wait() releases the lock and puts the thread into WAITING state.
  • notify()/notifyAll() wakes waiting threads; they must re‑acquire the lock before proceeding.
  • Always call wait() in a loop to check the condition.
  • Modern concurrency utilities (BlockingQueue, Lock, Semaphore, etc.) provide safer and more efficient alternatives.
Hi! Need help with studies? 👋
AI