2357 words
12 minutes
The Art of Synchronization: Coordinating Resources in Java

The Art of Synchronization: Coordinating Resources in Java#

Concurrency often sits at the heart of modern computer systems. Whether you’re building web servers handling thousands of simultaneous clients, or desktop applications performing background tasks, ensuring that your code remains efficient and correct is essential. Java provides a vast toolkit for concurrent programming, with synchronization mechanisms designed to coordinate access to shared resources without stepping on each other’s toes. This blog post walks through a comprehensive understanding of synchronization in Java, from the most fundamental concepts to more advanced scenarios.


Table of Contents#

  1. Why Synchronization Matters
  2. Threads and Concurrency Basics
  3. The “Synchronized” Keyword
  4. Lock Objects and ReentrantLock
  5. The Wait/Notify Mechanism
  6. Thread-Safe Data Structures and Atomic Classes
  7. Executors and the Concurrency Framework
  8. Advanced Synchronization Techniques
  9. Common Pitfalls and Best Practices
  10. Conclusion

Why Synchronization Matters#

Imagine a scenario where multiple threads access the same counter variable:

  1. Thread A reads the counter and increments it.
  2. Simultaneously, Thread B reads the counter and increments it.
  3. Thread A writes its incremented value back.
  4. Thread B writes its incremented value back.

If the original counter was 0, how many times is it incremented in reality? In an unsynchronized world, both threads might read the same value (0), increment it to 1, and then overwrite each other’s changes. Instead of ending at 2, the counter might end at 1. This phenomenon is called a “race condition,” and it can create subtle, hard-to-debug issues.

Synchronization avoids these pitfalls by making sure data changes happen atomically (together), safely, and in a visible manner to other threads.

Key reasons why synchronization is crucial:

  • Prevents race conditions.
  • Ensures visibility of updated values to other threads.
  • Coordinates complex actions between multiple threads, ensuring a defined order of operations.

Threads and Concurrency Basics#

Before jumping into synchronization specifics, let’s briefly review Java’s concurrency basics.

Creating Threads#

In Java, you typically create a thread by either:

  • Extending the Thread class and overriding run().
  • Implementing the Runnable interface and passing an instance to a Thread.
  • Using the newer concurrency framework (ExecutorService) which manages threads behind the scenes.

Example of creating and starting threads:

public class SimpleThreadExample {
public static void main(String[] args) {
// Using a Thread subclass
Thread threadA = new Thread() {
@Override
public void run() {
System.out.println("Thread A is running.");
}
};
// Using Runnable
Runnable runnableB = () -> System.out.println("Thread B is running.");
Thread threadB = new Thread(runnableB);
threadA.start();
threadB.start();
}
}

Thread Lifecycle#

Each Java thread goes through these primary states:

  1. New: The thread is created but not started (new Thread(runnable)).
  2. Runnable: The thread is eligible to run, though not necessarily running on the CPU at every moment.
  3. Running: The thread is actively executing instructions on the CPU.
  4. Blocked/Waiting: The thread is waiting for a resource or a notification to proceed.
  5. Terminated: The thread has finished execution.

Synchronization directly impacts states when multiple threads vie for resources. Some might be blocked, waiting for locks or other signals.


The “Synchronized” Keyword#

The simplest way to ensure thread safety in Java is by using the synchronized keyword on methods or code blocks.

Synchronized Methods#

When you mark a method as synchronized, you effectively say, “Only one thread can enter this method on a given instance at a time.” For instance:

public class SynchronizedCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
  • increment() and getCount() are synchronized on the this object.
  • Locked code ensures that if Thread A is in increment(), Thread B cannot enter either increment() or getCount() on the same object.

Synchronized Blocks#

If you don’t want to synchronize the entire method, you can synchronize only a portion with a synchronized block:

public class PartialSyncCounter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}
  • This provides more fine-grained control.
  • The lock object (this or a custom lock) ensures only one thread can enter that block at a time.

Intrinsic Locks#

