Java Synchronization Mechanisms: A Comprehensive Comparison
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
- Intrinsic Locks (synchronized)
- ReentrantLock
- ReadWriteLock
- ReentrantReadWriteLock
- Semaphore
- StampedLock
- CountDownLatch
- CyclicBarrier
- Phaser
- Comparison Table
- 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}
| Feature | Intrinsic Lock | ReentrantLock | ReadWriteLock | Semaphore | StampedLock | CountDownLatch | CyclicBarrier | Phaser |
| Mutual Exclusion | ✅ | ✅ | ✅ (Write) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Reentrancy | ✅ | ✅ | ✅ | ❌ | ❌ | N/A | N/A | N/A |
| Fairness Option | ❌ | ✅ | ✅ | ❌ | ❌ | N/A | N/A | N/A |
| Timeout Support | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| Interruptible | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ |
| Conditions | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Read-Write Separation | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| Concurrent Reads | ❌ | ❌ | ✅ | ❌ | ✅ | N/A | N/A | N/A |
| Reusable | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| Dynamic Parties | N/A | N/A | N/A | N/A | N/A | N/A | ❌ | ✅ |
| Multiple Phases | N/A | N/A | N/A | N/A | N/A | ❌ | ✅ | ✅ |
| Performance | ⚡⚡⚡ | ⚡⚡ | ⚡⚡ | ⚡⚡⚡ | ⚡⚡⚡⚡ | ⚡⚡⚡ | ⚡⚡⚡ | ⚡⚡⚡ |
| Complexity | ⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐ | ⭐⭐ | ⭐⭐⭐ |
Best Practices {#best-practices}
1. Choose the Right Tool
- Simple mutual exclusion: Use
synchronizedfor simplicity - Advanced locking needs: Use
ReentrantLockfor flexibility - Read-heavy workloads: Use
ReadWriteLockorStampedLock - 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 synchronizationReentrantLock: Flexible, feature-rich alternative to synchronized with timeout and interruptibilityReadWriteLock: Optimized for read-heavy workloads with concurrent readersSemaphore: Perfect for resource pooling and limiting concurrent accessStampedLock: High-performance option for low-contention scenarios with optimistic readsCountDownLatch: Simple one-time synchronization for waiting on completionCyclicBarrier: For reusable barrier points where threads wait for each otherPhaser: 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
- IntrinsicLockDemo - Bank accounts, thread-safe counters, and caches
- ReentrantLockDemo - Timeout support, producer-consumer queues, and fair scheduling
- ReadWriteLockDemo - Concurrent readers with performance comparison
- SemaphoreDemo - Connection pooling, rate limiting, and thread pool control
- StampedLockDemo - Optimistic reads with pessimistic fallback
- CountDownLatchDemo - Parallel task execution and pipeline coordination
- CyclicBarrierDemo - Iterative processing and game round synchronization
- 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.concurrentpackage - 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