Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 27

Concurrency Control: `synchronized` vs. `ReentrantLock`

Recommended resources

Hello! Welcome to our next lesson on advanced Java concurrency.

In our previous session, we learned how to create and manage pools of threads using ExecutorService. This allows us to execute many tasks in parallel, which is essential for building scalable applications. However, this power comes with a critical challenge: what happens when multiple threads need to access and modify the same data simultaneously? Without proper coordination, this leads to data corruption and unpredictable behavior known as a race condition.

Today, you will learn to solve race conditions using the 'synchronized' keyword and ReentrantLock, and explain their differences. This is a cornerstone of concurrent programming and a frequent topic in senior-level Java interviews. Mastering these tools is crucial for ensuring data integrity in any multi-threaded application.

1. The Problem: Race Conditions

A race condition occurs when two or more threads access shared mutable data, and at least one of them is writing. The final outcome depends on the unpredictable order in which the threads are scheduled by the operating system.

Let's start with a classic example: a shared counter.

class Counter {
    private int count = 0;

    public void increment() {
        count++; // This is not an atomic operation
    }

    public int getCount() {
        return count;
    }
}

public class RaceConditionDemo {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        Counter counter = new Counter();

        for (int i = 0; i < 1000; i++) {
            executor.submit(() -> counter.increment());
        }

        executor.shutdown();
        executor.awaitTermination(60, TimeUnit.SECONDS);

        System.out.println("Final count is: " + counter.getCount());
        // Expected result: 1000
        // Actual result: Something less than 1000
    }
}

If you run this code, you'll rarely see the output "1000". Why? The operation count++ looks like a single step, but it's actually three separate operations:

  1. Read: Read the current value of count.
  2. Modify: Add one to the value.
  3. Write: Write the new value back to count.

If Thread A reads count (e.g., 50), then gets paused, and Thread B reads count (still 50), increments it to 51, and writes it back, Thread A will eventually wake up, increment the value it originally read (50) to 51, and write that back. Two increments resulted in the value only increasing by one.

To solve this, we need to ensure two things:

  • Mutual Exclusion (Atomicity): The read-modify-write sequence must be an atomic operation. Only one thread can execute this critical section of code at a time.
  • Visibility: Changes made by one thread must be visible to other threads.

The Volatile and Synchronized Keywords in Java | Atomic Variables | Java Multithreading | Geekific

This short video from Geekific introduces these two fundamental concepts and explains how race conditions occur.

Watch from 00:39 to 01:39. Focus on the definitions of 'Visibility' and 'Mutual Exclusion' (Atomicity). This is the theoretical foundation for everything we'll discuss today.


2. The synchronized Keyword: Intrinsic Locking

Java's original solution to this problem is the synchronized keyword. It provides an intrinsic lock (or monitor lock) associated with every Java object.

You can use synchronized in two ways:

  1. Synchronized Method:

    public synchronized void increment() {
        count++;
    }
    

    When a method is synchronized, a thread must acquire the lock of the object instance (this) before executing it.

  2. Synchronized Block:

    private final Object lock = new Object(); // A dedicated lock object
    
    public void increment() {
        synchronized (lock) {
            count++;
        }
    }
    

    This provides more granular control. A thread must acquire the lock of the object specified in the parentheses. Using a dedicated private lock object is often a good practice to avoid unintended locking behavior if other parts of your code lock on this.

synchronized elegantly solves both of our requirements:

  • It enforces mutual exclusion.
  • It guarantees visibility. When a thread leaves a synchronized block, it flushes all variable changes to main memory. When a thread enters a synchronized block, it refreshes its variable view from main memory.

The Volatile and Synchronized Keywords in Java | Atomic Variables | Java Multithreading | Geekific

Let's revisit the Geekific video to see a practical demonstration of how synchronized solves a race condition.

Watch from 03:44 to 05:01 and from 07:56 to 08:39. These sections show how to apply the synchronized keyword to ensure both mutual exclusion and visibility, making an operation thread-safe.

The key advantage of synchronized is its simplicity. The JVM automatically handles acquiring and releasing the lock, even if an exception occurs.


3. ReentrantLock: An Explicit, Flexible Lock

Introduced in Java 5 as part of the java.util.concurrent package, ReentrantLock is a more powerful and flexible locking mechanism.

Java ReentrantLock Conceptual Graph
This diagram illustrates how a `ReentrantLock` works. One thread acquires the lock, while other threads wait in a queue until the lock is released.

Using ReentrantLock requires more boilerplate, but gives you more control.

import java.util.concurrent.locks.ReentrantLock;

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

    public void increment() {
        lock.lock(); // Acquire the lock
        try {
            count++;
        } finally {
            lock.unlock(); // ALWAYS release the lock in a finally block
        }
    }
    // ...
}

The try-finally block here is non-negotiable. If you don't release the lock in a finally block and an exception occurs in the try block, the lock will be held forever, causing other threads to block indefinitely—a severe bug known as deadlock or resource starvation.

Java ReentrantLock - fairness, tryLock and more

This video from Defog Tech provides an excellent deep dive into ReentrantLock. It covers its basic usage, the importance of try-finally, and its advanced features.

Watch from the beginning to 08:26. This is a crucial section. Pay close attention to: The concept of a lock to protect a shared resource (00:00 - 03:12). How ReentrantLock syntax compares to synchronized (03:12 - 05:57). The critical need for the try-finally pattern (05:57 - 06:51). The meaning of 'reentrant' (06:51 - 08:26). Both synchronized and ReentrantLock are reentrant, meaning a thread can re-acquire a lock it already holds.