Every Java object has an intrinsic lock (or monitor lock). When you use:

synchronized (someObject) {
// ...
}

you lock on someObject’s intrinsic lock. Another thread trying to synchronize on that same object must wait until the lock is released.

Visibility Guarantees#

Besides ensuring that no two threads execute a synchronized block simultaneously, Java’s memory model guarantees that changes within a synchronized block are visible to other threads that enter a synchronized block guarded by the same lock. This is crucial for consistency across threads.


Lock Objects and ReentrantLock#

While intrinsic locks (via synchronized) are simple and powerful, Java also provides more advanced locks with added flexibility, such as java.util.concurrent.locks.ReentrantLock.

ReentrantLock Basics#

ReentrantLock extends the concept of locks to:

  • Provide a way to lock and unlock explicitly.
  • Offer more control over the lock acquisition process (e.g., tryLock with timeouts).
  • Permit more sophisticated handling of concurrency, such as fairness policies.

Example:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
  • We use lock.lock() at the start of the critical section and lock.unlock() in the finally block to ensure unlock occurs even if an exception is thrown.
  • This pattern ensures resource safety and proper lock release.

Reentrant Behavior#

“Reentrant” means a thread that already owns the lock can reacquire it without deadlocking itself. For example, if a locked method calls another method that also locks the same ReentrantLock, the same thread can continue.

Fair vs. Non-Fair Locks#

ReentrantLock provides fairness options to control how threads acquire locks:

  • Non-fair (default): Threads requesting a lock can “jump the queue” if it becomes available, maximizing throughput but potentially starving some threads.
  • Fair: Threads acquire locks in a first-come, first-served manner.

You can construct a fair lock like so:

Lock fairLock = new ReentrantLock(true);

However, fair locks can reduce performance under heavy contention.


The Wait/Notify Mechanism#

Java allows threads to communicate with each other using the wait(), notify(), and notifyAll() methods inherited from Object. This mechanism operates based on intrinsic locks.

Basic Steps#

  1. A thread (Thread A) acquires the lock (synchronized block) on an object and checks a condition.
  2. If the condition is not met, it calls object.wait(). This releases the lock and places Thread A in the wait set, awaiting notification.
  3. Another thread (Thread B) acquires the same lock and calls object.notify() or object.notifyAll().
  4. When notify() is called, one waiting thread is awakened; with notifyAll(), all waiting threads are awakened. They proceed to re-acquire the lock before resuming.

Classic Producer-Consumer Example#

Consider a producer-consumer scenario using a shared queue:

public class ProducerConsumer {
private static final int MAX_CAPACITY = 5;
private final List<Integer> queue = new ArrayList<>();
public void produce(int value) throws InterruptedException {
synchronized (queue) {
while (queue.size() == MAX_CAPACITY) {
queue.wait(); // Wait if the queue is full
}
queue.add(value);
System.out.println("Produced: " + value);
queue.notifyAll(); // Notify consumers
}
}
public int consume() throws InterruptedException {
synchronized (queue) {
while (queue.isEmpty()) {
queue.wait(); // Wait if the queue is empty
}
int value = queue.remove(0);
System.out.println("Consumed: " + value);
queue.notifyAll(); // Notify producers
return value;
}
}
}

Key notes:

  • We use while (not if) in loops around wait(). This prevents spurious wakeups and re-checks conditions.
  • notifyAll() is typically safer in these scenarios to ensure all waiting threads can re-check their conditions.

Choosing Between wait/notify and Other Constructs#

While wait(), notify(), and notifyAll() are extremely flexible, they can be tricky to use correctly. You must be careful with acquiring and releasing the lock. In many cases, higher-level concurrency utilities (like BlockingQueue, Semaphore, or other classes in java.util.concurrent) can simplify your code, reduce mistakes, and improve maintainability.


Thread-Safe Data Structures and Atomic Classes#

To reduce the programmer’s burden of synchronization, Java provides several ready-made thread-safe classes.

