Skip to main content

Command Palette

Search for a command to run...

Java Synchronization Mechanisms: A Comprehensive Comparison

Updated
14 min read

Java Synchronization Mechanisms: A Comprehensive Comparison

Introduction

Concurrent programming in Java requires careful management of shared resources. The Java platform provides multiple synchronization mechanisms to prevent data races and ensure thread safety. This blog post explores the nuances of intrinsic locks, ReentrantLock, ReadWriteLock, Semaphore, and other synchronization primitives, helping you choose the right tool for your specific use case.


Table of Contents

  1. Intrinsic Locks (synchronized)
  2. ReentrantLock
  3. ReadWriteLock
  4. ReentrantReadWriteLock
  5. Semaphore
  6. StampedLock
  7. CountDownLatch
  8. CyclicBarrier
  9. Phaser
  10. Comparison Table
  11. Best Practices

Intrinsic Locks {#intrinsic-locks}

Overview

Intrinsic locks (also called monitor locks) are the synchronization mechanism built into the Java language itself. Every object in Java has an associated lock that is automatically managed by the JVM.

Usage

public class Counter {
    private int count = 0;

    // Synchronize on the instance
    public synchronized void increment() {
        count++;
    }

    // Synchronize on a specific object
    public void incrementSafe() {
        synchronized(this) {
            count++;
        }
    }
}

Characteristics

  • Automatic Release: Locks are automatically released when exiting a synchronized block
  • Reentrancy: The same thread can acquire the same lock multiple times
  • No Timeout: Cannot timeout waiting for a lock
  • Simple: Easy to use and understand
  • JVM Optimizations: Subject to optimization (biased locking, lock coarsening)

Advantages

✅ Simple and straightforward syntax
✅ Automatically released (no risk of forgetting to unlock)
✅ Reentrant by default
✅ Efficient for uncontended locks

Disadvantages

❌ Cannot check if lock is available without attempting to acquire it
❌ No timeout support
❌ Must use nested synchronized blocks for multiple locks (deadlock risk)
❌ Less flexible than explicit locks

Example

public class BankAccount {
    private double balance = 0;

    public synchronized void deposit(double amount) {
        balance += amount;
    }

    public synchronized void withdraw(double amount) {
        balance -= amount;
    }

    public synchronized double getBalance() {
        return balance;
    }
}

ReentrantLock {#reentrantlock}

Overview

ReentrantLock is an explicit implementation of the Lock interface that provides similar functionality to intrinsic locks but with additional features and flexibility.

Usage

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Lock;

public class Counter {
    private int count = 0;
    private final Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

Characteristics

  • Explicit Control: Manual lock and unlock
  • Reentrancy: Same thread can acquire multiple times
  • Fairness: Optional fair queuing policy (FIFO)
  • Timeout Support: Can timeout waiting for lock
  • Interruptible: Can be interrupted while waiting
  • Try Lock: Can attempt non-blocking lock acquisition

Advantages

✅ Supports timeout with tryLock(long, TimeUnit)
✅ Interruptible via lockInterruptibly()
✅ Can check lock status with tryLock()
✅ Fair scheduling option available
✅ Works with Conditions for advanced synchronization

Disadvantages

❌ Manual unlock required (easy to forget)
❌ More verbose than synchronized
❌ Slightly higher overhead than intrinsic locks

Example

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TimedLockExample {
    private final ReentrantLock lock = new ReentrantLock();
    private int value = 0;

    public void updateValue(int newValue) throws InterruptedException {
        if (lock.tryLock(5, TimeUnit.SECONDS)) {
            try {
                value = newValue;
                System.out.println("Value updated to: " + newValue);
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println("Could not acquire lock within timeout");
        }
    }

    public void interruptibleUpdate(int newValue) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            value = newValue;
        } finally {
            lock.unlock();
        }
    }
}

ReadWriteLock {#readwritelock}

Overview

ReadWriteLock maintains a pair of locks: one for read access and one for write access. Multiple threads can hold the read lock simultaneously, but only one thread can hold the write lock.

Characteristics

  • Concurrent Reads: Multiple threads can read simultaneously
  • Exclusive Writes: Only one thread can write, and no reads happen during writes
  • Improved Throughput: Better performance when reads greatly outnumber writes
  • Read-Write Lock Semantics: Readers don't block each other

Usage

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class DataCache {
    private String data = "";
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

    public String getData() {
        rwLock.readLock().lock();
        try {
            return data;
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public void setData(String newData) {
        rwLock.writeLock().lock();
        try {
            data = newData;
        } finally {
            rwLock.writeLock().unlock();
        }
    }
}

Advantages

✅ Excellent for read-heavy workloads
✅ Multiple concurrent readers
✅ Exclusive write access
✅ Prevents writer starvation

Disadvantages

❌ More overhead than simple locks
❌ Not beneficial when reads and writes are balanced
❌ More complex to understand and use


ReentrantReadWriteLock {#reentrantreadwritelock}

Overview

ReentrantReadWriteLock is the standard implementation of ReadWriteLock in Java. It provides reentrancy for both read and write locks with optional fairness.

Example

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class CachedData {
    private String value = "";
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public String getValue() {
        lock.readLock().lock();
        try {
            return value;
        } finally {
            lock.readLock().unlock();
        }
    }

    public void setValue(String newValue) {
        lock.writeLock().lock();
        try {
            value = newValue;
        } finally {
            lock.writeLock().unlock();
        }
    }

    // Downgrade write lock to read lock
    public void processData() {
        lock.writeLock().lock();
        try {
            // Perform write operation
            value = "processed";

            // Acquire read lock before releasing write lock (downgrade)
            lock.readLock().lock();
        } finally {
            lock.writeLock().unlock();
        }

        try {
            // Now we only hold the read lock
            System.out.println("Current value: " + value);
        } finally {
            lock.readLock().unlock();
        }
    }
}

Semaphore {#semaphore}

Overview

A Semaphore maintains a set of permits. Threads call acquire() to wait for a permit and release() to release one. When no permits are available, acquire() blocks the thread.

Characteristics

  • Permit-Based: Controls access through a pool of permits
  • Resource Pooling: Useful for limiting concurrent access to a resource pool
  • Counting Semaphore: Maintains a count of permits
  • Binary Semaphore: Acts as a lock when permit count = 1
  • Non-Reentrant: By default, doesn't allow the same thread to acquire twice

Usage

import java.util.concurrent.Semaphore;

public class ConnectionPool {
    private final Semaphore semaphore = new Semaphore(5); // 5 connections available

    public void executeTask() throws InterruptedException {
        semaphore.acquire(); // Wait for a permit
        try {
            System.out.println("Executing task with connection");
            // Simulate work
            Thread.sleep(1000);
        } finally {
            semaphore.release(); // Release the permit
        }
    }
}

Advantages

✅ Excellent for resource pooling
✅ Simple counting mechanism
✅ Can be used as a binary lock (Mutex)
✅ Supports timeout with tryAcquire(long, TimeUnit)

Disadvantages

❌ Not reentrant (unless you track permits manually)
❌ Less intuitive than locks for mutual exclusion
❌ No condition variables

Binary Semaphore (Mutex)

import java.util.concurrent.Semaphore;

public class MutexExample {
    private final Semaphore mutex = new Semaphore(1); // Binary semaphore
    private int counter = 0;

    public void incrementCounter() throws InterruptedException {
        mutex.acquire();
        try {
            counter++;
        } finally {
            mutex.release();
        }
    }
}

StampedLock {#stampedlock}

Overview

StampedLock is a high-performance lock introduced in Java 8 that provides three modes: write lock, read lock, and optimistic read. The optimistic read mode doesn't acquire a lock at all but checks a "stamp" to detect conflicts.

Characteristics

  • Optimistic Reading: Can read without acquiring a lock
  • Stamp-Based Validation: Uses stamps to detect write conflicts
  • High Performance: Designed for low-contention scenarios
  • Three Modes: Optimistic read, pessimistic read, write
  • Non-Reentrant: Cannot be reacquired by the same thread

Usage

import java.util.concurrent.locks.StampedLock;

public class OptimizedData {
    private int value = 0;
    private final StampedLock lock = new StampedLock();

    // Optimistic read - fast path
    public int readOptimistic() {
        long stamp = lock.tryOptimisticRead();
        int result = value;

        if (!lock.validate(stamp)) {
            // Conflict detected, fall back to pessimistic read
            stamp = lock.readLock();
            try {
                result = value;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return result;
    }

    // Pessimistic read
    public int readPessimistic() {
        long stamp = lock.readLock();
        try {
            return value;
        } finally {
            lock.unlockRead(stamp);
        }
    }

    // Write lock
    public void write(int newValue) {
        long stamp = lock.writeLock();
        try {
            value = newValue;
        } finally {
            lock.unlockWrite(stamp);
        }
    }
}

Advantages

✅ Excellent performance for read-heavy, low-contention scenarios
✅ Optimistic read avoids blocking
✅ Lower overhead than ReentrantReadWriteLock

Disadvantages

❌ Not reentrant
❌ More complex API
❌ Should only be used when you understand the implications
❌ Optimistic reads can fail (need validation logic)


CountDownLatch {#countdownlatch}

Overview

CountDownLatch is a synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.

Characteristics

  • One-Time Use: Cannot be reset once count reaches zero
  • Countdown Mechanism: Count decrements with each countDown() call
  • Blocking Wait: await() blocks until count reaches zero
  • Simple Coordination: Good for one-off synchronization

Usage

import java.util.concurrent.CountDownLatch;

public class ParallelProcessing {
    public static void main(String[] args) throws InterruptedException {
        int numWorkers = 3;
        CountDownLatch latch = new CountDownLatch(numWorkers);

        for (int i = 0; i < numWorkers; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " is working");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                System.out.println(Thread.currentThread().getName() + " finished");
                latch.countDown();
            }).start();
        }

        latch.await(); // Wait for all workers to finish
        System.out.println("All workers completed!");
    }
}

Advantages

✅ Simple and intuitive for one-off synchronization
✅ Lightweight and efficient
✅ Supports timeout with await(long, TimeUnit)

Disadvantages

❌ Cannot be reused (count cannot be reset)
❌ Only useful for one-time events
❌ Not suitable for repeating synchronization


CyclicBarrier {#cyclicbarrier}

Overview

CyclicBarrier allows a fixed number of parties to wait for each other at a barrier point. Unlike CountDownLatch, it can be reused.

Characteristics

  • Reusable: Can be used multiple times (cyclic)
  • Mutual Waiting: All threads wait for each other
  • Barrier Action: Optional action executed when all parties arrive
  • Fixed Number of Parties: Number of threads must be known upfront

Usage

import java.util.concurrent.CyclicBarrier;

public class BarrierExample {
    public static void main(String[] args) {
        int numThreads = 3;
        CyclicBarrier barrier = new CyclicBarrier(numThreads, () -> {
            System.out.println("All threads have reached the barrier!");
        });

        for (int i = 0; i < numThreads; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " is working");
                    Thread.sleep((long)(Math.random() * 3000));
                    System.out.println(Thread.currentThread().getName() + " reached barrier");
                    barrier.await(); // Wait for others
                    System.out.println(Thread.currentThread().getName() + " passed barrier");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

Advantages

✅ Reusable (cyclic)
✅ Symmetric - all threads wait for each other
✅ Optional barrier action supports coordinated tasks
✅ Good for iterative synchronization

Disadvantages

❌ All threads must wait (no timeout)
❌ Breaking the barrier (exception) may cause issues
❌ Less flexible than Phaser


Phaser {#phaser}

Overview

Phaser is a more flexible version of CyclicBarrier and CountDownLatch combined. It supports dynamic party registration and can be reused across multiple phases.

Characteristics

  • Flexible Parties: Can dynamically register/deregister threads
  • Multiple Phases: Can synchronize through multiple phases
  • Phased Execution: Better control over multi-phase synchronization
  • Timeout Support: Can timeout waiting for a phase to complete
  • Terminal Phase: Can handle completion elegantly

Usage

import java.util.concurrent.Phaser;

public class PhaserExample {
    public static void main(String[] args) {
        Phaser phaser = new Phaser(1); // Register the main thread

        for (int i = 0; i < 3; i++) {
            phaser.register(); // Register each worker
            new Thread(() -> {
                for (int phase = 0; phase < 3; phase++) {
                    System.out.println(Thread.currentThread().getName() + 
                        " working on phase " + phase);
                    try {
                        Thread.sleep((long)(Math.random() * 2000));
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    System.out.println(Thread.currentThread().getName() + 
                        " finished phase " + phase);
                    phaser.arriveAndAwaitAdvance(); // Move to next phase
                }
            }).start();
        }

        phaser.arriveAndAwaitAdvance(); // Advance from phase 0
        System.out.println("Phase 0 complete!");

        phaser.arriveAndAwaitAdvance(); // Advance from phase 1
        System.out.println("Phase 1 complete!");

        phaser.arriveAndAwaitAdvance(); // Advance from phase 2
        System.out.println("Phase 2 complete!");
    }
}

Advantages

✅ Dynamic party registration
✅ Multiple phases support
✅ Timeout support with awaitAdvanceInterruptibly()
✅ Elegant handling of completion
✅ Most flexible synchronization primitive

Disadvantages

❌ More complex API
❌ Slight overhead compared to simpler mechanisms
❌ Requires careful understanding of phase advancement


Comparison Table {#comparison-table}

FeatureIntrinsic LockReentrantLockReadWriteLockSemaphoreStampedLockCountDownLatchCyclicBarrierPhaser
Mutual Exclusion✅ (Write)
ReentrancyN/AN/AN/A
Fairness OptionN/AN/AN/A
Timeout Support
Interruptible
Conditions
Read-Write Separation
Concurrent ReadsN/AN/AN/A
Reusable
Dynamic PartiesN/AN/AN/AN/AN/AN/A
Multiple PhasesN/AN/AN/AN/AN/A
Performance⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡
Complexity⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

Best Practices {#best-practices}

1. Choose the Right Tool

  • Simple mutual exclusion: Use synchronized for simplicity
  • Advanced locking needs: Use ReentrantLock for flexibility
  • Read-heavy workloads: Use ReadWriteLock or StampedLock
  • Resource pooling: Use Semaphore
  • One-time coordination: Use CountDownLatch
  • Iterative synchronization: Use CyclicBarrier
  • Multi-phase synchronization: Use Phaser

2. Always Release Locks

Lock lock = new ReentrantLock();
lock.lock();
try {
    // Critical section
} finally {
    lock.unlock(); // Always execute in finally
}

3. Avoid Deadlocks

// ❌ BAD: Risk of deadlock with nested locks
lock1.lock();
lock2.lock();

// ✅ GOOD: Consistent lock ordering
if (lock1.getId() < lock2.getId()) {
    lock1.lock();
    lock2.lock();
} else {
    lock2.lock();
    lock1.lock();
}

4. Use Try-Finally or Try-With-Resources

// ✅ GOOD: Modern approach with try-with-resources (if available)
try (Locker locked = new Locker(lock)) {
    // Critical section
}

// ✅ GOOD: Traditional try-finally
lock.lock();
try {
    // Critical section
} finally {
    lock.unlock();
}

5. Consider High-Level Abstractions

// ❌ Low-level: Manual synchronization
// ✅ High-level: Collections.synchronizedList or ConcurrentHashMap
List<String> list = Collections.synchronizedList(new ArrayList<>());

6. Profile Before Optimizing

StampedLock and other advanced locks introduce complexity. Profile your application to ensure the performance gains justify the complexity.

// ❌ Premature optimization
private final StampedLock lock = new StampedLock(); // Might be overkill

// ✅ Thoughtful choice based on workload analysis
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); // Suitable for the workload

7. Document Lock Ordering

/**
 * Locks must be acquired in this order to prevent deadlocks:
 * 1. accountLock
 * 2. transactionLock
 */
public void transfer(Account from, Account to, double amount) {
    // Lock in documented order
}

8. Use java.util.concurrent Collections

// ✅ Preferred: Concurrent collections handle synchronization internally
Map<String, Integer> map = new ConcurrentHashMap<>();
List<String> list = new CopyOnWriteArrayList<>();
Queue<String> queue = new ConcurrentLinkedQueue<>();

Summary

Java provides multiple synchronization mechanisms, each suited for different scenarios:

  • synchronized (Intrinsic Locks): Simple, built-in, suitable for basic synchronization
  • ReentrantLock: Flexible, feature-rich alternative to synchronized with timeout and interruptibility
  • ReadWriteLock: Optimized for read-heavy workloads with concurrent readers
  • Semaphore: Perfect for resource pooling and limiting concurrent access
  • StampedLock: High-performance option for low-contention scenarios with optimistic reads
  • CountDownLatch: Simple one-time synchronization for waiting on completion
  • CyclicBarrier: For reusable barrier points where threads wait for each other
  • Phaser: Most flexible for multi-phase, dynamic synchronization scenarios

The key is understanding your workload and choosing the appropriate primitive that provides the necessary functionality with minimal complexity. In most cases, simple solutions like synchronized or the concurrent collections are sufficient and preferable.


Interactive Demos

This blog post comes with comprehensive, runnable demonstrations of each synchronization mechanism. Each demo includes:

  • Real-world scenarios (bank accounts, caches, connection pools, pipelines, game rounds)
  • Best practices with detailed comments
  • Multiple example classes per synchronization type
  • Performance comparisons where applicable
  • Exception handling and timeout patterns

Demo Files

  1. IntrinsicLockDemo - Bank accounts, thread-safe counters, and caches
  2. ReentrantLockDemo - Timeout support, producer-consumer queues, and fair scheduling
  3. ReadWriteLockDemo - Concurrent readers with performance comparison
  4. SemaphoreDemo - Connection pooling, rate limiting, and thread pool control
  5. StampedLockDemo - Optimistic reads with pessimistic fallback
  6. CountDownLatchDemo - Parallel task execution and pipeline coordination
  7. CyclicBarrierDemo - Iterative processing and game round synchronization
  8. PhaserDemo - Multi-phase execution and dynamic party management

Running the Demos

Run the interactive launcher:

java info.mnafshin.locks_in_java.demos.DemoLauncher

Or run individual demos:

java info.mnafshin.locks_in_java.demos.IntrinsicLockDemo
java info.mnafshin.locks_in_java.demos.ReentrantLockDemo
java info.mnafshin.locks_in_java.demos.SemaphoreDemo
# ... etc

See DEMOS_README.md for detailed documentation and usage instructions for each demo.


Further Reading

  • Java Concurrency in Practice (Book)
  • Java API Documentation: java.util.concurrent package
  • Oracle Java Tutorials on Concurrency

Get the Code

The source code for this project is available on GitHub: https://github.com/mnafshin/locks-in-java.git