4. synchronized vs. ReentrantLock: A Detailed Comparison

This is a classic senior-level interview question. Your ability to articulate the trade-offs demonstrates a deep understanding of Java concurrency. While synchronized is simple, ReentrantLock provides advanced capabilities that synchronized lacks.

Synchronized vs ReentrantLock Comparison
This table provides a side-by-side comparison of the key differences between the `synchronized` keyword and the `ReentrantLock` class.

Let's explore the key features exclusive to ReentrantLock:

  • Timed and Interruptible Lock Acquisition: A thread can try to acquire a lock without waiting forever.
    • tryLock(): Tries to get the lock immediately and returns true or false.
    • tryLock(long timeout, TimeUnit unit): Tries to get the lock within a given time.
    • lockInterruptibly(): Waits for the lock but can be interrupted by another thread.
  • Fairness Policy: By default, both synchronized and ReentrantLock are "unfair," meaning a newly arriving thread can "barge in" and acquire a lock before threads that have been waiting longer. This is done for higher throughput. ReentrantLock allows you to create a "fair" lock (new ReentrantLock(true)) that guarantees a first-in, first-out (FIFO) acquisition order, preventing thread starvation at the cost of some performance.

Java ReentrantLock - fairness, tryLock and more

The Defog Tech video you just watched has excellent segments explaining these advanced features.

Now watch from 08:26 to 13:02. This part covers: Fairness: The difference between fair and unfair locks and the performance implications (08:26 - 10:52). tryLock(): The non-blocking and timed-wait variations for acquiring a lock (10:52 - 13:02).

Test your understanding!

Imagine you are designing a system where multiple worker threads need to acquire a lock on a shared resource. To prevent the system from becoming unresponsive, if a thread cannot acquire the lock within 500 milliseconds, it should log a warning and try a different task. Which locking mechanism, synchronized or ReentrantLock, would you choose and why?

Show answer

ReentrantLock is the only choice here. The requirement to attempt acquiring a lock with a timeout can only be fulfilled by lock.tryLock(500, TimeUnit.MILLISECONDS). The synchronized keyword does not provide any mechanism for a timed or non-blocking lock acquisition; a thread waiting for a synchronized lock will block indefinitely until it is acquired.


5. Performance: A Modern Perspective

There is a common, but outdated, belief that ReentrantLock is always faster than synchronized. In modern JVMs (Java 8+), this is no longer true for many common scenarios.

The JVM can apply powerful optimizations to synchronized because it is a native keyword. The primary optimization is lock escalation. Based on the level of contention, the JVM can dynamically change the type of lock it uses:

  • Thin Lock: In low-contention scenarios, the JVM uses an optimistic, low-overhead spinning technique (using Compare-And-Set atomic instructions) without involving the OS. This is very fast.
  • Fat Lock: When contention becomes high, the lock "inflates" to a "fat" lock, which uses a heavier, OS-level mutex. This puts waiting threads to sleep, which is more efficient than burning CPU cycles by spinning when the lock is held for a long time.

ReentrantLock, being a library class, cannot benefit from these deep JVM optimizations in the same way. It is always implemented using the Compare-And-Set mechanism.

How the Humble Synchronized beats ReentrantLock

This article provides a fantastic, simplified explanation of why synchronized can often outperform ReentrantLock in modern Java, a key insight for a senior-level candidate.

Read the sections titled 'Synchronized is superior to ReentrantLock in most situations' and 'So why Intrinsic (synchronized) Locking can outshine ReentrantLock mostly?'. Focus on understanding the concept of lock escalation and why synchronized's integration with the JVM gives it a performance advantage in many cases.


6. The Rule of Thumb: When to Use Which

Given the performance discussion, here is a practical guideline for your interviews and your code:

  1. Default to synchronized: For simple mutual exclusion, prefer synchronized. It is cleaner, less error-prone (no risk of forgetting unlock()), and modern JVMs optimize it heavily.
  2. Use ReentrantLock for its features: Choose ReentrantLock only when you need its specific advanced capabilities:
    • Timed lock waiting (tryLock).
    • Interruptible lock waiting (lockInterruptibly).
    • A non-blocking lock attempt (tryLock).
    • A fair queuing policy.
    • Advanced coordination with Condition objects (which we'll see in a future lesson on the producer-consumer pattern).

If you don't need any of these features, using ReentrantLock adds complexity without a clear benefit.


Conclusion

You now have a solid understanding of the two primary mechanisms for enforcing mutual exclusion in Java. Protecting shared mutable state is at the heart of concurrent programming, and being able to discuss these tools with nuance is a hallmark of a senior developer.

Key Takeaways for Your Interview:

  • A race condition occurs when multiple threads access shared mutable state concurrently, leading to incorrect results. It's solved with mutual exclusion and ensuring visibility.
  • synchronized is a simple, built-in keyword that provides intrinsic locking. Its main advantages are simplicity and automatic lock release.
  • ReentrantLock is a flexible API-based lock that requires manual management (try-finally). Its main advantages are advanced features like fairness policies, timed waits (tryLock), and interruptible locking.
  • In modern JVMs, synchronized is highly optimized through lock escalation and should be the default choice unless the advanced features of ReentrantLock are explicitly required.

Preview of the Next Lesson:
We've now tackled atomicity with locks. However, sometimes you don't need full mutual exclusion; you just need to ensure that changes to a variable made by one thread are immediately visible to others. In our next lesson, we will explore this exact scenario by covering how to solve memory visibility problems using the 'volatile' keyword.