Thread-Safe Data Structures#

  • Collections.synchronizedXxx: Wrappers around standard collections. E.g., Collections.synchronizedList(new ArrayList<>()).
  • Concurrent Collections:
    • ConcurrentHashMap: Allows concurrent reads and updates without locking the entire map.
    • CopyOnWriteArrayList, CopyOnWriteArraySet: Great for scenarios with frequent reads and infrequent writes.
    • ConcurrentLinkedQueue, LinkedBlockingQueue, and other concurrent queues for producer-consumer patterns.

Example using ConcurrentHashMap:

import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentMapExample {
private final ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
public void incrementKey(String key) {
map.merge(key, 1, Integer::sum);
}
public int getValue(String key) {
return map.getOrDefault(key, 0);
}
}

ConcurrentHashMap provides concurrency-friendly operations without the developer needing to handle synchronization explicitly in many cases.

Atomic Classes#

For simple operations on shared variables, the Java java.util.concurrent.atomic package provides classes like:

  • AtomicInteger
  • AtomicLong
  • AtomicReference

Each class offers thread-safe atomic operations (like incrementAndGet(), compareAndSet()). Example:

import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private AtomicInteger count = new AtomicInteger(0);
public int increment() {
return count.incrementAndGet();
}
public int getValue() {
return count.get();
}
}

These classes eliminate the need for your own synchronization when performing discrete atomic updates.


Executors and the Concurrency Framework#

Moving beyond manual thread management, Java’s concurrency framework aims to simplify how we work with pools of threads.

ExecutorService#

ExecutorService provides a higher-level abstraction to manage worker threads:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10; i++) {
final int taskNumber = i;
executor.submit(() -> {
System.out.println("Task " + taskNumber + " is running on "
+ Thread.currentThread().getName());
});
}
executor.shutdown();
}
}
  • You define how many threads you want in a pool (4 in the example).
  • You submit tasks (Runnable or Callable) to the executor.
  • Internally, the executor chooses an available thread from the pool to run the task, or queues the task if all threads are busy.
  • executor.shutdown() initiates a graceful shutdown, allowing ongoing tasks to finish.

Schedulers, ScheduledExecutorService#

Need a task to run periodically? Use ScheduledExecutorService:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduledTaskExample {
public static void main(String[] args) {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// Run a task after a delay of 5 seconds
scheduler.schedule(() -> System.out.println("Delayed task"), 5, TimeUnit.SECONDS);
// Run a task at a fixed rate of 2 seconds
scheduler.scheduleAtFixedRate(() -> System.out.println("Repeated task"),
2,
2,
TimeUnit.SECONDS);
}
}

Future and Callable#

submit() can be used to run tasks that return a value. The Future object can retrieve the result, check if the task is done, or cancel it:

import java.util.concurrent.*;
public class FutureExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<Integer> callableTask = () -> {
TimeUnit.SECONDS.sleep(2);
return 42;
};
Future<Integer> futureResult = executor.submit(callableTask);
try {
// get() will block until the task completes
Integer result = futureResult.get();
System.out.println("Computed value: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
}
}

Advanced Synchronization Techniques#

In addition to basic and intermediate constructs, Java offers more specialized tools.

Semaphores#

A Semaphore manages a set of permits that allow multiple threads to access resources up to a limit. For example, limiting concurrency to a specified number:

import java.util.concurrent.Semaphore;
public class ConnectionLimiter {
private final Semaphore semaphore;
public ConnectionLimiter(int maxConnections) {
semaphore = new Semaphore(maxConnections);
}
public void acquireConnection() throws InterruptedException {
semaphore.acquire();
}
public void releaseConnection() {
semaphore.release();
}
}
  • Only a specified number of threads (e.g., maxConnections) can acquire the semaphore at once.
  • Other threads must wait.

CountDownLatch#

A CountDownLatch makes one or more threads wait until a set of operations done by other threads completes. Example: wait for 5 tasks to finish:

import java.util.concurrent.CountDownLatch;
public class LatchExample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(5);
// Start five tasks in separate threads
for (int i = 0; i < 5; i++) {
new Thread(() -> {
// Perform some work
try { Thread.sleep(1000); } catch (InterruptedException e) {}
latch.countDown();
System.out.println(Thread.currentThread().getName() + " finished.");
}).start();
}
latch.await(); // Wait until all five tasks have called countDown()
System.out.println("All tasks completed.");
}
}

CyclicBarrier#

Similar to CountDownLatch but reusable. A CyclicBarrier makes a group of threads wait at a common barrier point until all have arrived, then the barrier is reset:

import java.util.concurrent.CyclicBarrier;
public class BarrierExample {
public static void main(String[] args) {
int parties = 3;
CyclicBarrier barrier = new CyclicBarrier(parties, () -> {
System.out.println("All parties arrived at barrier. Let's continue...");
});
for (int i = 0; i < parties; i++) {
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + " doing work...");
Thread.sleep((long) (Math.random() * 2000));
barrier.await(); // Wait for all threads
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}

Phaser#

A Phaser is a more flexible synchronization barrier than CyclicBarrier. It can handle dynamic registration of parties and synchronization across multiple phases.


Common Pitfalls and Best Practices#

Synchronization is powerful but also easy to get wrong. Here are some common pitfalls and best practices to steer clear of trouble.

PitfallIssueBest Practice
Single lock for allIf you abuse a single lock for everything, concurrency is reduced.Use finer-grained locks or concurrent collections if possible.
Not using “finally” blocksLock not released if an exception occurs inside try block.Always unlock in a finally block.
Mixing wait/notify across multiple objectsWait on one object, calling notify on another object.Ensure you call wait/notify on the same lock object.
Spurious wakeupsUsing if instead of while in wait loops can break logic.Always loop around wait with while, re-checking conditions.
Visibility issuesNot using a shared lock for all critical sections or forgetting volatile.Ensure consistent synchronization and consider volatile for single variables.
Over-synchronizationSynchronizing more than needed kills performance.Lock the narrowest critical section that ensures correctness.

Other Best Practices#

  1. Immutable objects: If data never changes, you don’t need synchronization. Immutability is a simpler model of concurrency.
  2. Encapsulation: Keep lock objects private and do not leak them outside your class.
  3. Use concurrency-friendly libraries: Avoid reinventing concurrency constructs. Leverage the standard library’s concurrency tools.
  4. Prefer concurrency frameworks over low-level threads: ExecutorService, parallel streams, and other frameworks can greatly simplify concurrency.

Conclusion#

Synchronization in Java is both an art and a science. It starts with understanding the basics of threads, the significance of the “synchronized” keyword, and moves on to advanced locks and concurrency constructs like ReentrantLock, Semaphore, CountDownLatch, and CyclicBarrier. Building efficient, bug-free concurrent applications requires a deep understanding of how locks work, how data visibility is ensured, and how to apply higher-level abstractions provided by the concurrency framework.

Whether you are writing simple multi-threaded code to perform background tasks or architecting a highly concurrent system handling thousands of requests, mastering these synchronization concepts is essential. By leveraging the right tools, structuring your code carefully, and adhering to best practices, you can create Java applications that reliably manage shared data, scale well under load, and remain maintainable over time.

Synchronization is not a one-size-fits-all solution. Often, less is more—apply it judiciously. Utilize the rich offerings of Java’s concurrency library for higher-level constructs, and avoid pitfalls by carefully structuring synchronized blocks, using concurrency-friendly data structures, and ensuring correct lock handling. In the end, the goal is always the same: safe, efficient, and maintainable software that remains correct even when the pressure of multiple threads is at its peak.

The Art of Synchronization: Coordinating Resources in Java
https://science-ai-hub.vercel.app/posts/e35dde23-35af-4f3c-b501-4b302109ff3e/5/
Author
AICore
Published at
2025-04-27
License
CC BY-NC-SA 4.0